2012-10-01 17 views
10

Sto scrivendo un servizio Web che restituisce oggetti contenenti elenchi molto lunghi, codificati in JSON. Ovviamente vogliamo usare gli iteratori piuttosto che gli elenchi Python in modo da poter trasmettere gli oggetti da un database; sfortunatamente, il codificatore JSON nella libreria standard (json.JSONEncoder) accetta solo elenchi e tuple da convertire in elenchi JSON (anche se _iterencode_list sembra che funzioni effettivamente su qualsiasi iterabile).Programmatori JSON iteratori molto lunghi

Le docstrings suggeriscono di sovrascrivere il valore predefinito per convertire l'oggetto in un elenco, ma questo significa che perdiamo i vantaggi dello streaming. Precedentemente, abbiamo annullato un metodo privato, ma (come si poteva prevedere) che si è rotto quando il codificatore è stato refactored.

Qual è il modo migliore per serializzare gli iteratori come elenchi JSON in Python in modo streaming?

risposta

3

Mi serviva esattamente questo. Il primo approccio era l'override del metodo JSONEncoder.iterencode(). Tuttavia questo non funziona perché non appena l'iteratore non è toplevel, subentra l'interno di qualche funzione _iterencode().

Dopo aver studiato il codice, ho trovato una soluzione molto hacky, ma funziona.Python 3 solo, ma sono sicuro che la stessa magia è possibile con Python 2 (solo altri nomi magic-metodo):

import collections.abc 
import json 
import itertools 
import sys 
import resource 
import time 
starttime = time.time() 
lasttime = None 


def log_memory(): 
    if "linux" in sys.platform.lower(): 
     to_MB = 1024 
    else: 
     to_MB = 1024 * 1024 
    print("Memory: %.1f MB, time since start: %.1f sec%s" % (
     resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/to_MB, 
     time.time() - starttime, 
     "; since last call: %.1f sec" % (time.time() - lasttime) if lasttime 
     else "", 
    )) 
    globals()["lasttime"] = time.time() 


class IterEncoder(json.JSONEncoder): 
    """ 
    JSON Encoder that encodes iterators as well. 
    Write directly to file to use minimal memory 
    """ 
    class FakeListIterator(list): 
     def __init__(self, iterable): 
      self.iterable = iter(iterable) 
      try: 
       self.firstitem = next(self.iterable) 
       self.truthy = True 
      except StopIteration: 
       self.truthy = False 

     def __iter__(self): 
      if not self.truthy: 
       return iter([]) 
      return itertools.chain([self.firstitem], self.iterable) 

     def __len__(self): 
      raise NotImplementedError("Fakelist has no length") 

     def __getitem__(self, i): 
      raise NotImplementedError("Fakelist has no getitem") 

     def __setitem__(self, i): 
      raise NotImplementedError("Fakelist has no setitem") 

     def __bool__(self): 
      return self.truthy 

    def default(self, o): 
     if isinstance(o, collections.abc.Iterable): 
      return type(self).FakeListIterator(o) 
     return super().default(o) 

print(json.dumps((i for i in range(10)), cls=IterEncoder)) 
print(json.dumps((i for i in range(0)), cls=IterEncoder)) 
print(json.dumps({"a": (i for i in range(10))}, cls=IterEncoder)) 
print(json.dumps({"a": (i for i in range(0))}, cls=IterEncoder)) 


log_memory() 
print("dumping 10M numbers as incrementally") 
with open("/dev/null", "wt") as fp: 
    json.dump(range(10000000), fp, cls=IterEncoder) 
log_memory() 
print("dumping 10M numbers built in encoder") 
with open("/dev/null", "wt") as fp: 
    json.dump(list(range(10000000)), fp) 
log_memory() 

risultati:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 
[] 
{"a": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]} 
{"a": []} 
Memory: 8.4 MB, time since start: 0.0 sec 
dumping 10M numbers as incrementally 
Memory: 9.0 MB, time since start: 8.6 sec; since last call: 8.6 sec 
dumping 10M numbers built in encoder 
Memory: 395.5 MB, time since start: 17.1 sec; since last call: 8.5 sec 

E 'chiaro a vedere che l'IterEncoder fa Non serve la memoria per memorizzare 10 M inte, mantenendo la stessa velocità di codifica.

Il trucco (hacky) è che lo _iterencode_list in realtà non ha bisogno di nessuna delle cose dell'elenco. Vuole solo sapere se l'elenco è vuoto (__bool__) e quindi ottenere il suo iteratore. Tuttavia arriva a questo codice solo quando isinstance(x, (list, tuple)) restituisce True. Quindi sto impacchettando l'iteratore in una sottoclasse di lista, poi disabilitando tutti gli accessi casuali, facendo in modo che il primo elemento sia in primo piano in modo che io sappia se è vuoto o meno, e restituisce l'iteratore. Quindi il metodo default restituisce questa lista falsa in caso di iteratore.

+1

+1 È un'ottima soluzione che mi ha spinto a scrivere una simile variante più corta di FakeListIterator nella mia [risposta a una domanda simile] (https://stackoverflow.com/a/46841935/448 mila quattrocentosettantaquattro). Non ha bisogno di generare eccezioni e tutto funziona perfettamente, tra cui '__len__',' __bool__', '__repr__' ecc. Che non sono sovrascritti. – hynekcer

+0

btw se si sta solo sovrascrivendo il metodo 'default' di' JSONEncoder' non è necessario creare una sottoclasse, basta definire una funzione e passarla al kwarg 'default' di' json.dumps (obj, default = .. .) ' – cowbert

-1

Non è così semplice. Il protocollo WSGI (che è quello che molti usano) non supporta lo streaming. E i server che lo supportano stanno violando le specifiche.

E anche se si utilizza un server non conforme, è necessario utilizzare qualcosa come ijson. prendere anche uno sguardo a questo ragazzo che aveva lo stesso problema, come si http://www.enricozini.org/2011/tips/python-stream-json/

EDIT: (?) Poi tutto si riduce al cliente, che suppongo che sarà scritto in Javascript. Ma non vedo come sia possibile costruire oggetti javascript (o qualsiasi altra lingua) da chunk incompleti JSON. L'unica cosa che posso pensare è di suddividere manualmente il JSON lungo in oggetti JSON più piccoli (sul lato server) e poi di riversarlo, uno alla volta, sul client. Ma ciò richiede websocket e non richieste/risposte http stateless. E se per servizio web intendi un'API REST, allora suppongo che non sia quello che vuoi.

+1

La mia domanda non ha davvero nulla a che fare con WSGI. ijson è un parser, non un codificatore. – Max

+0

Nulla in PEP 3333 (il protocollo WSGI) proibisce le risposte di streaming. L'unica condizione è che il server WSGI non debba bufferizzare o bloccare internamente una volta che inizia a scrivere sul client. La maggior parte dei server WSGI chiama continuamente fflush (3) o astrazione comparabile sullo zoccolo di uscita al ricevimento di un puntatore da un'applicazione, quindi è perfettamente accettabile iterare su un generatore (l'oggetto ceduto viene immediatamente inviato al socket dopo la serializzazione). – cowbert

0

Lo streaming reale non è supportato da json, poiché significherebbe anche che l'applicazione client dovrà supportare lo streaming. Ci sono alcune librerie java che supportano la lettura di stream in streaming json, ma non è molto generico. Esistono anche alcuni collegamenti Python per yail, che è una libreria C che supporta lo streaming.

Forse è possibile utilizzare Yaml anziché json. Yaml è un superset di JSON. Ha un migliore supporto per lo streaming su entrambi i lati e qualsiasi messaggio sarà ancora valido yaml.

Ma nel tuo caso potrebbe essere più semplice suddividere il flusso dell'oggetto in un flusso di messaggi separati json.

Vedi anche la discussione qui, che librerie client supportano lo streaming: Is there a streaming API for JSON?

+3

La domanda * C'è un'API di streaming per JSON? * Riguarda l'analisi di JSON che non la crea. –

+0

Questo semplicemente non è Vero: "significherebbe anche che l'applicazione client dovrà supportare lo streaming". Per quanto riguarda il lato server, potrebbe interessare l'ingombro della memoria, mentre sul client è possibile mantenere l'intero oggetto in memoria. –

+0

Come si esegue lo streaming di un elenco parziale da un server a un client che non supporta lo streaming? –

2

Salva questo in un file di modulo e importarlo o incollarlo direttamente nel codice.

''' 
Copied from Python 2.7.8 json.encoder lib, diff follows: 
@@ -331,6 +331,8 @@ 
        chunks = _iterencode(value, _current_indent_level) 
       for chunk in chunks: 
        yield chunk 
+  if first: 
+   yield buf 
     if newline_indent is not None: 
      _current_indent_level -= 1 
      yield '\n' + (' ' * (_indent * _current_indent_level)) 
@@ -427,12 +429,12 @@ 
      yield str(o) 
     elif isinstance(o, float): 
      yield _floatstr(o) 
-  elif isinstance(o, (list, tuple)): 
-   for chunk in _iterencode_list(o, _current_indent_level): 
-    yield chunk 
     elif isinstance(o, dict): 
      for chunk in _iterencode_dict(o, _current_indent_level): 
       yield chunk 
+  elif hasattr(o, '__iter__'): 
+   for chunk in _iterencode_list(o, _current_indent_level): 
+    yield chunk 
     else: 
      if markers is not None: 
       markerid = id(o) 
''' 
from json import encoder 

def _make_iterencode(markers, _default, _encoder, _indent, _floatstr, 
     _key_separator, _item_separator, _sort_keys, _skipkeys, _one_shot, 
     ## HACK: hand-optimized bytecode; turn globals into locals 
     ValueError=ValueError, 
     basestring=basestring, 
     dict=dict, 
     float=float, 
     id=id, 
     int=int, 
     isinstance=isinstance, 
     list=list, 
     long=long, 
     str=str, 
     tuple=tuple, 
    ): 

    def _iterencode_list(lst, _current_indent_level): 
     if not lst: 
      yield '[]' 
      return 
     if markers is not None: 
      markerid = id(lst) 
      if markerid in markers: 
       raise ValueError("Circular reference detected") 
      markers[markerid] = lst 
     buf = '[' 
     if _indent is not None: 
      _current_indent_level += 1 
      newline_indent = '\n' + (' ' * (_indent * _current_indent_level)) 
      separator = _item_separator + newline_indent 
      buf += newline_indent 
     else: 
      newline_indent = None 
      separator = _item_separator 
     first = True 
     for value in lst: 
      if first: 
       first = False 
      else: 
       buf = separator 
      if isinstance(value, basestring): 
       yield buf + _encoder(value) 
      elif value is None: 
       yield buf + 'null' 
      elif value is True: 
       yield buf + 'true' 
      elif value is False: 
       yield buf + 'false' 
      elif isinstance(value, (int, long)): 
       yield buf + str(value) 
      elif isinstance(value, float): 
       yield buf + _floatstr(value) 
      else: 
       yield buf 
       if isinstance(value, (list, tuple)): 
        chunks = _iterencode_list(value, _current_indent_level) 
       elif isinstance(value, dict): 
        chunks = _iterencode_dict(value, _current_indent_level) 
       else: 
        chunks = _iterencode(value, _current_indent_level) 
       for chunk in chunks: 
        yield chunk 
     if first: 
      yield buf 
     if newline_indent is not None: 
      _current_indent_level -= 1 
      yield '\n' + (' ' * (_indent * _current_indent_level)) 
     yield ']' 
     if markers is not None: 
      del markers[markerid] 

    def _iterencode_dict(dct, _current_indent_level): 
     if not dct: 
      yield '{}' 
      return 
     if markers is not None: 
      markerid = id(dct) 
      if markerid in markers: 
       raise ValueError("Circular reference detected") 
      markers[markerid] = dct 
     yield '{' 
     if _indent is not None: 
      _current_indent_level += 1 
      newline_indent = '\n' + (' ' * (_indent * _current_indent_level)) 
      item_separator = _item_separator + newline_indent 
      yield newline_indent 
     else: 
      newline_indent = None 
      item_separator = _item_separator 
     first = True 
     if _sort_keys: 
      items = sorted(dct.items(), key=lambda kv: kv[0]) 
     else: 
      items = dct.iteritems() 
     for key, value in items: 
      if isinstance(key, basestring): 
       pass 
      # JavaScript is weakly typed for these, so it makes sense to 
      # also allow them. Many encoders seem to do something like this. 
      elif isinstance(key, float): 
       key = _floatstr(key) 
      elif key is True: 
       key = 'true' 
      elif key is False: 
       key = 'false' 
      elif key is None: 
       key = 'null' 
      elif isinstance(key, (int, long)): 
       key = str(key) 
      elif _skipkeys: 
       continue 
      else: 
       raise TypeError("key " + repr(key) + " is not a string") 
      if first: 
       first = False 
      else: 
       yield item_separator 
      yield _encoder(key) 
      yield _key_separator 
      if isinstance(value, basestring): 
       yield _encoder(value) 
      elif value is None: 
       yield 'null' 
      elif value is True: 
       yield 'true' 
      elif value is False: 
       yield 'false' 
      elif isinstance(value, (int, long)): 
       yield str(value) 
      elif isinstance(value, float): 
       yield _floatstr(value) 
      else: 
       if isinstance(value, (list, tuple)): 
        chunks = _iterencode_list(value, _current_indent_level) 
       elif isinstance(value, dict): 
        chunks = _iterencode_dict(value, _current_indent_level) 
       else: 
        chunks = _iterencode(value, _current_indent_level) 
       for chunk in chunks: 
        yield chunk 
     if newline_indent is not None: 
      _current_indent_level -= 1 
      yield '\n' + (' ' * (_indent * _current_indent_level)) 
     yield '}' 
     if markers is not None: 
      del markers[markerid] 

    def _iterencode(o, _current_indent_level): 
     if isinstance(o, basestring): 
      yield _encoder(o) 
     elif o is None: 
      yield 'null' 
     elif o is True: 
      yield 'true' 
     elif o is False: 
      yield 'false' 
     elif isinstance(o, (int, long)): 
      yield str(o) 
     elif isinstance(o, float): 
      yield _floatstr(o) 
     elif isinstance(o, dict): 
      for chunk in _iterencode_dict(o, _current_indent_level): 
       yield chunk 
     elif hasattr(o, '__iter__'): 
      for chunk in _iterencode_list(o, _current_indent_level): 
       yield chunk 
     else: 
      if markers is not None: 
       markerid = id(o) 
       if markerid in markers: 
        raise ValueError("Circular reference detected") 
       markers[markerid] = o 
      o = _default(o) 
      for chunk in _iterencode(o, _current_indent_level): 
       yield chunk 
      if markers is not None: 
       del markers[markerid] 

    return _iterencode 

encoder._make_iterencode = _make_iterencode 
+0

http://bugs.python.org/issue14573 – Zectbumo