Простой способ создания совместных веб-приложений

Простойспособсозданиясовместныхвебприложений

Недавно я думал о том, как создать совместное веб-приложение в 39966.

От веб-приложения для совместной работы Я имею в виду приложения с взаимодействием на рабочем столе и коллаборации в реальном времени , такие как Notion, Discord, Figma и т. д.

Я хочу найти подход, подходящий для небольших команд , просто , но не обязательно легко , быстро по умолчанию и масштабируемый по мере роста.

Таким образом, я начал путешествие, чтобы выяснить это, создав демонстрационное приложение с задачами, исследуя инструменты Я приветствую всевозможные сторонние инструменты и службы, если они не содержат трудно заменяемых вен. и соответствующие методы

В нашем приложении todo есть следующие функции:

  • пользователи могут создавать, редактировать, удалять и переупорядочивать задачи, которые хранятся на сервер для постоянства
  • пользователи могут совместно редактировать один и тот же список задач, и изменения автоматически синхронизируются между разными клиентами в в реальном времени
  • все операции должно быть максимально быстрым для всех пользователей по всему миру

быстрая часть в основном связана с задержкой , потому что у большинства приложений не будет большой пропускной способности разобраться в самом начале. В частности, мы хотим, чтобы изменения, сделанные одним клиентом, доставлялись другим клиентам со скоростью света – меньше, чем 0773 мс в том же географический регион и несколько сотен мс по континенту. По этой причине приложение названо Todo Light.

Вы можете играть с конечный продукт Результат не соответствует нашей цели по скорости, в зависимости от вашего местоположения в мире. Подробнее об этом позже. здесь. Клиент и , код сервера размещен на Github.

При запуске приложения создается случайный идентификатор списка. Вы можете поделиться URL-адресом с параметром list_id , чтобы совместно редактировать один и тот же список задач с другими (или

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

Давайте приступим к его созданию.

Клиент



Мы начинаем с клиента, потому что он содержит ядро наше приложение.

Часть пользовательского интерфейса проста, и мы используем ее для создания. Другие реактивные UI-фреймворки, такие как Svelte и Vue, также должны работать. Я выбрал React из-за того, что мне знакомо.

В клиентской версии приложения просто написать:

  Импортировать   Реагировать  ,     {    useState    }   из   '  реагировать   '      экспорт   дефолт    функция     TodoApp   ()     {       const        знак равно    useState   ([])       const     [content, setContent]     знак равно   useState   (  ''  )       возвращение  (      
onSubmit={(e) => { e . preventDefault () если (содержание . длина > 0 ) { setTodos ( ( задачи ) => { возвращение [...todos, { content, completed: false }] } ) setContent ( '' ) } } } > autoFocus value={content} onChange={(e) => setContent ( e . цель. стоимость) } />
       
    { задачи . карта ( (сделать , показатель) => { возвращение ( key={index}> { сделать.содержание } флажок " проверено = {сделать. завершенный} onChange = { ( е ) => { setTodos ( ( задачи ) => { возвращение задачи . карта ( (сделать , я) => { если ( я === показатель) { возвращение { ... сделать , завершенный : e . цель. отмечен , } } еще { возвращение сделать } } ) } ) } } /> onClick={() => { setTodos ( ( задачи ) => { возвращение задачи . фильтр( ( сделать, я) => { возвращение показатель ! == i } ) } ) } } > Икс
  • ) } ) }
  • ) }

    Менее чем 500 строк кода, получившееся приложение уже выглядит и работает как конечный продукт, за исключением того, что его состояние непостоянно. Обновите браузер, и вы потеряете все свои задачи!

    Мы используем состояние для хранения данных, что нормально для входного значения, потому что оно временное по своей природе, но не совсем подходит для задач. Задачи должны быть:

    1. обновлено в локальном кэше браузера для максимальной скорости Этот паттерн иногда называют Оптимистическим UI
    2. синхронизировано с сервером для обеспечения устойчивости
    3. доставлено другим клиентам в правильном порядке и состоянии.
      1. В настоящее время существует множество интерфейсов управления состоянием. библиотеки на выбор: Redux, MobX, Recoil, клиенты GraphQL, такие как Apollo и Relay, и т. д. К сожалению, ни одна из них не работает в нашем случае использования. Что нам нужно, так это распределенная система со встроенной синхронизацией в реальном времени и разрешением конфликтов. Хотя есть

      хорошие писания по этой теме, распределенные системы все еще слишком сложно реализовать правильно для одного - человек команда. Я хотел бы помочь.

      После некоторого поиска появляется многообещающий вариант - Репликация , на главной странице которого написано:

      Replicache упрощает добавление совместной работы в реальном времени без задержек Пользовательский интерфейс и автономная поддержка веб-приложений. Он работает с любым внутренним стеком.

      Звучит слишком хорошо, чтобы быть правдой (спойлер: в основном это правда). Как Replicache достигает этих смелых заявлений? На его сайте документации есть

      целую страницу , чтобы объяснить, как это работает. Чтобы сэкономить ваше время, я кратко резюмирую здесь.

      Replicache реализует постоянное хранилище в браузере, используя IndexedDB. Вы можете изменить магазин локально и подписаться на часть магазина в своем пользовательском интерфейсе. При изменении данных подписки активируются повторно, а пользовательский интерфейс обновляется.

      Вам необходимо предоставить две конечные точки серверной части, чтобы Replicache мог разговаривать. в: replicache-pull и репликация -толкать. replicache-pull отправляет обратно подмножество вашей базы данных для текущего клиента. replicache-push обновляет базу данных от локальных мутаций. После применения мутации на сервере вы отправляете WebSocket Как говорится в документе Replicache, управление собственным сервером WebSocket связано с очень высокими эксплуатационными расходами. Мы используем Умело здесь. сообщение, предлагающее затронутым клиентам повторить запрос

      . )

      Это все, что вам нужно сделать. Relicache управляет всем процессом, чтобы гарантировать согласованность состояния при синхронизации в реальном времени.

      Мы погрузимся в интеграцию с серверной частью в следующем разделе этой статьи. А пока давайте перепишем связанный с состоянием код, используя Replicache:

         // Отображается только соответствующая часть      Импортировать   {    Репликация    }    из   '  репликация   '  Импортировать    {    использоватьПодписка    }   из    '  replicache-react   '   Импортировать   {    наноид    }    из   '  наноид   '      const     rep    знак равно   новый   Репликация   (  {      // другие параметры репликации       мутаторы  :     {      async     createTodo   (  tx  ,     {   я бы,  завершенный,   содержание ,   порядок    })     {     Ждите   tx  .  положил (  `сделать/ $ { я бы }   ` ,     {    завершенный ,     содержание ,     порядок ,     я бы ,      }  )      },       асинхронный     updateTodoCompleted   (  tx  ,     {  я бы,   завершенный   })     {      const   ключ   знак равно   ` сделать/ $ {  id  }   `      const    сделать  знак равно   Ждите   tx  .  получать(ключ   )      сделать .завершенный    знак равно ) завершенный      Ждите    tx   .положил  (  `  todo /   $ {я бы }   ` ,    сделать)     },       асинхронный     deleteTodo   (  tx  ,     {   я бы  })     {    Ждите   tx  .   del   (  `сделать/ $ {  я бы}  ` )      },      },    }  )       экспорт   дефолт    функция     TodoApp   ()     {      const     задачи   знак равно       useSubscribe   (  представитель  ,     асинхронный     (  tx  )     =>     {     возвращение Ждите    tx  .   сканирование   (  {  префикс :     'сделать/  '   }  )  .   записи   ()  .   toArray   ()      }  )   ??   []         const     onSubmit    знак равно    (  e  )     =>     {      e  .   preventDefault   ()     если  (содержание.   длина  >     0  )   {      rep  .   мутировать  .   createTodo   (  {    я бы :     наноид   ()  ,     содержание ,     завершенный :   ложный ,      }  )         setContent   (  ''  )      }      }         const     onChangeCompleted    знак равно     (  e  )     =>     {      представитель  .   mutate  .   updateTodoCompleted   (  {     я бы:   сделать.  я бы,    завершенный :     e  . цель .   проверено  ,      }  )      }         const     onDelete      знак равно   (  _ e  )     =>     {      представитель  .   мутировать  .   deleteTodo   (  {   я бы:   сделать .  я бы  }   )      }        // оказывать  }    

      Мы заменяем состояние в памяти React постоянным хранилищем Replicache. Приложение должно работать, как и раньше, за исключением того, что ваши тщательно написанные задачи не исчезнут при закрытии вкладки браузера.

      . мутаторы мы регистрируем при инициализации Replicache. Это основные API-интерфейсы, которые мы используем для взаимодействия с хранилищем Replicache. Когда они выполняются на клиенте, соответствующие мутации будут отправлены в конечную точку replicache-push с помощью Replicache.

      С помощью Replicache вы можете рассматривать состояние вашего клиента как гигантское хеш-таблица. Вы можете читать из него и писать в него по своему усмотрению, и Replicache будет послушно поддерживать синхронизацию состояния между сервером и всеми клиентами.

      Сервер




      Теперь перейдем к серверу.

      План ясен: мы реализуем две конечные точки, необходимые для Replicache, используя некоторый внутренний язык (в данном случае мы используем NodeJS) и некоторую базу данных. Единственное требование Replicache - это то, что база данных должна поддерживать определенный тип сделка.

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

      Поскольку мы реализовали оптимистичный пользовательский интерфейс на клиенте, большинство операции уже выполняются быстро (нулевая задержка). Чтобы изменения могли быстро синхронизироваться от одного клиента к другому, нам по-прежнему необходимо добиться низкой задержки для запросов к серверу. Надеюсь, задержка должна быть ниже 0773 мс для совместной работы, чтобы почувствовать в реальном времени .

      Мы можем добиться этого только путем глобального развертывания сервера и базы данных. . Если мы этого не сделаем и развернем только в одном регионе, задержка для пользователя на другом континенте будет выше нескольких сотен миллисекунд, независимо от того, что мы делаем. Это скорость света, точка.

      Глобальное развертывание сервера без отслеживания состояния должно быть простым. По крайней мере, так я думал изначально. Оказывается, я ошибался. В 2021, самый облако В основном я имею в виду PaaS как Heroku и Google App Engine. FaaS (функционирующий как услуга) намного проще развернуть глобально, но имеет свои собственные подводные камни. провайдеры по-прежнему позволяют развертывать сервер только в одном регионе. Вам нужно сделать много дополнительных шагов, чтобы получить глобальную настройку.

      К счастью, я нашел Fly.io , облачный сервис, который помогает вам «развертывать серверы приложений рядом с вашими пользователями», что как раз то, что нам нужно. Он поставляется с отличным инструментом командной строки и плавным процессом развертывания «push to deploy». Для масштабирования до нескольких регионов (в нашем случае - Гонконга и Лос-Анджелеса) требуется всего несколько нажатий клавиш. Более того, они предлагают довольно щедрый уровень бесплатного пользования.

      Остается только вопрос, какую базу данных мы должны использовать. Глобально распределенные базы данных с высокой согласованностью - это огромная и сложная область, которой в последние годы занимались крупные компании.

      Google Spanner , многие решения с открытым исходным кодом выходят. Один из самых безупречных конкурентов - CockroachDB . К счастью, они предлагают управляемую услугу с 64 - дневная пробная версия.

      Хотя мне удалось собрать версию Todo Light с использованием CockroachDB, конечный продукт в этой статье основан на гораздо более простом Настройка Postgres с распределенными репликами чтения. Работа с глобальной базой данных сопряжена с большими сложностями, которые не являются существенными для предмета этой статьи, поскольку потребуется еще одна часть.

      Нам нужны две таблицы, одна для задач и одна для клиентов репликации.

      Replicache должен отслеживать last_mutation_id различных клиентов для координации все мутации, подтвержденные или ожидающие. Столбец удален используется для мягкого удаления. Столбец версия используется для вычисления изменений для извлечения реплики, которые мы объясним позже.

      replicache-push конечная точка получает аргументы от локальных мутаторов. Давайте сохраним их в базе данных. Нам также необходимо увеличить lastMutationID в той же транзакции, как и предписано.

      database schema маршрутизатор . сообщение ( ' / replicache-push ' , асинхронный ( req , res ) => { const { list_id : listID } знак равно req . запрос const толкать знак равно req . тело пытаться { // db - это типичный объект, чем представляет соединение с базой данных Ждите db . tx ( асинхронный ( t ) => { позволять lastMutationID знак равно Ждите getLastMutationID ( t , толкать.ID клиента ) для ( const мутация из толкать . мутации ) { const знак равно lastMutationID + 1 если ( мутация .я бы < expectedMutationID ) { приставка . бревно( ` Мутация $ { мутация .я бы } уже обработано - пропуск ` , ) Продолжать } если ( изменено n . я бы > Ожидаемый идентификатор мутации ) { приставка.предупреждать ( ` Мутация $ { мутация . я бы} из будущего - прерывание ` ) сломать } // эти мутации автоматически отправляются Replicache, когда мы выполняем их аналоги на клиенте выключатель ( мутация .название ) { дело ' createTodo ' : Ждите createTodo ( t , мутация . args , listID ) сломать дело ' updateTodoCompleted ' : Ждите updateTodoCompleted ( t , мутация . args ) сломать дело ' updateTodoOrder ' : Ждите updateTodoOrder ( t , мутация . аргументы ) сломать дело ' deleteTodo ' : Ждите deleteTodo ( t , мутация . args ) сломать дефолт: бросать новый Ошибка( ` Неизвестная мутация: $ { мутация . название} ` ) } lastMutationID знак равно Ожидаемый идентификатор мутации } // после успешных мутаций мы используем Ably для уведомления клиентов const канал знак равно умело . каналы. получать( ` задачи - $ { listID } ` ) канал.публиковать ( ' изменять' , {} ) Ждите t .никто ( ' ОБНОВЛЕНИЕ replicache_clients SET last_mutation_id = $ 1 ГДЕ id = $ 2 ' , [lastMutationID, push.clientID] , ) res . Отправить( ' {} ' ) } ) } поймать ( e ) { приставка.ошибка ( e ) res .статус ( 0773 ) .Отправить ( e .нанизывать ()) } } ) асинхронный функция getLastMutationID ( t , ID клиента ) { const clientRow знак равно ) Ждите t . oneOrNone ( ' ВЫБРАТЬ last_mutation_id ИЗ replicache_clients ГДЕ id = $ 1 ' , ID клиента , ) если ( clientRow ) { возвращение синтаксический анализ ( clientRow . last_mutation_id ) } Ждите т . никто ( ' INSERT INTO replicache_clients (id, last_mutation_id) VALUES ($ 1, 0) ' , ID клиента, ) возвращение 0 } асинхронный функция createTodo ( т , { я бы, завершенный, содержание , порядок }, listID ) { Ждите t . никто( ` ВСТАВИТЬ В задачи ( значения идентификатора, завершения, содержимого, ord, list_id (1 доллар, 2 доллара, 3 доллара, 4 доллара, 5 долларов) ` , [id, completed, content, order, listID] , ) } асинхронный функция updateTodoCompleted ( t , { я бы, завершенный }) { Ждите т .никто ( ` ОБНОВИТЬ задачи SET завершен = $ 2, версия = gen_random_uuid () ГДЕ id = $ 1 ` , [id, completed] , ) } // другие подобные функции SQL CRUD опускаются

      извлечение репликации конечная точка требует больше усилий. Общий план состоит в том, что при каждом запросе на извлечение репликэша мы вычисляем различие состояния и произвольный файл cookie (не путать с HTTP cookie) для отправки обратно клиенту. Файл cookie будет прикреплен к последующему запросу для вычисления разницы. Промойте и повторите.

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

       стратегии .  Мы будем использовать наиболее рекомендуемый вариант:  стратегия строковой версии . 

      database schema
         роутер  .  сообщение(   '  / replicache-pull   ' ,     асинхронный     (  req  ,     res  )     =>     {      const    тянуть знак равно     req   .тело      const     {  list_id  :     listID    }    знак равно    req  .  запрос      пытаться   {     Ждите   db  .   tx   (  асинхронный     (  t  )     =>     {      const     lastMutationID    знак равно    синтаксический анализ   (    (    Ждите   t  .   oneOrNone   (      '  выберите last_mutation_id из реплики_  клиенты, у которых id = $ 1   ' ,     тянуть .  ID клиента,    )    )  ?.   last_mutation_id     ??     '  0   ' ,    )         const     todosByList    знак равно  Ждите     t  .   manyOrNone   (      '  выберите идентификатор, завершено, содержимое, порядковый номер, удалено, версия из задач, где list_id = $ 1   ' ,       listID  ,    )         // патч - это массив мутаций, который будет применен к клиенту       const    пластырь знак равно   []         const     cookie    знак равно    {}         // Для первоначального вызова мы просто очищаем клиентское хранилище .      если (тянуть .   cookie     ==    значение NULL)   {     пластырь.толкать (  {    op  :     ' Очистить'   }  )      }         todosByList  .для каждого  (      ({   я бы,    завершенный,   содержание,     ord  ,    версия,    удалено    })     =>     {      // Файл cookie - это карта от идентификатора строки к версии строки.       // По мере роста количества задач он может стать слишком большим для эффективного обмена.       // К тому времени мы можем вычислить хэш как файл cookie и сохранить фактический файл cookie на сервере.       cookie   [id]   знак равно версия       const    ключ  знак равно    `  задача /   $ {я бы}   `       если (тянуть.   cookie     ==    значение NULL   ||     пу  ll  .   cookie   [id]  ! ==     версия  )   {    если (  удалено  )   {    пластырь .  толкать (  {      op  :     '  del   ' ,     ключ ,      }  )      }    еще   {      // все дополнения и обновления представлен как операция 'положить'     пластырь .  толкать(  {      op  :    'положил  ' ,     ключ ,     стоимость :     {    я бы ,     завершенный ,     содержание ,     порядок :     ord  ,      },      }  )      }      }      },    )         res  .   json   (  {    lastMutationID  ,     cookie  ,   пластырь   }   )       res   .конец  ()      }  )      }    поймать (  e  )   {      res  . статус (  0773  )  . Отправить  (  e  . нанизывать  ())      }    }  )    

      Потому что version - это случайный UUID, сгенерированный Postgres gen_random_uuid , мы можем использовать ее для эффективного расчета, был ли обновлен элемент задачи или нет.

      Это все, что касается серверного кода, и мы подошли к концу нашего пути. С помощью множества отличных инструментов мы успешно создали быстрое приложение для совместной работы. Что еще более важно, мы разработали достаточно простой подход к созданию похожих веб-приложений. По мере роста базы пользователей и набора функций этот подход будет хорошо масштабироваться как по производительности, так и по сложности.

    Бонус - Реализация переупорядочения с дробным индексированием


    Вы можете заметить, что мы используем тип текст для столбца ord в схеме базы данных, который, кажется, лучше подходит для числового типа. Причина в том, что мы используем метод под названием Дробное индексирование для изменения порядка. Проверить исходный код Todo Light или попробуйте реализовать его самостоятельно. Это должно быть интересной практикой.

    На момент написания одним из недостатков Replicache было то, что его локальные транзакции не обрабатывались. достаточно быстро, чтобы разрешить сложные взаимодействия, такие как перетаскивание. Чтобы предотвратить отставание, мы включили

     useMemstore: true  опция отключения автономной поддержки.  Надеюсь, это скоро будет исправлено.