| Vectorified Science Blog |

Free Vector Icons

Создание приложения CRUD с помощью Datomic Cloud Ions

Перевод статьи - Building a CRUD app with Datomic Cloud Ions

Автор - Jacob O'Bryant

Источник оригинальной статьи:

https://jacobobryant.com/post/2019/ion/

Я только что выпустил FlexBudget, версию веб-сайта сценария, который я написал несколько месяцев назад для удовлетворения наших потребностей в бюджетировании. [1] Я построил его с использованием ионов Datomic Cloud. Я начал использовать Datomic On-Prem где-то в прошлом году, но я впервые использовал Datomic Cloud (не говоря уже об ионах). [2]

Я думаю, что Clojure + Datomic открывает двери для некоторых великих инноваций в архитектуре веб-приложений (например, идей, обсуждаемых в этом классическом разделе), не говоря уже о том, что один только Datomic испортил меня, и я не знаю, смогу ли я когда-нибудь вернуться к SQL сейчас , Но даже с ионами, я думаю, еще предстоит проделать большую работу над историей веб-разработки Clojure. Далее следует описание моего опыта по настройке FlexBudget.

Примечание. В этом посте предполагается, что вы уже знакомы с Clojure и Datomic.

Маршрутизация

В итоге я использовал моно-лямбду, которая пересылала все запросы одному и тому же иону, затем этот ион использовал Compojure, чтобы направить запрос соответствующему обработчику. Для локальной разработки я просто запустил локальный веб-сервер с этим обработчиком.

(def handler'
  (-> routes
      ; various middleware
      wrap-catchall))

(def handler (ionize handler'))

(defn start-immutant []
  (imm/run handler' {:port 8080}))

(Я начал использовать Aleph, но получал странную ошибку, когда он не передавал запросы моему обработчику. Я не уверен, что там происходит, но все снова заработало, когда я использовал Immutant. Я также попробовал Jetty , но у него были конфликты зависимостей с Datomic Cloud.)

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

Функции транзакций

Это было сложнее. Чтобы использовать пользовательскую функцию транзакции, ее необходимо развернуть. Вы не можете сказать Transactor (который работает в облаке) как-то использовать функцию локальной транзакции, которая определена только на вашем ноутбуке. Официальный совет:

Транзакционные функции - это чистые функции, поэтому вам не нужно их где-либо развертывать для тестирования. Вы можете просто вызывать их как обычный код в своем REPL или тестовом наборе.

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

Итак, я написал функцию eval-tx-fns которая принимает функцию транзакции и применяет ее локально. Затем «простая» транзакция может быть отправлена ​​в транзакционную компанию.

(def transact (if (:local-tx-fns? config)
                (let [lock (Object.)]
                  (fn [conn arg-map]
                    (locking lock
                      (->> #(u/eval-tx-fns (d/with-db conn) %)
                           (update arg-map :tx-data)
                           (d/transact conn)))))
                d/transact))

Из-за лени я создал эту transact функцию и использовал ее всякий раз, когда мне нужно было что-то сделать. Мне также пришлось написать аналогичную функцию замены для with . Вероятно, было бы чище создать собственную реализацию клиентского протокола Datomic, как это сделано здесь.

Вызов locking используется, чтобы убедиться, что транзакции остаются сериализованными. Это работает только потому, что во время dev все транзакции в базе данных dev, которую я использую, проходят через одну машину (мой ноутбук). И быть единственным разработчиком, использующим топологию Solo, это нормально. Однако это может быть проблемой для производственной системы с группами запросов для разных этапов.

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

Эта стратегия может снизить пропускную способность транзакций, но, вероятно, это приемлемо для этапов разработки. Альтернативное решение - запуск отдельной производственной топологии для каждого этапа, но я предполагаю, что это будет дороже. [3]

Развертывание

Я написал этот сценарий Planck, чтобы автоматизировать шаги «push, deploy, deploy, команда состояния deploy до тех пор, пока она не преуспеет или не прекратится».

У меня были некоторые проблемы с неудачей развертывания, даже после того, как я разрешил конфликты зависимостей и проверил локально. Проблема всегда заключалась в том, что у меня был какой-то фрагмент кода инициализации, который запускался, как только код был загружен. Это привело к зависанию развертывания и времени ожидания. (В частности, шаг ValidateService зависнет, как в этом вопросе).

Например, вам не следует загружать конфигурацию с помощью datomic.ion/get-params до тех пор, пока не datomic.ion/get-params запрос. Вы можете запоминать поиск следующим образом:

(def get-params
  (memoize
    (fn [env]
      (-> {:path (str "/datomic-shared/" env "/bud/")}
          ion/get-params keywordize-keys))))

И тогда не называй это, пока тебе не придется . Я использовал Firebase для аутентификации, и он требует некоторого кода инициализации, который извлек секрет из get-params :

(let [options ...] ; includes a call to get-params
  (FirebaseApp/initializeApp options))

Одна из моих ошибок развертывания произошла, потому что у меня был запущен код инициализации Firebase. Развертывание снова заработало после того, как я поместил его в init-firebase! Функция, которую я затем вызывал только при проверке токенов:

(defn verify-token [token]
  (when (= 0 (count (FirebaseApp/getApps)))
    (init-firebase!))
  ...)

Я также обернул свои вызовы к d/client и d/conn conn в запомненные функции, как в проекте Ion Starter, но я обнаружил, что они не были переопределены, когда я запустил clojure.tools.namespace.repl/refresh . Поэтому я определил их как состояния монтирования:

(mount.core/defstate client :start
  (d/client (:client-cfg config)))

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

(defn wrap-start-mount [handler]
  (fn [req]
    (when (contains? #{mount.core.NotStartedState
                       mount.core.DerefableState}
                     (type client))
      (mount.core/start))
    (handler req)))

Интерлюдия

Добавьте несколько журналов с datomic.ion.cast и в нем рассказывается о моем опыте непосредственно с ионами Datomic Cloud. Потребовалось некоторое время, чтобы разобраться, но я доволен этим сейчас, хотя функция транзакции кажется немного глупой (я не уверен, что еще с этим делать).

Остальная часть этой статьи посвящена тому, как я настраиваю взаимодействие между интерфейсом и бэкэндом.

DataScript

Недавно я написал веб-приложение Clue, в котором для хранения состояния внешнего интерфейса использовался один атом (с Datomic на бэкэнде). Настольные игры, как правило, имеют сложные модели данных, и я определенно почувствовал, что из-за несоответствия импеданса мне приходится проецировать данные Datomic на иерархический атом. Итак, в этом проекте (хотя модель данных здесь намного проще), я определенно хотел использовать DataScript.

После того, как пользователь входит в систему, веб-интерфейс достигает конечной точки /init которая возвращает его данные:

(defn datoms-for [db uid]
  (let [user-eid (:db/id (d/pull db [:db/id] [:user/uid uid]))]
    (->>
      (conj
        (vec (d/q '[:find ?e ?attr ?v :in $ ?user :where
                    [?ent :auth/owner ?user]
                    (or
                      [(identity ?ent) ?e]
                      [?ent :entry/asset ?e])
                    [?e ?a ?v]
                    [?a :db/ident ?attr]]
                  db user-eid))
        [user-eid :user/uid uid])
      (u/stringify-eids ds-schema))))

По сути, datoms-for ищет значения :auth/owner которые соответствуют текущему пользователю. Я не использую никаких фильтров БД, но лучше сделать это, а затем разрешить интерфейсу отправлять произвольный запрос.

Datoms также проходят через функцию stringify-eids которую я написал. Эта функция берет, например, [[1 :foo 2] [3 :some/ref 1]] и превращает ее в [["1" :foo 2] ["3" :some/ref "1"]] . Таким образом, DataScript будет обрабатывать идентификаторы объектов как временные, и будут назначаться новые идентификаторы. Это важно, потому что идентификаторы сущностей Datomic могут быть больше, чем Number.MAX_SAFE_INTEGER JavaScript. Поэтому вместо использования идентификаторов сущностей Datomic на внешнем интерфейсе я позволил DataScript назначить свои собственные идентификаторы, а затем сохранить соответствие между идентификаторами DataScript и идентификаторами Datomic (которые хранятся на внешнем интерфейсе в виде строк).

Чтобы быть точным, они на самом деле хранятся как теги с литералами, например, #eid "123456789" . Я вернусь к этому позже, но это позволяет [[#eid "12345789" :bar "hello"]] интерфейсу отправлять транзакцию, например [[#eid "12345789" :bar "hello"]] , а затем я просто включаю запись для eid в свой data_readers.clj файл.

Я также передаю аргумент ds-schema («схема DataScript») в stringify-eids . Это происходит из библиотеки, которую я разделяю между внешним и внутренним интерфейсами:

(def schema
  {:user/uid [:db.type/string :db.unique/identity]
   :user/email [:db.type/string :db.unique/identity]
   :auth/owner [:db.type/ref]
   :entry/date [:db.type/instant]
   :entry/draft [:db.type/boolean]
   ; etc
   ]})

(def datomic-schema (u/datomic-schema schema))
(def ds-schema (u/datascript-schema schema))

Материализованные взгляды

На самом деле я никогда не использовал много раз. Несмотря на то, что я использую DataScript вместо обычного атома для хранения состояния внешнего интерфейса, существует re-posh, которая объединяет перекадровку с Posh, библиотекой, которая позволяет определять реактивные запросы DataScript. Я использовал Posh немного, но

  1. Это нарушает некоторые крайние случаи, включая случай, с которым я столкнулся.
  2. Вы не можете использовать pull внутри запросов.

Поэтому вместо этого я написал макрос defq :

(defq entries
  (->> @conn
       (d/q '[:find [(pull ?e [*]) ...] :where
              (or [?e :entry/draft]
                  [?e :entry/date])])))

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

Очевидно, это не будет быстрым, когда у вас много запросов, но на данный момент этого достаточно. Я вернусь позже.

Помимо defq , я обнаружил, что использование простого старого reagent.ratom/reaction хорошо и лаконично:

(def entry (reaction (last @entries)))
(def draft? (reaction (:entry/draft @entry)))

Я храню все это в одном пространстве имен, поэтому я могу ссылаться на них из своих представлений Реагента, например, @db/entries или @db/draft? ,

Компоненты

Я в основном использовал re-com, и это действительно приятно. У меня было два небольших раздражения, хотя. Во-первых, все параметры определяются с помощью деструктуризации карты. Это означает, что когда вы используете элементы контейнера, вы должны написать [rc/h-box :children [foo bar baz]] вместо просто [rc/h-box foo bar baz] . Контейнеры используются довольно часто и имеют все это :children могут сложить.

Это не так уж плохо, я просто определил свои собственные компоненты h-box и v-box которые не использовали деструктуризацию карты.

Другая вещь, с которой я столкнулся, была, когда я использовал компонент horizontal-tabs и я не мог изменить цвета, используя встроенные стили; Мне пришлось включить отдельный файл CSS, чтобы переопределить стили Bootstrap.

В дальнейшем было бы неплохо, чтобы все было полностью настраиваемым с помощью встроенных стилей, поэтому мне нужно решить, хочу ли я продолжать использовать re-com и / или Bootstrap и вносить какие-то изменения, или мне следует свернуть свои собственные. Я признаю, что я не большой пользователь UI, но было бы неплохо выяснить систему, которая работает для меня (и позволяет мне легко создавать сайты, которые выглядят хорошо. Я думаю, есть люди, которые заботятся об этом).

Транзакции на фронтэнде

На внешнем интерфейсе я также определил пользовательский transact! функция:

(defn transact! [persist-fn conn tx & queries]
  (let [tx-result (d/transact! conn tx)]
    (apply invalidate! queries)
    (go (let [tx (u/translate-eids (:schema @conn) (::eids @conn) tx)
              eids (<! (persist-fn tx))
              tempids (reverse-tempids tx-result eids)]
          (swap! conn update ::eids merge tempids)))
    tx-result))

Это делает несколько вещей:

  1. Он немедленно применяет транзакцию к базе данных веб-интерфейса. В настоящее время у меня нет ничего для отката, если транзакция не выполняется на бэкэнде; это часть моей будущей работы.

  2. invalidate! это то, что обновляет запросы, которые я определил ранее, с помощью defq .

  3. translate-eids обходит транзакцию, заменяя идентификаторы сущностей DataScript на идентифицированные тегами Datomic ID, как я упоминал ранее. Например, учитывая транзакцию [[:db/add 1 :foo "bar"]] и отображение идентификатора сущности {1 #eid "12345"} , возвращаемое значение будет [[:db/add #eid "12345" :foo "bar"]] (сюрприз). К сожалению, мы не можем сделать что-то простое с clojure.walk/postwalk например, «если элемент является ключом в карте идентификатора объекта, замените его значением», потому что мы не знаем, является ли число на самом деле идентификатором объекта или просто число. Единственный способ узнать, это пройти транзакцию в соответствии с грамматикой и заменить идентификаторы сущности на этом пути. Это было немного утомительно писать, но не супер сложно.

  4. persist-fn принимает транзакцию и отправляет ее бэкэнду. Бэкэнд возвращает идентификаторы сущностей любых вновь созданных сущностей. Например, если вы произвели транзакцию [[:db/add "tmp-id" :foo "bar"]] и новый идентификатор объекта, назначенный Datomic, был 12345, бэкэнд (и, следовательно, persist-fn ) вернул бы {"tmp-id" #eid "12345"} .

  5. reverse-tempids будет использовать это возвращаемое значение для сопоставления идентификаторов объектов, назначенных DataScript, с идентификаторами, назначенными Datomic. Продолжая предыдущий пример, если DataScript назначит идентификатор объекта 4, то возвращаемое значение reverse-tempids будет {4 #eid "12345"} .

Транзакции на бэкэнде

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

Я настроил одну конечную точку /tx для получения транзакций. После получения он сначала проверяет, что транзакция не включает функции транзакции, которые не были внесены в белый список. Затем мы проводим транзакцию через функцию транзакции, которая называется authorize . Эта функция спекулятивно выполняет транзакцию, используя d/with . Затем он анализирует результат, чтобы выяснить, какие объекты были затронуты.

Каждый объект, который был изменен, должен передать функцию авторизатора приложения. Вот пример функции авторизатора; это позволит пользователю создавать объект сообщения, если он указан в качестве отправителя этого сообщения:

(s/def ::message (u/ent-spec :req [:message/text :message/sender]))

(def authorizers
  {[nil ::message]
  (fn [{:keys [uid eid datoms db-before db-after before after]}]
    (not-empty
      (d/q '[:find ?e :in $ ?e ?user :where
             [?e :message/sender ?user]]
        db-after eid [:user/uid uid])))})

Я расскажу это сейчас:

Функция authorizer получает аргумент, который включает следующие ключи:

Итак, если вы предоставите спецификации и функции авторизатора, то authorize может позаботиться обо всем остальном. Он отделяет логику того, какие изменения разрешены, от того, как эти изменения доставляются в бэкэнд; поэтому для последнего мы можем сказать: «отправь их всех в одно и то же место и отправь в любой форме».

Будущая работа

Одним из ключевых выводов здесь является то, что подавляющее большинство моего времени не было посвящено только логике приложения. Эрик Норманд описал необходимость «скучного веб-фреймворка». Я думаю, что это хороший анализ. Насколько я могу судить, Clojure получил широкое признание среди новаторов и ранних последователей. Вы можете сделать некоторые интересные вещи, если вы потратите время, чтобы настроить его самостоятельно, и это более или менее хорошо для людей, которые уже знают Clojure. Но если мы автоматизируем этот процесс, у Clojure будет гораздо больше шансов преодолеть пропасть в раннем большинстве.

Создав FlexBudget, я постарался разделить как можно больше вещей на библиотеки. Я планирую продолжить этот процесс и попытаться создать веб-фреймворк, который позволит даже начинающим пользователям Clojure приступить к работе со стеком Clojure + Datomic, в котором есть все эти архитектурные компоненты, которые я описал. Я также собираюсь добавить больше компонентов, таких как связь в реальном времени. Особенно, если / когда станет доступен реактивный Datalog, я думаю, что эта среда может быть большим благом для разработки веб-приложений.



Примечания

[1] Я думаю, что большинство подходов к составлению бюджета, например составление бюджета с нулевой суммой, вынуждают вас вдаваться в детали. Когда я отслеживаю использование ресурса, мне все равно, как именно используется ресурс - мне просто нужен высокий уровень «все в порядке или нет». Если есть проблема, тогда я буду использовать профилировщик / анализатор использования диска / etc, чтобы копать глубже. Я использую FlexBudget, чтобы дать мне высокий уровень «все в порядке», но он не предназначен для профилирования.

[2] Прежде чем я прочитал документацию по ионам, я подумал, что это всего лишь своего рода хак, позволяющий вам определять функции транзакций в облаке. Для тех, кто еще не знает, они на самом деле гораздо больше, чем это. Они позволяют вам повторно использовать инфраструктуру Datomic Cloud для развертывания вашего приложения, поэтому вам не нужно заниматься настройкой собственной инфраструктуры. Это большой шаг к Святому Граалю, когда нужно думать только о логике своего приложения.

Учитывая это, я намного лучше с ценой Datomic Cloud за $ 30 в месяц. Раньше я думал, что это слишком много только для стороннего проекта, который не приносит никаких денег, но теперь я в порядке с этим из-за времени, которое меня экономит. Вернее, время сэкономит мне теперь, когда я знаю, как его использовать.

[3] Я собирался поговорить о возможности Cognitect сделать это так, чтобы у каждой группы запросов мог быть свой собственный транзактор, но потом я понял, что это просто сведется к запуску отдельных производственных топологий. Поэтому я сомневаюсь, есть ли какие-либо возможные решения, которые лучше, чем я описал.

[4] В отношении дискуссии «библиотеки против фреймворков»: проблема фреймворков не в том, что они много для вас делают, а в том, что их сложно изменить, если вы хотите что-то другое. С достаточной тщательностью мы могли бы создать библиотеку / фреймворк, который установит для вас множество настроек по умолчанию, но при этом позволит вам настроить его так, как вам нужно.


Есть отзыв? foo@jacobobryant.com