Coordenação: Copycat
Copycat é um arcabouço de replicação de máquinas de estados implementada pela Atomix. Na base do Copycat está uma implementação do Raft. Sobre o Raft, uma API simples mas moderna permite a criação de máquinas de estados usando lambdas, futures, e o estilo fluent de encadeamento de invocações.
- Classe com um único método.
1 2 3 4 5 6 7 8 |
|
- Classe anônima - uso único
1 2 3 4 5 6 |
|
- Lambda
1 2 3 4 |
|
- Encadeamento
1 2 3 4 5 |
|
- Promessa de computação e resultado.
1 2 |
|
- Quando será executado? Em algum momento.
- Como pegar o resultado?
1 2 3 4 |
|
- Em qual thread? Em algum thread. Depende do Executor Service usado.
Há várias versões do Copycat disponíveis, com vantagens e desvantagens.
Versões
- Baseado em http://atomix.io/copycat/docs/getting-started/ e https://www.baeldung.com/atomix
- Código funcional em https://github.com/pluxos/atomix_labs
- Documentação oficial removida
- Melhor desempenho
- Documentação ruim ou inexistente
- https://github.com/atomix/atomix
- em Go
- evolução rápida
- o código é a documentação
Aqui usaremos a versão 1.1.4, que apesar de antiga, é a melhor documentada atualmente, pelo tutorial referenciado acima.
- Clone e compile o projeto
- Instale dependências:
- git
- maven
- JDK >= 1.8
git clone https://github.com/pluxos/atomix_labs
cd atomix_labs
cd replication
mvn compile
mvn test
Você deve ver uma saída semelhante à seguinte, o que quer dizer que seu código está compilando perfeitamente.
1 2 3 4 5 6 7 8 9 |
|
Antes de começar a escrever suas prórpia máquinas de estado, familiarize-se com a estrutura do projeto em https://github.com/pluxos/atomix_labs/tree/master/replication/src/main/java/atomix_lab/state_machine
Observe que há três pastas:
type
- tipos dos dados mantidos pela replica (Edge e Vertex)
Os tipos são serializable para que o Java saiba como transformá-los em bytes.command
- estruturas que contêm informações para modificar os tipos
Os comandos serão enviadas do cliente para o cluster e são naturalmente serializable.client
- cria comandos e os envia para serem executados no cluster
Respostas podem ser esperadas síncrona ou assincronamente.server
- recebe os comandos na ordem definida pelo Raft e os executa
O projeto foi construído seguindo as instruções no tutorial mencionado antes, saltando-se a parte dos snapshots, isto é:
- crie um projeto maven
eclipse tem template para isso - adicione dependências no
pom.xml
como so criei um projeto, coloquei as dependências tanto do cliente quando do servidor - defina
Command
que modifiquem o estado das réplicas - defina
Queries
que consultem o estado das réplicas - implemente a réplica para lidar com os comandos
- implemente o cliente para emitir comandos
Para executar um servidor, você precisa passar diversos parâmetros
- identificador do processo (inteiro)
- IP do processo com identificador 0
- porta do processo com identificar 0
- IP do processo com identificador 1
- porta do processo com identificar 1
- ...
Sabendo seu identificador, o servidor sabe em qual porta escutar e em quais IP/porta se conectar para se comunicar com os outros servidores.
Para testar o projeto, execute três servidores, em três terminais distintos. Usando o maven, da linha de comando, basta executar os seguintes comandos[^\]:
1 2 3 4 5 6 7 8 9 10 11 |
|
O cliente não precisa de um identificador, apenas dos pares IP/porta dos servidores. Por exemplo, use o comando:
1 2 3 |
|
Exercício
Uma vez executado o projeto, modifique-o para incluir uma nova operação (Command
) e nova consulta (Query
), de sua escolha.
Estudo de caso: Ratis
Ratis é um arcabouço de coordenação recentemente emancipado como um projeto no Apache. Embora mal documentado, o projeto tem alguns exemplos que demonstram como usar abstrações já implementadas. A seguir veremos um passo-a-passo, baseado nestes exemplos, de como usar o Ratis para implementar uma máquina de estados replicada.
Crie um novo projeto Maven com o nome ChaveValor
(eu estou usando IntelliJ, mas as instruções devem ser semelhantes para Eclipse).
Abra o arquivo pom.xml
do seu projeto e adicione o seguinte trecho, com as dependências do projeto, incluindo o próprio Ratis.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
|
Adicione também o plugin Maven e o plugin para gerar um .jar
com todas as dependências. Observe que estou usando Java 14, mas você pode mudar para a sua versão.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
Crie uma nova classe denominada Cliente
no arquivo Cliente.java
.
Nesta classe, iremos criar um objeto RaftClient
que será usado para enviar operações para os servidores.
Esta classe é importada juntamente com outras várias dependências, adicionadas no pom.xml
, que devemos instanciar antes do RaftClient
.
Neste exemplo eu coloco praticamente todos os parâmetros de configuração do Ratis hardcoded para simplificar o código. Obviamente que voce deveria ser estes parâmetros como argumentos para o programa ou de um arquivo de configuração.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
O campo raftGroupId
identifica um cluster Ratis; isso quer dizer que um mesmo processo pode participar de vários clusters, mas aqui nos focaremos em apenas um. O valor do campo deve ter exatamente caracteres, o que soma 32 bytes em java, e será interpretado como um UUID.
id2addr
é um mapa do identificador de cada processo no cluster para seu endereço IP + Porta.
Aqui usei várias portas distintas porquê todos os processos estão rodando na mesma máquina, mas se estivesse executando em máquinas distintas, com IP distintos, poderia usar a mesma porta em todos.
addresses
é uma lista de RaftPeer
construída a parti de id2addr
.
O campo raftGroup
é uma referência a todos os servidores, associados ao identificador do grupo, raftGroupId
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Uma vez criado o grupo, criamos o cliente usando a fábrica retornada por RaftClient.newBuilder()
.
A fábrica deve ser configurada com os dados do grupo e o tipo de transporte, neste caso gRPC.
Também é necessário o identificador do processo que está se conectando ao grupo; neste caso, usamos um identificador aleatório qualquer, diferente do que faremos com os servidores.
1 2 3 4 5 6 7 8 |
|
Uma vez criado o cliente, podemos fazer invocações de operações nos servidores. Cada operação será invocada em todos os servidores, na mesma ordem.
Este protótipo suporta duas operações, add
e get
, incluindo algumas variações, que ignoraremos por enquanto.
A operação add
é codificada como uma String
, add:k:v
, onde k
e v
são do tipo String
. add:k:v
adiciona uma entrada em um mapa implementado pelo nosso servidor com chave k
e valor v
.
Já a operação get:k
recupera o valor v
associado à chave k
, se presente no mapa.
O método RaftClient.io().send
é usado para enviar modificações para as réplicas e deve, necessariamente, passar pelo protocolo Raft.
Já o método RaftClient.io().sendReadOnly
é usado para enviar consultas a qualquer das réplicas.
Ambos os métodos codificam o comando sendo enviado (add:k:v
ou get:k
) no formato interno do Ratis para as réplicas e retorna um objeto RaftClientReply
, que pode ser usado para pegar a resposta da operação.
O código é auto explicativo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
|
Um vez criado o cliente, crie a classe Servidor
, no arquivo Servidor.java
; a parte inicial do código é semelhante à do cliente.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
A primeira diferença vem na necessidade de identificar o servidor dentro do conjunto de servidores, o que é feito com um RaftPeerId
.
Como cada servidor deve usar um identificador único, do conjunto pré-determinado em id2addr
, o identificador é passado como argumento para o programa, obrigatoriamente.
1 2 3 4 5 6 7 8 |
|
Encare a seção seguinte como uma receita, mas observe que o método RaftServerConfigKeys.setStorageDir
recebe o nome de uma pasta como argumento, que será usada para armazenar o estado da máquina de estados.
Se você executar o servidor múltiplas vezes, a cada nova execução o estado anterior do sistema será recuperado desta pasta.
Para limpar o estado, apague as pastas de cada servidor.
1 2 3 4 |
|
A máquina de estados em si é especificada no próximo excerto, em setStateMachine
, que veremos a seguir.
1 2 3 4 5 6 7 8 9 |
|
Uma vez iniciado o servidor, basta esperar que ele termine antes de sair do programa.
1 2 3 4 5 |
|
Vamos agora para a definição da classe MaquinaDeEstados
, no arquivo MaquinaDeEstados.java
.
Esta classe deve implementar a interface org.apache.ratis.statemachine.StateMachine
e seus vários métodos ou, mais simples, estende org.apache.ratis.statemachine.impl.BaseStateMachine
, a abordagem que usaremos aqui.
1 2 |
|
Por enquanto, ignoraremos o armazenamento do estado em disco, mantendo-o simplesmente em memória no campo key2values
, e simplesmente implementaremos o processamento de comandos, começando pela implementação do método query
.
Este método é reponsável por implementar operações que não alteram o estado da máquina de estados, enviadas com o método RaftClient::sendReadOnly
. A única query
no nosso sistema é o get
.
No código, o conteúdo da requisição enviada pelo cliente deve ser recuperado em quebrado em operação (get
) e chave , usando :
como delimitador.
Recuperado o valor associado à chave, o mesmo é colocado em um CompletableFuture
e retornado.
1 2 3 4 5 6 7 8 9 10 |
|
O método applyTransaction
implementa operações que alteram o estado, como add
, enviadas com o método RaftClient::send
.
Da mesma forma que em get
, a operação deve ser recuperada em quebrada em operação (add
), chave e valor, usando :
como delimitador.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Pronto, você já tem uma máquina de estados replicada, bastando agora apenas compilá-la e executá-la.
Para compilar, de raiz do projeto execute o comando mvn package
.
A primeira vez que faz isso pode demorar um pouco pois várias dependências são baixadas da Internet.
Ao final da execução do comando você deveria ver algo semelhante ao seguinte
1 2 3 4 5 6 7 |
|
Então, em três terminais diferentes, execute os seguintes comandos:
1 2 3 |
|
Para executar o cliente, em um outro terminal, faça, por exemplo,
1 2 3 4 |
|
Todo o código está disponível no Github
Exercício
- Adicionar operações
del
clear
Operações assíncronas
TODO
- Operações assíncronas usando
async()
em vez deio()
. CompletableFuture
Leituras "velhas"
TODO
- stale reads usando
sendStaleRead
em vez desendRead
. - índice inicial
- nó
java -cp target/ChaveValor-1.0-SNAPSHOT-jar-with-dependencies.jar Cliente get_stale k1 p1
-
O
\\
no final da linha é só para mostrar que o comando continua na próxima e facilitar a visualização. Na hora de executar, use apenas uma linha, sem o\\
. ↩