Começando com XTDB e Clojure

2021-10-23

##O que eu preciso ter instalado?

O XTDB pode ser usado com Java, Clojure e via API HTTP. Eu vou usar Clojure nesse guia, então você vai precisar ter o Clojure instalado. Junto de Clojure, você obviamente precisa do Java (8+).

Para criar o projeto, vou usar o Leiningen.

Tanto Java, Clojure e Leiningen você consegue instalar facilmente via Homebrew. Note que o Homebrew também funciona no Linux!

Como persistência eu vou usar o PostgreSQL através do JDBC. Mas note que esse guia espera que você já possua uma instância do PostgreSQL executando na sua máquina. Por exemplo, eu tenho uma rodando no Docker.

Aqui o XTDB aceita várias formas de persistência de dados. Você pode usar qualquer uma que está listada no site:

  • JDBC
  • Kafka
  • RocksDB
  • AWS S3

E vários outros.

##Criando o App

Entre em um diretório qualquer. Por exemplo, eu vou usar o ~/Workspace no meu computador como referência. Em seguida crie um app simples:

$ lein new xtdb-app

Entre no diretório xtdb-app e em seguida abra o arquivo project.clj. Nele vamos inserir as dependências necessárias.

project.clj
(defproject xtdb-app "0.1.0-SNAPSHOT"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "EPL-2.0 OR GPL-2.0-or-later WITH Classpath-exception-2.0"
            :url "https://www.eclipse.org/legal/epl-2.0/"}
  :dependencies [[org.clojure/clojure "1.10.3"]
                 [com.xtdb/xtdb-core "1.19.0"]
                 [com.xtdb/xtdb-jdbc "1.19.0"]
                 [com.xtdb/xtdb-rocksdb "1.19.0"]

                 [org.postgresql/postgresql "42.2.2.jre7"]]
  :repl-options {:init-ns xtdb-app.core})

Precisamos do com.xtdb/xtdb-code para conseguir usar as funcionalidades do XTDB. Também precisamos do com.xtdb/xtdb-jdbc e org.postgresql/postgresql para conseguir fazer o XTDB conversar com o PostgreSQL.

Note que é necessário manualmente indicar o driver do Postgre como dependência, caso contrário o driver não será baixado automaticamente.

Legal, agora podemos entrar no REPL digitando lein repl.

De agora em diante, eu vou usar o Calva no VSCode para escrever e executar código.

##Conectando com o XTDB

Para facilitar a nossa vida, vou criar o namespace xtdb-app.db para alocar o código que faz o trabalho de se conectar com o banco de dados.

Se você não conhece muito bem Clojure ainda, encare um namespace como um arquivo que podemos definir funções, macros, valores e mais várias outras coisas e importar essas coisas em outros arquivos (namespaces).

src/xtdb_app/db.clj
(ns xtdb-app.db
  (:require
   [xtdb.api :as xt]))

(defn start-xtdb! []
  (xt/start-node
   {:xtdb.jdbc/connection-pool
    {:dialect {:xtdb/module 'xtdb.jdbc.psql/->dialect}
     :pool-opts {:maximumPoolSize 2}
     :db-spec {:dbname "xtdb-test"
               :host "localhost"
               :port 15432
               :user "postgres"
               :password "***"}}

    :xtdb/tx-log
    {:xtdb/module 'xtdb.jdbc/->tx-log
     :connection-pool :xtdb.jdbc/connection-pool}

    :xtdb/document-store
    {:xtdb/module 'xtdb.jdbc/->document-store
     :connection-pool :xtdb.jdbc/connection-pool}}))

(defn stop-xtdb! [node]
  (.close node))

Aqui não estou usando variáveis de ambiente para evitar de complexificar o guia. Porém vale notar que profissionalmente você deveria definir os dados de conexão com o banco de dados em variáveis de ambiente.

No código anterior, duas funções são definidas: start-xtdb! e stop-xtdb!.

A função start-xtdb! retorna um nó que representa uma conexão com o banco de dados. Ao criar a conexão com o banco de dados nós precisamos declarar como o XTDB vai gerenciar os logs de transação e documentos.

Documentos são versões de um conjunto de fatos. Fatos podem ser quase qualquer coisa. Por exemplo, o nome de um usuário é um fato. O e-mail desse usuário também é um fato. O id desse usuário é outro fato. Alguns fatos podem mudar de acordo com o tempo.

Você pode mudar o seu nome e seu e-mail. O que o XTDB faz é promover uma arquitetura de dados adequada para armazenar um registro de mudanças.

Podemos testar se a conexão com o banco de dados está funcionando ao criar um nó. Vamos adicionar uma linha logo abaixo da definição da função stop-xtdb!:

src/xtdb_app/db.clj
;; ...

(def node (start-xtdb!))

Ao executar essa linha com o Calva, uma nova instância do XTDB é iniciada. No meu editor algo parecido com isso é mostrado logo após executar essa linha:

src/xtdb_app/db.clj
;; ...

(def node (start-xtdb!)) => #'xtdb-app.db/node

Nada explodiu? Então funcionou! Agora delete essa linha.

##Criando usuários

Vou seguir a mesma abordagem usada para criar o namespace xtdb-app.db e vou criar o namespace xtdb-app.user.core. Como você talvez possa imaginar, isso se traduz em criar um arquivo em src/xtdb_app/user/core.clj.

src/xtdb_app/user/core.clj
(ns xtdb-app.user.core
  (:require
   [xtdb.api :as xt]))

(defn put
  [node user]
  (xt/submit-tx
    node
    [[::xt/put
      {:xt/id (:id user)
       :user/name (:name user)
       :user/email (:email user)}]]))

Aqui é definida a função put. Essa função aceita um nó e um usuário como argumento e despacha uma transação para o XTDB. O XTDB lida com transações, na verdade o XTDB possui um conjunto bem pequeno de transações quando comparado a bancos de dados.

Ao submeter transações, nós podemos submeter várias de uma vez. Esse é o motivo do argumento da função xt/submit-tx aceita um vetor de vetores.

A transação que estamos submetendo para o XTDB é um put (::xt/put). Essa transação cria uma nova versão de um documento no instânte de tempo que for processada no banco de dados.

Ou seja, você submete a vontade de "ah, vou salvar um negócio no banco", essa "vontade" entra em uma fila (em produção, durante esse ambiente de desenvolvimento não tem nada de fila envolvido), então é processado pelo XTDB, é salvo no banco, o documento é indexado e só depois de ser indexado, o documento pode ser encontrado via query.

Podemos adotar uma abordagem bloqueante, mas sinceramente não vejo necessidade.

Durante o desenvolvimento, a performance do XTDB é excelente e você também não gostaria de fazer coisas bloqueantes em produção, não é mesmo?

##Juntando tudo

Lá no arquivo que foi criado automaticamente para nós, o src/xtdb_app/core.clj, eu vou juntar tudo e fazer funcionar.

Vou começar importando o namespace do nosso banco de dados e criar um alias para o nome db. Também vou importar o namespace de gerenciamento de usuários e usar o nome user para acessar as funções definidas lá.

src/xtdb_app/core.clj
(ns xtdb-app.core
  (:require
   [xtdb-app.db :as db]
   [xtdb-app.user.core :as user]))

Agora vou definir um nó do XTDB:

src/xtdb_app/core.clj
(ns xtdb-app.core
  (:require
   [xtdb-app.db :as db]
   [xtdb-app.user.core :as user]))

(def node (db/start-xtdb!))

Bom, precisamos salvar alguma coisa no banco de dados. Vou aproveitar que estou usando o REPL e já chamar a função user/put:

src/xtdb_app/core.clj
(ns xtdb-app.core
  (:require
   [xtdb-app.db :as db]
   [xtdb-app.user.core :as user]))

(def node (db/start-xtdb!))

(user/put node {:id "user-id"
                :name "Gustavo Santos"
                :email "gustavo@email.com"})

Executando esse último bloco de código, no REPL tenho essa resposta:

clj꞉xtdb-app.db꞉> 
#:xtdb.api{:tx-id 19, :tx-time #inst "2021-10-24T00:31:58.640-00:00"}

A resposta pode não ser a mais bonita, mas é significado de sucesso!

Uma transação foi despachada para o XTDB, essa transação tem o id 19 e foi criada no instante de tempo #inst "2021-10-24T00:31:58.640-00:00".

Ou seja, está no banco de dados!

Se você não acredita em mim, vamos criar uma função para encontrar esse usuário no banco de dados e já aproveitar para sujar os dedos com Datalog.

src/xtdb_app/user/core.clj
;; ...

(defn find-by-id
  [node id]
  (xt/q
   (xt/db node)
   '{:find [(pull ?user [:xt/id :user/name :user/email])]
     :in [id]
     :where [[?user :xt/id id]]}
   id))

Aqui estou definido uma função chamada find-by-id. Como você já deve imaginar, vamos chamar ela lá no arquivo core.clj usando (user/find-by-id ...).

Essa função recebe um nó do XTDB e um id. O corpo dessa função que é a parte mais interessante da história. A função xt/q recebe uma versão do banco de dados. É isso mesmo, a função xt/db retorna uma versão do banco de dados baseado no nó.

Obviamente, essa versão não possui os dados do banco de dados, é apenas uma estrutura de dados que contém a representação temporal do banco de dados.

O outro argumento da função xt/q é a query escrita no formato do Datalog.

Digo e "formato" do Datalog, porque o Datalog é só uma especificação. A query é escrita em código Clojure válido.

Não vou tentar explicar os detalhes da query, mas entenda que na linha do find, é feito um pull das propriedades :xt/id, :user/name e :user/email da variável ?user.

?user é cada usuário que passou pelo filtro do bloco where.

Ou seja, nessa query estamos retornado todas as propriedades de todos os usuários, cujo :xt/id é igual ao id passado via argumento para a função xt/q.

Vamos voltar lá no arquivo core.clj e buscar pelo usuário.

src/xtdb_app/core.clj
(user/put node {:id "user-id"
                :name "Gustavo Santos"
                :email "gustavo@email.com"})

(user/find-by-id node "user-id")

Note que eu passei como argumento o id "user-id", que é o mesmo que usei para criar o usuário anteriormente.

Se eu executar esse código no REPL, o Clojure me retorna o seguinte:

#{[{:user/name "Gustavo Santos", :user/email "gustavo@email.com", :xt/id "user-id"}]}

Vou fazer um favor a nós e importar a função pprint para visualizar melhor esses dados.

src/xtdb_app/core.clj
(ns xtdb-app.core
  (:require
   [clojure.pprint :only [pprint]]
   [xtdb-app.db :as db]
   [xtdb-app.user.core :as user]))

(def node (db/start-xtdb!))

(user/put node {:id "user-id"
                :name "Gustavo Santos"
                :email "gustavo@email.com"})

(pprint (user/find-by-id node "user-id"))

Ao executar, tenho no REPL:

#{[{:user/name "Gustavo Santos",
    :user/email "gustavo@email.com",
    :xt/id "user-id"}]}

Melhor, não?

##Histórico de updates

Essa é a hora que o XTDB brilha. Vamos atualizar esse usuário. Digamos que agora eu mudei de e-mail e o meu novo e-mail é email@gustavo.com.

Podemos usar a função user/put para inserir uma nova versão do meu documento, desde que usemos o mesmo id.

src/xtdb_app/core.clj
(user/put node {:id "user-id"
                :name "Gustavo Santos"
                :email "email@gustavo.com"})

Se eu executar esse trecho, vejo no REPL:

#:xtdb.api{:tx-id 21, :tx-time #inst "2021-10-24T00:51:12.531-00:00"}

Hum, duas transações diferentes em dois instantes de tempo diferentes... Talvez eu consiga obter um histórico de versões.

Se eu buscar pelo meu usuário novamente, obtenho:

#{[{:user/name "Gustavo Santos",
    :user/email "email@gustavo.com",
    :xt/id "user-id"}]}

A função xt/entity-history faz exatamente. Para uma entidade associada a um id global (o id "user-id" é um id global), podemos obter todas as versões dessa entidade.

Vou definir uma pequena função para fazer o meio de campo:

src/xtdb_app/core.clj
(defn doc-history [id]
  (xt/entity-history
   (xt/db node)
   id
   :desc
   {:with-docs? true}))

Agora posso chamar essa função passando o meu id:

src/xtdb_app/core.clj
(defn doc-history [id]
  (xt/entity-history
   (xt/db node)
   id
   :desc
   {:with-docs? true}))

(pprint (doc-history "user-id"))

Obtenho no REPL:

[#:xtdb.api{:tx-time #inst "2021-10-24T00:51:12.531-00:00",
            :tx-id 21,
            :valid-time #inst "2021-10-24T00:51:12.531-00:00",
            :content-hash
            #xtdb/id "c79bc9608fb9acafe694586e07b9c09d895f55f8",
            :doc
            {:user/name "Gustavo Santos",
             :user/email "email@gustavo.com",
             :xt/id "user-id"}}
 #:xtdb.api{:tx-time #inst "2021-10-24T00:31:58.640-00:00",
            :tx-id 19,
            :valid-time #inst "2021-10-24T00:31:58.640-00:00",
            :content-hash
            #xtdb/id "439a0c1a4d13dbfb2dc5af8861b4772cd244485a",
            :doc
            {:user/name "Gustavo Santos",
             :user/email "gustavo@email.com",
             :xt/id "user-id"}}]

E isso, é o poder da bitemporalidade!

#clojure#xtdb

📝 Edite esta página