Мы уже рассказывали про InterPlanetary File System, распределённую сеть поверх одноимённого p2p-протокола с доступом к данным по HTTP. Данные в ней не поддаются изменению (не блокчейн, но часть принципов совпадает), хранятся неограниченно долго и у неё даже есть система резервируемых имён IPNS, позволяющая бесплатно размещать статические сайты и serverless приложения. Главный недостаток IPFS — низкая и непредсказуемая скорость передачи данных: каждый файл или каталог разбивается на блоки, которые случайным образом разлетаются по всей сети и собираются воедино с помощью DHT. Таким образом, если даже незначительную часть блоков занесёт на другое полушарие, затормозится вся загрузка. Это в принципе проблема всех распределённых сетей и легкого решения нет. Зато разработчики и комьюнити проекта OrbitDB смогли решить другую назойливую проблему IPFS — отсутствие полноценной базы данных, которая могла бы полноценно интегрироваться с экосистемой IPFS и быть такой же независимой и безопасной.

Что делает OrbitDB уникальной в своём роде? Она, как и все распределённые системы, не зависит от конкретных серверов и хранит данные распределённо и с большим запасом копий. Копии постоянно синхронизируются между всеми доступными пирами и переписываются в соответствии с CRDT (бесконфликтные реплицированные типы данных, вот подробная статья). Именно благодаря OrbitDB serverless приложения на основе IPFS могут пользоваться единым источником данных, не полагаясь на сторонние сервисы. При этом подход к хранению данных сильно меняется по сравнению с обычной клиент-серверной архитектурой: каждый участник сети хранит только «свои» данные, к которым у него есть доступ, а совместный доступ и система прав работают из коробки.

Кстати, реплики работают через IPFS PubSub, на котором построено большинство децентрализованных приложений (dApps). PubSub — обычная практика для больших сетей, паттерн, позволяющий подписаться на изменения определённого узла и взаимодействовать с ним напрямую. Обычно он используется для рационального распределения нагрузки на сеть, но в IPFS это также единственный встроенный способ взаимодействовать между узлами: каждый доступен только по длинному хэшу, который невозможно подобрать или угадать случайно.


Долгое время OrbitDB не выходила из мучительного этапа разработки, когда вроде бы основное ядро готово, но на периферии постоянно что-то валится и вызывает каскад багов — в общем, не то что в прод, её даже для пет-проектов было слишком неудобно использовать. К счастью, за прошлый год вышло много крупных релизов, которые довели всю систему до довольно стабильного состояния (только местами не хватает документации, при том что мануал по размеру приближается к небольшой книге). Дальше мы разберём процесс создания и взаимодействия со свои инстансом и попробуем его в действии на маленькой демке.

Создаём базу


Конкретных требований к железу разработчики не приводят, я пробовал работать с IPFS и OrbitDB на разных машинах и рекомендую брать не меньше 2гб памяти и двух ядер для средней нагрузки, а для высокой придётся подбирать характеристики самостоятельно. В пике при одновременной загрузке файлов на 3-5 гигабайт одна нода съедала целиком выделенные ей 4 гига оперативки на двухъядерном 3.2ГГц сервере, поэтому самое дешёвое железо лучше не брать.

У IPFS есть две независимые реализации, на Go и JS, и вторая гораздо популярнее из-за простоты встраивания в расширения и веб-приложения. На ней же и основывается OrbitDB. поэтому накатываем NodeJS с npm и устанавливаем пакеты:

  npm install orbit-db ipfs


Для работы с базой нам нужно запустить IPFS и создать инстанс OrbitDB. Это ещё не сама БД, это интерфейс:

  const IPFS = require('ipfs')
  const OrbitDB = require('orbit-db')

  async function main () {
    const ipfsOptions = { repo : './ipfs', }
    const ipfs = await IPFS.create(ipfsOptions)
    const orbitdb = await OrbitDB.createInstance(ipfs)
  }

  main()


Всего доступно четыре типа данных: key-value, log (только дописывание в конец), feed (log с возможностью перезаписи) и documents (индексированный JSON). Есть ещё обычный инкрементальный счётчик, но назвать его типом данных язык не поворачивается.

Создадим KV-базу:

  const IPFS = require('ipfs')
  const OrbitDB = require('orbit-db')
  
  async function main () {
    const ipfsOptions = { repo : './ipfs', }
    const ipfs = await IPFS.create(ipfsOptions)
    const orbitdb = await OrbitDB.createInstance(ipfs)
    // в OrbitDB много алиасов, здесь .keyvalue приравнивается
    // к созданию базы, но им же можно и открыть её
    const db = await orbitdb.keyvalue('first-database')
  }
  main()


После создания БД получает уникальный адрес вида /orbitdb/ + хэш + название базы. Например, такой:

/orbitdb/Qmd8TmZrWASypEp4Er9tgWP4kCNQnW4ncSnvjvyHQ3EVSU/first-database

Здесь /orbitdb/ это протокол, а посередине — обычный мультихэш IPFS, по которому можно обнаружить манифест базы.

Система доступа


Каждая запись в базу подписана публичным ключом её автора, который можно получить из объекта identity:

  const identity = db.identity
  console.log(identity.toJSON())
  // вывод
  {
    id: '0443729cbd756ad8e598acdf1986c8d586214a1ca...',
    publicKey: '0446829cbd926ad8e858acdf1988b8d586...',
    signatures: {
      id: '3045022058bbb2aa415623085124b32b254b866...',
      publicKey: '3046022100d138ccc0fbd48bd41e74e4...'
    },
    type: 'orbitdb'
  }


Подробно расписывать все варианты работы с ключом здесь не имеет смысла, всё достаточно детально изложено в доках. Вкратце: при создании базы в объекте accessController передаётся список разрешённых айдишников, который, внимание, пока работает только на добавление. Отозвать доступ нельзя, не изменив глобальный адрес БД.

  const IPFS = require('ipfs')
  const OrbitDB = require('orbit-db')

  async function main () {
    const ipfsOptions = { repo: './ipfs',}
    const ipfs = await IPFS.create(ipfsOptions)
    const orbitdb = await OrbitDB.createInstance(ipfs)
    const options = {
      // Выдаём себе доступ
      accessController: {
        write: [orbitdb.identity.id]
      }
    }

    const db = await orbitdb.keyvalue('first-database', options)
    console.log(db.address.toString())
    // /orbitdb/Qmd8TmZrWASypEp4Er9tgWP4kCNQnW4ncSnvjvyHQ3EVSU/first-database
  }
  main()


Для добавления другого пира, нужно указать его собственный orbitdb.identity.id — публичный ключ ключ из примера выше.

Чтобы сделать базу открытой для всех, достаточно указать

  accessController: {
    write: ['*']
  }


Чтобы выдать доступ после создания базы, используем await db.access.grant('write', identity2.publicKey)

Операции с данными


Здесь нужно сразу отметить особенность IPFS: чтобы не засорять сеть неиспользуемым мусором, DHT регулярно вычищает его из себя. Чтобы ваши данные не превратились в мусор, их нужно закрепить (pin). Это правило работает для любых данных во всей сети, и нужно не забывать о нём, чтобы не остаться внезапно без базы.

Put/Set/Add/Inc… wtf?


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

  • key-value: put или set
  • log и feed: add
  • docs: put
  • counter: inc


  // создаём инстанс
  const orbitdb = await OrbitDB.createInstance(ipfs)
  // создаём базу
  const db = await orbitdb.keyvalue('first-database')
  // записываем данные
  await db.put('name', 'hello')
  // или закрепляем их, чтобы не выпилил GC
  await db.put('name', 'hello', { pin: true })


Похоже на минное поле? Осталось только добавить, что все операции записи пока должны выполняться синхронно. Вроде как пропускной способности хватает с запасом, но помнить нужно.

Get и компания


  • key-value: get
  • log и feed: iterator
  • docs: get и query
  • counter: value


  const orbitdb = await OrbitDB.createInstance(ipfs)
  const db = await orbitdb.keyvalue('first-database')
  await db.put('name', 'hello')
  const value = db.get('name')
  // hello


Конечно, было бы неплохо рассмотреть ещё разрешение конфликтов, персистентность и реплицирование, но они достаточно хорошо описаны в доках и к ним можно будет вернуться при более глубоком погружении, пока что нам нужен только базовый функционал для прототипа.

Хабр торт?


Что может быть проще Todo? У OrbitDB есть публичная демка полноценного TodoMVC, с персистентностью, хранилищем и логикой. Пока слишком много, возьмём минимальный функционал — пользователи с разных клиентов могут изменять общедоступные данные хранящиеся в публичной базе. У каждого на клиенте записывается его кусок, после чего уходит синхронизироваться с остальными. Обросим ручной ввод, чтобы не париться с санитайзером, лучше поспорим о вечном — а Хабр ещё торт?


Конечно, оставим нежно-нейтральный вариант

Мне было проще накидать за пять минут макет на Vue, но это дело вкуса. При желании, можно вообще выкинуть UI и вести войны в консоли.

Весь компонент под катом
    <template>
      <div class="dropdown">
        <span>Хабр: </span>
        <dropdown :options="options" :selected="active" v-on:updateOption="onSelect"></dropdown>
        <p>Последнее обновление: {{lastUpdated}}</p>
      </div>
    </template>
    <script>
    import dropdown from 'vue-dropdowns'
    const IPFS = require('ipfs')
    const OrbitDB = require('orbit-db')
    export default {
      name: 'HabrIs',
      components: {
        dropdown
      },
      data() {
        return {
          options: [{ name: 'торт' }, { name: 'не торт' }, { name: 'социальное СМИ об IT' }],
          active: {
            name: 'социальное СМИ об IT',
          },
          lastUpdated: '',
          db: false,
          address: '/orbitdb/zdpuB3UcoWfRJCEeZ7rMsw3CuLjK25ahuTG1KCs9CW2b1HVsV/habr-is'
        }
      },
      methods: {
        onSelect(value) {
          this.active = value
          this.lastUpdated = new Date().toLocaleString()
          this.updateDatabase()
        },
        run: async function () {
          const ipfsOptions = {
            repo: './ipfs',
            EXPERIMENTAL: {
              pubsub: true,
            },
            config: {
              Addresses: {
                Swarm: [
                  '/dns4/wrtc-star1.par.dwebops.pub/tcp/443/wss/p2p-webrtc-star/',
                  '/dns4/wrtc-star2.sjc.dwebops.pub/tcp/443/wss/p2p-webrtc-star/',
                  '/dns4/webrtc-star.discovery.libp2p.io/tcp/443/wss/p2p-webrtc-star/'
                ]
              },
            }
          }
          const ipfs = await IPFS.create(ipfsOptions)
          const orbitdb = await OrbitDB.createInstance(ipfs)
          if (!this.address) {
            this.createDatabase(orbitdb)
          } else {
            this.openDatabase(orbitdb)
          }
        },
        createDatabase: async function (orbitdb) {
          try {
            const options = {
              EXPERIMENTAL: {
                pubsub: true,
              },
              accessController: {
                write: ['*']
              }
            }
    
            this.db = await orbitdb.keyvalue('first-database', options)
            this.address = this.db.address.toString()
            console.log('created database at ' + this.address)
            await this.db.put('value', 'торт', { pin: true })
            await this.db.put('lastUpdated', new Date().toLocaleString(), { pin: true })
            this.getData()
          } catch (e) {
            console.error(e)
          }
        },
        openDatabase: async function (orbitdb) {
          try {
            this.db = await orbitdb.keyvalue(this.address)
            console.log('opened database at ' + this.address)
            console.log(this.db)
            this.getData()
          } catch (e) {
            console.error(e)
          }
        },
        getData() {
          setInterval(() => {
            try {
              const value = this.db.get('value')
              this.active.name = value
              const lastUpdated = this.db.get('lastUpdated')
              this.lastUpdated = lastUpdated
            } catch (e) {
              console.error(e)
            }
          }, 3000);
        },
        updateDatabase: async function () {
          try {
            await this.db.put('value', this.active.name, { pin: true })
            await this.db.put('lastUpdated', this.lastUpdated, { pin: true })
          } catch (e) {
            console.error(e)
          }
        }
      },
      mounted() {
        this.run()
      }
    }
    </script>
  



Разбираем важные моменты: во-первых, надо настроить ноду IPFS, чтобы у неё работал PubSub и подключение через WebRTC — даже так получится не самый быстрый отклик, но лучше, чем без него.

  const ipfsOptions = {
    repo: './ipfs',
    EXPERIMENTAL: {
      pubsub: true,
    },
    config: {
      Addresses: {
        Swarm: [
          '/dns4/wrtc-star1.par.dwebops.pub/tcp/443/wss/p2p-webrtc-star/',
          '/dns4/wrtc-star2.sjc.dwebops.pub/tcp/443/wss/p2p-webrtc-star/',
          '/dns4/webrtc-star.discovery.libp2p.io/tcp/443/wss/p2p-webrtc-star/'
        ]
      },
    }
  }


Для инициализации, конечно, укажем, что хабр — торт:

  createDatabase: async function (orbitdb) {
    try {
      const options = {
        accessController: {
          write: ['*']
        }
      }
      this.db = await orbitdb.keyvalue('first-database', options)
      this.address = this.db.address.toString()
      console.log('created database at ' + this.address)
      await this.db.put('value', 'торт', { pin: true })
      await this.db.put('lastUpdated', new Date().toLocaleString(), { pin: true })
      this.getData()
    } catch (e) {
      console.error(e)
    }
  }


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

  openDatabase: async function (orbitdb) {
    try {
      this.db = await orbitdb.keyvalue(this.address)
      console.log('opened database at ' + this.address)
      console.log(this.db)
      this.getData()
    } catch (e) {
      console.error(e)
    }
  },
  getData() {
    // соединение открыто в openDatabase, свежие данные приходится
    // тянуть таким неприглядным способом — vue watch плохо работает с async
    setInterval(() => {
      try {
        const value = this.db.get('value')
        this.active.name = value
        const lastUpdated = this.db.get('lastUpdated')
        this.lastUpdated = lastUpdated
      } catch (e) {
        console.error(e)
      }
    }, 3000);
  }


При выборе варианта в дропдауне нужно записать его в базу:

  onSelect(value) {
    this.active = value
    this.lastUpdated = new Date().toLocaleString()
    this.updateDatabase()
  },
  updateDatabase: async function () {
    try {
      await this.db.put('value', this.active.name, { pin: true })
      await this.db.put('lastUpdated', this.lastUpdated, { pin: true })
    } catch (e) {
      console.error(e)
    }
  }


Тривиальный процесс, но из-за местами скудной документации и примеров с тремя (!) разными наборами методов и алиасов, пришлось провести вечер за разбором js-ipfs и issues в репо OrbitDB. Зато вышло интересно)

Исходники здесь: https://github.com/Slipn3r/orbitdb-demo, а вот как это выглядит:



Заключение


Очень здорово, что обычно довольно разрозненная движуха вокруг распределённых сетей потихоньку кристаллизуется на IPFS. Децентрализованных приложений в одном только Awesome IPFS уже несколько десятков, а крутые интеграции и концепты появляются чуть ли не по штуке в неделю. На OrbitDB изначально был написан proof-of-concept чат Orbit, а сейчас её используют уже сотни разработчиков (и сотни контрибьютят, что особенно приятно). Если не мне одному интересно разобраться в ней получше, в следующий раз выкатим уже нормальную демку с полноценным функционалом.

Что почитать/посмотреть:
orbitdb.org
ipfs.io
Quick start
Грандиозный мануал. А у IPFS ещё больше, раза в три.
Документация по API. Читается с трудом.
Тот самый TodoMVC
Awesome IPFS
Orbit chat



На правах рекламы


VDSina предлагает VDS в аренду под любые задачи, огромный выбор операционных систем для автоматической установки, есть возможность установить любую ОС с собственного ISO, удобная панель управления собственной разработки и посуточная оплата тарифа, который вы можете создать индивидуально под свои задачи.