Бинарный формат PSON

в 7:55, , рубрики: BSON, json, open source, Pandora, PSON, python, ruby, XML, бинарный формат, Микроформаты, структуры данных

PSON (Pandora Simple Object Notation) – бинарный формат упаковки, позволяющий переводить простые типы данных, массивы и списки в последовательность байт (простую строку). PSON придуман и разработан для использования в свободной распределённой информационной системе Pandora как более простая альтернатива бинарному формату BSON.

Бинарный формат PSON - 1

Поддерживаемые типы

Текущая версия PSON поддерживает упаковку значений 9 типов:
1. Целое число (Integer)
2. Дробное число (Float)
3. Строка (String)
4. Логическое (Boolean)
5. Дата и время (Time в Ruby или Datetime в Python)
6. Массив (Array в Ruby или List в Python)
7. Словарь (Hash в Ruby или Dict в Python)
8. Символьное (Symbol в Ruby)
9. Пустое значение (Nil).

При упаковке массивов и словарей вложенные значения также упаковываются, например:

value = ['Hello', 1500, 3.14, true, {:name=>'Michael', :family=>'Jackson'}]

будет упаковано в строку длиной 57 байт, а при обратной распаковке выдаст идентичный объект.

Реализация на Ruby и Python

Код для упаковки и распаковки написан на языках Ruby и Python, но при желании может быть реализован и на других языках программирования.

Ruby:

  # Codes of data types in PSON
  # RU: Коды типов данных в PSON
  PT_Int   = 0
  PT_Str   = 1
  PT_Bool  = 2
  PT_Time  = 3
  PT_Array = 4
  PT_Hash  = 5
  PT_Sym   = 6
  PT_Real  = 7
  # 8..14 - reserved for other types
  PT_Nil   = 15
  PT_Negative = 16

  # Encode data type and size to PSON type and count of size in bytes (1..8)-1
  # RU: Кодирует тип данных и размер в тип PSON и число байт размера
  def self.encode_pson_type(basetype, int)
    count = 0
    neg = 0
    if int<0
      neg = PT_Negative
      int = -int
    end
    while (int>0) and (count<8)
      int = (int >> 8)
      count +=1
    end
    if count >= 8
      puts '[encode_pan_type] Too big int='+int.to_s
      count = 7
    end
    [basetype ^ neg ^ (count << 5), count, (neg>0)]
  end

  # Decode PSON type to data type and count of size in bytes (1..8)-1
  # RU: Раскодирует тип PSON в тип данных и число байт размера
  def self.decode_pson_type(type)
    basetype = type & 0xF
    negative = ((type & PT_Negative)>0)
    count = (type >> 5)
    [basetype, count, negative]
  end

  # Convert ruby object to PSON (Pandora Simple Object Notation)
  # RU: Конвертирует объект руби в PSON
  # sort_mode: nil or false - don't sort, 1 - sort array, 2 - sort hash
  # 3 or true - sort arrays and hashes 
  def self.rubyobj_to_pson(rubyobj, sort_mode=nil)
    type = PT_Nil
    count = 0
    neg = false
    data = AsciiString.new
    elem_size = nil
    case rubyobj
      when String
        data << AsciiString.new(rubyobj)
        elem_size = data.bytesize
        type, count, neg = encode_pson_type(PT_Str, elem_size)
      when Symbol
        data << AsciiString.new(rubyobj.to_s)
        elem_size = data.bytesize
        type, count, neg = encode_pson_type(PT_Sym, elem_size)
      when Integer
        type, count, neg = encode_pson_type(PT_Int, rubyobj)
        rubyobj = -rubyobj if neg
        data << PandoraUtils.bigint_to_bytes(rubyobj)
      when Time
        rubyobj = rubyobj.to_i
        type, count, neg = encode_pson_type(PT_Time, rubyobj)
        rubyobj = -rubyobj if neg
        data << PandoraUtils.bigint_to_bytes(rubyobj)
      when TrueClass, FalseClass
        type = PT_Bool
        type = type ^ PT_Negative if not rubyobj
      when Float
        data << [rubyobj].pack('D')
        elem_size = data.bytesize
        type, count, neg = encode_pson_type(PT_Real, elem_size)
      when Array
        if (sort_mode and ((not sort_mode.is_a?(Integer)) or ((sort_mode & 1)>0)))
          rubyobj = self.sort_complex_array(rubyobj)
        end
        rubyobj.each do |a|
          data << rubyobj_to_pson(a, sort_mode)
        end
        elem_size = rubyobj.size
        type, count, neg = encode_pson_type(PT_Array, elem_size)
      when Hash
        if (sort_mode and ((not sort_mode.is_a?(Integer)) or ((sort_mode & 2)>0)))
          #rubyobj = rubyobj.sort_by {|k,v| k.to_s}
          rubyobj = self.sort_complex_hash(rubyobj)
        end
        elem_size = 0
        rubyobj.each do |a|
          data << rubyobj_to_pson(a[0], sort_mode) << rubyobj_to_pson(a[1], sort_mode)
          elem_size += 1
        end
        type, count, neg = encode_pson_type(PT_Hash, elem_size)
      when NilClass
        type = PT_Nil
      else
        puts 'Error! rubyobj_to_pson: illegal ruby class ['+rubyobj.class.name+']'
    end
    res = AsciiString.new
    res << [type].pack('C')
    if (data.is_a? String) and (count>0)
      data = AsciiString.new(data)
      if elem_size
        if (elem_size == data.bytesize) or (rubyobj.is_a? Array) or (rubyobj.is_a? Hash)
          res << PandoraUtils.fill_zeros_from_left( 
            PandoraUtils.bigint_to_bytes(elem_size), count) + data
        else
          puts 'Error! rubyobj_to_pson: elem_size<>data_size: '+elem_size.inspect+'<>'
            +data.bytesize.inspect + ' data='+data.inspect + ' rubyobj='+rubyobj.inspect
        end
      elsif data.bytesize>0
        res << PandoraUtils.fill_zeros_from_left(data, count)
      end
    end
    res = AsciiString.new(res)
  end

  # Convert PSON to ruby object
  # RU: Конвертирует PSON в объект руби
  def self.pson_to_rubyobj(data)
    data = AsciiString.new(data)
    val = nil
    len = 0
    if data.bytesize>0
      type = data[0].ord
      len = 1
      basetype, count, neg = decode_pson_type(type)
      if data.bytesize >= len+count
        elem_size = 0
        elem_size = PandoraUtils.bytes_to_int(data[len, count]) if count>0
        case basetype
          when PT_Int
            val = elem_size
            val = -val if neg
          when PT_Time
            val = elem_size
            val = -val if neg
            val = Time.at(val)
          when PT_Bool
            if count>0
              val = (elem_size != 0)
            else
              val = (not neg)
            end
          when PT_Str, PT_Sym, PT_Real
            pos = len+count
            if pos+elem_size>data.bytesize
              elem_size = data.bytesize-pos
            end
            val = AsciiString.new(data[pos, elem_size])
            count += elem_size
            if basetype == PT_Sym
              val = val.to_sym
            elsif basetype == PT_Real
              val = val.unpack('D')[0]
            end
          when PT_Array, PT_Hash
            val = Array.new
            elem_size *= 2 if basetype == PT_Hash
            while (data.bytesize-1-count>0) and (elem_size>0)
              elem_size -= 1
              aval, alen = pson_to_rubyobj(data[len+count..-1])
              val << aval
              count += alen
            end
            val = Hash[*val] if basetype == PT_Hash
          when PT_Nil
            val = nil
          else
            puts 'pson_to_rubyobj: illegal pson type '+basetype.inspect
        end
        len += count
      else
        len = data.bytesize
      end
    end
    [val, len]
  end

  # Value is empty?
  # RU: Значение пустое?
  def self.value_is_empty?(val)
    res = (val==nil) or (val.is_a? String and (val=='')) 
      or (val.is_a? Integer and (val==0)) or (val.is_a? Time and (val.to_i==0)) 
      or (val.is_a? Array and (val==[])) or (val.is_a? Hash and (val=={}))
    res
  end

  # Pack PanObject fields to Name-PSON binary format
  # RU: Пакует поля панобъекта в бинарный формат Name-PSON
  def self.hash_to_namepson(fldvalues, pack_empty=false, sort_mode=2)
    #bytes = ''
    #bytes.force_encoding('ASCII-8BIT')
    bytes = AsciiString.new
    fldvalues = fldvalues.sort_by {|k,v| k.to_s } if sort_mode
    fldvalues.each do |nam, val|
      if pack_empty or (not value_is_empty?(val))
        nam = nam.to_s
        nsize = nam.bytesize
        nsize = 255 if nsize>255
        bytes << [nsize].pack('C') + nam[0, nsize]
        pson_elem = rubyobj_to_pson(val, sort_mode)
        bytes << pson_elem
      end
    end
    bytes = AsciiString.new(bytes)
  end

  # Convert Name-PSON block to PanObject fields
  # RU: Преобразует Name-PSON блок в поля панобъекта
  def self.namepson_to_hash(pson)
    hash = {}
    while pson and (pson.bytesize>1)
      flen = pson[0].ord
      fname = pson[1, flen]
      if (flen>0) and fname and (fname.bytesize>0)
        val = nil
        if pson.bytesize-flen>1
          pson = pson[1+flen..-1]  # drop getted name
          val, len = pson_to_rubyobj(pson)
          pson = pson[len..-1]     # drop getted value
        else
          pson = nil
        end
        hash[fname] = val
      else
        pson = nil
        hash = nil if hash == {}
      end
    end
    hash
  end
Python:

# Codes of data types in PSON
# RU: Коды типов данных в PSON
PT_Int   = 0
PT_Str   = 1
PT_Bool  = 2
PT_Time  = 3
PT_Array = 4
PT_Hash  = 5
PT_Sym   = 6
PT_Real  = 7
# 8..14 - reserved for other types
PT_Nil   = 15
PT_Negative = 16

# Encode data type and size to PSON kind and count of size in bytes (1..8)-1
# RU: Кодирует тип данных и размер в тип PSON и число байт размера
def encode_pson_kind(basekind, size):
  count = 0
  neg = 0
  if size<0:
    neg = PT_Negative
    size = -size
  while (size>0) and (count<8):
    size = (size >> 8)
    count +=1
  if count >= 8:
    print('[encode_pan_kind] Too big int='+size.to_s)
    count = 7
  return [basekind ^ neg ^ (count << 5), count, (neg>0)]

# Decode PSON kind to data kind and count of size in bytes (1..8)-1
# RU: Раскодирует тип PSON в тип данных и число байт размера
def decode_pson_kind(kind):
  basekind = kind & 0xF
  negative = ((kind & PT_Negative)>0)
  count = (kind >> 5)
  return [basekind, count, negative]

# Convert python object to PSON (Pandora simple object notation)
# RU: Конвертирует объект питон в PSON
def pythonobj_to_pson(pythonobj):
  kind = PT_Nil
  count = 0
  data = '' #!!!data = AsciiString.new
  elem_size = None
  if isinstance(pythonobj, str):
    data += pythonobj #!!!AsciiString.new(pythonobj)
    elem_size = len(data) #!!!data.bytesize
    kind, count, neg = encode_pson_kind(PT_Str, elem_size)
  elif isinstance(pythonobj, bool):
    kind = PT_Bool
    print('Boool1 kind='+str(kind))
    if not pythonobj: kind = kind ^ PT_Negative
    print('Boool2 kind='+str(kind))
  elif isinstance(pythonobj, int):
    kind, count, neg = encode_pson_kind(PT_Int, pythonobj)
    if neg: pythonobj = -pythonobj
    data += bigint_to_bytes(pythonobj, 8)
  #!!!elif isinstance(pythonobj, Symbol):
  #  data << AsciiString.new(pythonobj.to_s)
  #  elem_size = data.bytesize
  #  kind, count, neg = encode_pson_kind(PT_Sym, elem_size)
  elif isinstance(pythonobj, datetime.datetime):
    pythonobj = int(pythonobj)
    kind, count, neg = encode_pson_kind(PT_Time, pythonobj)
    if neg: pythonobj = -pythonobj
    data << PandoraUtils.bigint_to_bytes(pythonobj)
  elif isinstance(pythonobj, float):
    data += struct.pack('d', pythonobj)
    elem_size = len(data)
    kind, count, neg = encode_pson_kind(PT_Real, elem_size)
  elif isinstance(pythonobj, (list, tuple)):
    for a in pythonobj:
      data += pythonobj_to_pson(a)
    elem_size = len(pythonobj)
    kind, count, neg = encode_pson_kind(PT_Array, elem_size)
  elif isinstance(pythonobj, dict):
    #!!!pythonobj = pythonobj.sort_by {|k,v| k.to_s}
    elem_size = 0
    for key in pythonobj:
      data += pythonobj_to_pson(key) + pythonobj_to_pson(pythonobj.get(key, 0))
      elem_size += 1
    kind, count, neg = encode_pson_kind(PT_Hash, elem_size)
  elif pythonobj is None:
    kind = PT_Nil
  else:
    print('Error! pythonobj_to_pson: illegal ruby class ['+pythonobj+']')
  res = ''   #res = AsciiString.new
  res += struct.pack('!B', kind)  #res << [kind].pack('C')
  if isinstance(data, str) and (count>0):
    #!!!data = AsciiString.new(data)
    if elem_size:
      if (elem_size==len(data)) or isinstance(pythonobj, (list,dict,tuple)):
        #!!!res += PandoraUtils.fill_zeros_from_left( 
        #  PandoraUtils.bigint_to_bytes(elem_size), count) + data
        res += fill_zeros_from_left(bigint_to_bytes(elem_size), count) + data
      else:
        print('Error! pythonobj_to_pson: elem_size<>data_size: '+elem_size.inspect+'<>'
          +data.bytesize.inspect + ' data='+data.inspect + ' pythonobj='+pythonobj.inspect)
    elif len(data)>0:
      #!!!res << PandoraUtils.fill_zeros_from_left(data, count)
      res += data[:count]
  return res #AsciiString.new(res)

# Convert PSON to python object
# RU: Конвертирует PSON в объект питон
def pson_to_pythonobj(data):
  val = None
  size = 0
  if len(data)>0:
    kind = ord(data[0])
    size = 1
    basekind, count, neg = decode_pson_kind(kind)
    if (len(data) >= size+count):
      elem_size = 0
      if count>0: elem_size = bytes_to_int(data[size:size+count])
      if basekind==PT_Int:
        val = elem_size
        if neg: val = -val
      elif basekind==PT_Time:
        val = elem_size
        if neg: val = -val
        val = datetime.datetime(val)  #Time.at(val)
      elif basekind==PT_Bool:
        if count>0:
          val = (elem_size != 0)
        else:
          val = (not neg)
      elif (basekind==PT_Str) or (basekind==PT_Sym) or (basekind==PT_Real):
        pos = size+count
        if pos+elem_size>len(data):
          elem_size = len(data)-pos
        val = data[pos: pos+elem_size]
        count += elem_size
        if basekind == PT_Sym:
          val = val.to_sym
        elif basekind == PT_Real:
          unpacked = struct.unpack('d', val)
          val = unpacked[0]
          print('RT_REAL val='+str(val))
      elif (basekind==PT_Array) or (basekind==PT_Hash):
        val = []
        if basekind == PT_Hash: elem_size *= 2
        while (len(data)-1-count>0) and (elem_size>0):
          elem_size -= 1
          aval, alen = pson_to_pythonobj(data[size+count:])
          val.append(aval)
          count += alen
        if basekind == PT_Hash:
          dic = {}
          for i in range(len(val)/2): dic[val[i*2]] = val[i*2+1]
          val = dic
          print(str(val))
      elif (basekind==PT_Nil):
        val = None
      else:
        print('pson_to_pythonobj: illegal pson kind '+basekind.inspect)
      size += count
    else:
      size = data.bytesize
  return [val, size]

# Value is empty?
# RU: Значение пустое?
def is_value_empty(val):
  res = ((val is None) or (isinstance(val, str) and (len(val)==0)) 
    or (isinstance(val, int) and (val==0)) 
    or (isinstance(val, list) and (val==[])) or (isinstance(val, dict) and (val=={})))
    #or (val==Time and (val.to_i==0)) 
  return res

# Pack PanObject fields to Name-PSON binary format
# RU: Пакует поля панобъекта в бинарный формат Name-PSON
def hash_to_namepson(fldvalues, pack_empty=False):
  buf = ''
  #fldvalues = fldvalues.sort_by_key()  #!!!sort_by {|k,v| k.to_s } # sort by key
  for nam in fldvalues:
    val = fldvalues.get(nam, 0)
    if pack_empty or (not is_value_empty(val)):
      nam = str(nam)
      nsize = len(nam)
      if nsize>255: nsize = 255
      buf += struct.pack('B', nsize) + nam[0: nsize]
      pson_elem = pythonobj_to_pson(val)
      buf += pson_elem
  return buf


# Convert Name-PSON block to PanObject fields
# RU: Преобразует Name-PSON блок в поля панобъекта
def namepson_to_hash(pson):
  dic = {}
  while pson and (len(pson)>1):
    flen = ord(pson[0])
    fname = pson[1: 1+flen]
    if (flen>0) and fname and (len(fname)>0):
      val = None
      if len(pson)-flen > 1:
        pson = pson[1+flen:]  #!!! pson[1+flen..-1] # drop getted name
        val, size = pson_to_pythonobj(pson)
        pson = pson[size:]  #!!!pson[len..-1]   # drop getted value
      else:
        pson = None
      dic[fname] = val
    else:
      pson = None
      if dic == {}: dic = None
  return dic

Область применения

Сегодня PSON используется для:
1) упаковки записи перед созданием новой подписи и перед проверкой существующих подписей;
2) передачи данных по сети;
3) сохранения нескольких значений в одно поле таблицы базы данных.

Формат упаковки

В упакованном виде каждое значение содержит одну обязательную (*) и две необязательных компоненты:

Бинарный формат PSON - 2

Таблица 1. Компоненты PSON

Первые 4 бита в Type задают тип данных: целое (PT_Int=0), строка (PT_Str=1), логическое (PT_Bool=2), время (PT_Time=3), массив (PT_Array=4), хэш (PT_Hash=5), символьное (PT_Sym=6), дробное (PT_Real=7), зарезервировано для других типов (8-14), пустое (PT_Nil=15). 5й бит используется как признак «отрицательный» для численных или логических значений. Оставшиеся 3 бита показывают блину поля Length в байтах. «0» означает, что поля «Length» и «Data» пропущены.

В поле Length задано целое число. Поле Length может быть в пределах (1..7) байт, 1 байт означает что длина в пределах 255, 2 – в пределах 65535, а 7 – в пределах 256^7=7*10^16. Если тип данных указан как Int или Time, то поле Length содержит значение, а поле Data опускается. Если же тип данных задан как Str, Sym, Array, Hash или Real, то поле Length указывает длину данных, а сами данные содержатся в поле Data.

Для упаковки нескольких значений желательно помещать их в массив (как в примере выше).
Также для упаковки записей создан дополнительный формат – Name-PSON. Такой формат удобен для представления записей из таблицы базы данных перед подписыванием или передачей по сети. При упаковке входной параметр задаётся как Hash, в котором название поля задано в виде строки или символа, например:

{:name=>'Michael', 'family'=>'Jackson', 'birthday'=>Time.parse('29.08.1958'), :sex=>1}

Перед упаковкой имена полей будут переведены в текст, поля будут отсортированы по алфавиту, а затем упакованы в виде последовательности:

Len1:Name1:Pson1 | Len2:Name2:Pson2 | Len2:Name2:Pson3 | Len1:Name1:Pson4

Каждое поле и значение представлено тремя обязательными компонентами:

Бинарный формат PSON - 3

Таблица 2. Компоненты Name-PSON

Для примера выше распакованное значение будет выглядеть так:

{"birthday"=>1958-08-29 00:00:00 +0500, "family"=>"Jackson", "name"=>"Michael", "sex"=>1}

Обратите внимание, все имена полей стали строковыми (хотя изначально были и строковые, и символьные), а поля отсортированы по алфавиту. Такая строгость обеспечивает формирование определенной структуры перед созданием подписи, а в дальнейшем, на других компьютерах – создание идентичной структуры и проверку имеющихся подписей, что исключает разночтение структуры и ошибки при проверке подписей.

Достоинства

1) простой (в отличие от BSON)
2) компактный (в отличие от JSON и XML), так как хранит данные в сыром (бинарном) виде и не требует преобразования в Base64
3) однозначность упаковки
4) легкая упаковка и быстрая распаковка, так как формат строго структурирован. Не требует преобразования и парсинга, работает мгновенно и экономит вычислительные ресурсы и электроэнергию

Недостатки

1) отсутствие человекочитаемости (что не имеет значения при машинной обработке)
2) чувствительность к искажениям при передаче по сети (не страшно при CRC-проверке, шифровании или подписи)

Заключение

В целом PSON, являясь простым и быстрым форматом, при подписывании, проверке подписи и передаче данных по сети экономит дисковое пространство, процессорные мощности и сетевой трафик.

Бинарный формат PSON был разработан мною для распределённой информационной системы Pandora, опубликованной под GNU GPL2, а значит, передан в общественное достояние и может свободно использоваться в социальных, коммерческих, управленческих и других приложениях.

Автор: robux

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js