Taverna /dev/All

Aplicativo offline-first: qual arquitetura é melhor?

Meu objetivo com este post é ouvir o feedback de outros arquitetos a respeito do tema: Sistemas Empresariais (ERP) que permitem mobilidade offline para realizar vendas e cadastros no campo, geralmente em locais onde não há sinal de internet. Já tive a oportunidade de trabalhar em um sistema desse porte no setor de Agronegócios, era necessário sincronizar um grande volume de dados offline, uma tarefa realmente desafiadora e talvez até incomum para a maioria dos sistemas. Caso nunca tenha ouvido falar desse tipo de solução, dá uma olhada neste vídeo.

Pretendo comentar sobre a arquitetura generalista, que está em alta no mercado, e depois irei comentar sobre uma arquitetura especialista, mostrando as vantagens e desvantagens de cada uma neste cenário.

Requisitos

  • O aplicativo deve sincronizar informações com o servidor para que as mesmas regras e fluxos de negócio que são executados no servidor também seja executado no aplicativo offline, dessa forma o representante da empresa pode receber os dados de clientes no início do expediente e ir a campo para efetuar operações in-off como: cadastro de clientes, cadastro de pedido de venda, agendamento de visitas em propriedades, verificação do status financeiro do cliente, relatórios de contas à receber/pagar, dentre muitas outras rotinas de negócio. No final do expediente, assim que o representante chegar na empresa ou em casa, ele terá acesso a internet, então deverá sincronizar esses cadastros com o ERP.

  • Para receber os dados atualizados do servidor, deve ser desenvolvido uma tela de sincronização que solicita a geração de um banco de dados SQLite com todas as informações vindas do SGBD relacional.

  • Para enviar os dados para o servidor é mais simples, apenas uma requisição que enviará os dados cadastrados para o ERP. O banco SQLite que contêm os dados gerenciais para uso offline sempre será gerado no servidor, enquanto que o outro banco de dados local será utilizado para persistir os dados cadastrados.

  • Quanto a plataforma Mobile, muito provavelmente o Android será mais utilizado, principalmente se o smartphone do representante é da empresa, dificilmente seria um iPhone por exemplo, a não ser que o representante utilizar o seu iOS para trabalho.

Estrutura

  • Backend: Aplicação REST desenvolvida em Java, pode ser qualquer plataforma ou framework desse ecossistema (Jakarta EE, Spring, Quarkus, Micronaut).

  • Frontend: Solução client-side (frameworks JavaScript) ou server-side (JSP/JSF).

  • Banco de dados: Um banco de dados relacional como Oracle, Postgres, MySQL etc.

  • Banco de dados offline: Acredito que o SQLite é a solução com mais maturidade para lidar com dados offline, pois é um banco relacional arquitetado para rodar localmente.

Arquitetura Generalista

  • Aplicativo offline: Solução híbrida como Flutter, React Native, dentre outras.

Arquitetura Especialista

  • Aplicativo offline: Solução nativa para ambas plataformas, Android (Java/Kotlin) e iOS (Swift).

Tradeoffs: Aplicativo híbrido

  • Provavelmente a decisão mais comum seria desenvolver um app híbrido para ter a grande vantagem do suporte para Android e iOS mantendo apenas uma base de código. Esta abordagem geralmente é adotada em um MVP, mas apresenta vários problemas a nível de manutenção e performance.

  • O primeiro problema seria lidar com uma linguagem que não é interoperável com o backend, no caso do Flutter seria o Dart, isso significa que boa parte do código de negócio que é executado no ERP online em Java também teria que ser executado no aplicativo, causando duplicidade na camada de negócio, pois não seria possível realizar uma requisição para o backend lidar com o fluxo de negócio, o requisito é funcionar boa parte do tempo in-off.

  • Outro problema é que os testes unitários seriam duplicados também, tanto em Java quanto Flutter, dessa forma o tempo de desenvolvimento pode aumentar e para entregar as features mais rapidamente, o time poderia acabar deixando de lado o teste automatizado em uma das duas pontas.

  • Como se trata de um app offline, a manutenção de código de negócio duplicado pode realmente dificultar as coisas.

Tradeoffs: Aplicativo nativo

  • A decisão mais segura no longo prazo é desenvolver o mesmo aplicativo em ambas plataformas, além disso possibilita a contratação de desenvolvedores especializados caso seja necessário lidar com recursos mais avançados da plataforma, além de ter mais performance se necessário.

  • Antes de comentar sobre os tradeoffs, vamos analisar o cenário antigo e atual de desenvolvimento mobile nativo: Antigamente a duplicidade do código de negócio era inevitável, fazendo com que esse tipo de solução ficasse limitada ao Android pois seria mais caro desenvolver para iOS, já os apps online optavam por desenvolver nas 2 plataformas, duplicando assim o código que realizava a comunicação com o backend. Uma forma de tentar resolver este problema de duplicidade era usando a JNI como uma camada entre as duas plataformas, porém não era trivial pois o código de negócio seria escrito em C/C++ e ficaria preso nesse contexto. Para tornar o desenvolvimento mobile mais acessível foi criado o Flutter na tentativa de resolver definitivamente esse e outros problemas.

  • No entanto, muitas empresas não tiveram escolhas e continuaram desenvolvendo nativo pois nem sempre é possível utilizar uma solução híbrida, podem existir limitações nessas soluções. Nesse contexto, a Jetbrains já tinha criado a linguagem Kotlin, uma linguagem interoperável com Java e que fornece uma experiência de desenvolvimento melhor para o Android, inclusive a Google teve que estabelecer uma posição quanto a arquitetura dos apps, recomendando o MVVM, pois boa parte do mercado usava outras arquiteturas como MVP ou não seguiam nenhum padrão ainda, dessa forma o Kotlin se tornou a linguagem oficial nesse contexto. Por último, a Jetbrains criou o KMM, uma solução multiplataforma que promete resolver os problemas de duplicidade do código de negócio entre as plataformas nativas: “Write the business logic for your iOS and Android apps just once, in pure Kotlin”.

  • Logo, uma solução para o problema de duplicidade do ERP móvel pode ser o módulo Kotlin compartilhado entre os projetos Java, ajudando não só o desenvolvimento mobile mas também a nível de back-end, já que é possível compartilhar o código comum entre os projetos, porém não significa que é uma tarefa fácil, ainda mais lidando com código já existente, mas é uma ótima opção tanto para desenvolvimento exclusivo Android e Web quanto para desenvolvimento iOS nativo, pois o código KMM compartilhado, é compilado para bytecode (JVM) para Android e também é compliado em binários nativos para iOS. Isso torna a integração com o KMM perfeita em ambas as plataformas, desde que o seu código seja compatível.

  • Então, a grande vantagem seria esta camada de negócio escrita em Kotlin com testes unitários, ela atuaria como uma ponte entre o Android, iOS e até mesmo no backend (tratando-se apenas de código unitário). Não seria mais necessário duplicar este código e nem os testes.

  • Existem mais detalhes técnicos como as consultas SQL que serão compartilhadas entre as plataformas, para isso existe uma lib compatível com o KMM, o SQL Delight que internamente utiliza o driver do JDBC do SQLite através de chamadas JNI. Para fazer requisições HTTP por exemplo, tem o Ktor Client, sendo mais útil ainda para apps online mas mesmo um app offline terá que fazer as chamadas para sincronizar com o servidor.

  • No entanto, a desvantagem dessa abordagem é que o KMM não é híbrido, sendo necessário desenvolver as Views nativamente para ambas plataformas.

Tudo vai depender do contexto, nesse caso específico de aplicativo offline, acredito que seria muito interessante construir a arquitetura considerando o módulo Kotlin compartilhado e usando o KMM se for necessário desenvolver o app para iOS. No caso do KMM, não vejo a duplicidade das telas como um problema visto que a View e outros detalhes de UX são menos críticos do que o código de negócio que será alterado com muito mais frequência nesse tipo de sistema.

O que acham dessa ideia?

2 Curtidas

Já atuei em alguns projetos assim: a maior dificuldade na minha opinião não são as tecnologias, mas qual protocolo você vai seguir para realizar a sincronização dos dados.

Por protocolo aqui tô usando o termo no literal e não técnico, isto é, quais as convenções a serem adotadas na plataforma pra responder estas perguntas:

  • o que é considerado informação desatualizada?
  • ao enviar dados ao servidor central, quais metadados são fundamentais pra decidir o que é informação atualizada ou não?
  • como lidar com conflitos de dados? Se dois nos tentam atualizar o mesmo registro, qual a atualização válida?
  • como os nos sabem se o que mandam pro central foi aceito?
  • por quanto tempo a informação no nó pode ser considerada válida?
  • a informação em que momento tem maior valor no nó ou no central?

Acho que aqui se aprende mais com o que se fazia nos anos 90 e início dos 2000 (talvez até antes) pra se resolver estas questões pois infelizmente hoje muita gente já pensa no sistema como algo que está sempre on-line, o que limita muito as opções de solução.

2 Curtidas

Acho que vou até um pouco além viu?

O sistema offline-first que você tá com problema aí é na realidade um problema básico de sistemas distribuídos.

1 Curtida

Pensei nisso. O problema não é a aplicação móvel ou o nó central, mas como você vai autenticar e validar se as informações que serão sincronizadas realmente pertencem a um client confiável. Após autenticar só precisa fazer uma carga em lote. As informações no client podem até mesmo ser um xml, e não precisaria nem mesmo de um banco relacional como sqlite. Vou mais além. Precisa somente ser um serviço agregador, semelhante a um sistema de messageria, e os dados coletados poderiam ser agregados por um job noturno. Nem precisaria ser realtime.

Sobre desafio do sincronismo dos dados, que em primeira análise parece simples, mas que na prática apresenta algumas armadilhas, o Elemar Júnior escreveu um capítulo com umas técnicas interessantíssimas a este respeito, em seu livro Manual do Arquiteto de Software.

Superando o desafio de sincronizar dados com Change Vectors

Sobre os desafios da arquitetura da solução. Não conheço o histórico do time e sua experiência,
mas devido a necessidade de centralização de regras de negócios para ambos os sistemas, eu olharia para algumas tecnologias como Ionic Framework para os frontends (App Offline e Frontend do ERP Web, ERP Desktop, etc) e Node.js com o Express, para o backend. Esta conjunto, lhe permitirá reutilizar muito código como ViewModels e a camada do domínio com regras de negócio. Além de permitir utilizar os mesmos componentes de frontend em várias aplicações.

Eu particularmente, não gosto muito do Node.js, parece que potencializa a criação de código desorganizado, mas é claro que isso será definido pelo nível de maturidade da equipe. Também nunca usei profissionalmente, pode ser só preconceito meu.

Já o Ionic Framework eu usei bastante, até subi um app na Google Play e o acho muito poderoso. Para operações de manipulação de dados (CRUD com SQLite) e acesso ao hardware nativo (GPS, câmera, acelerômetro, etc) ele é sensacional. Sem falar que usa tecnologia WEB, onde a maioria dos problemas já estão resolvidos.

1 Curtida

É isso mesmo, o envio de dados é uma tarefa crítica, mas a dificuldade maior encontra-se no recebimento de dados. Executar o envio dos dados do servidor para o aplicativo de maneira performática e mantendo totalmente a integridade desses registros talvez seja muito mais desafiador. No sistema que eu trabalhei, haviam cerca de 200 etapas no backend Java para enviar os registros para uso off-line, o aplicativo era desenvolvido nativamente no Android, cada etapa dessas consultava todos os registros do banco de dados, em algumas etapas já chegou a passar de milhões de registros, depois realizava a inserção desses dados em um banco SQLite gerado e armazenado no servidor, e somente após as 200 etapas serem executadas criando uma tabela para cada etapa, o arquivo do banco de dados (.db/.sqlite3) era compactado e enviado pela rede, até ser descompactado e o aplicativo ser reiniciado. Existiam diversos problemas de performance e consumo de recursos que fui resolvendo com o tempo, mas o processo da carga continuou o mesmo, sempre realizando a carga de dados completa quando o usuário solicitava, deveria ser refatorado para executar uma carga de dados iniciais para que posteriormente fossem executadas cargas parciais, mais leves. Só que esse sistema “legado” era gigantesco e a empresa não tinha mais o interesse em refatorar esse processo como um todo, preferiram iniciar um novo projeto por outros motivos.

Pensando em um cenário complexo como esse, onde é necessário manter uma grande quantidade de registros off-line em um banco SQLite apenas para leitura e outro banco para leitura e escrita, estou tentando encontrar uma arquitetura eficiente que permita economizar recursos (CPU, memória, rede) e com uma boa performance. Gostaria de preparar uma prova de conceito desse processo e compartilhar com a comunidade Java/Kotlin, além disso sei que existem muitos projetos reais que sofrem com esse problema de sincronização, geralmente lidando com a sincronização de um grande volume de dados.

Para implementar o recebimento dos dados, pensei no seguinte:

  • O aplicativo cria o banco de dados de leitura em uma pasta interna reservada pelo sistema para o aplicativo e estabelece uma conexão JDBC com o driver nativo do SQLite.

  • Roda os scripts de migração para preparar a estrutura inicial do banco de dados.

  • Envia uma requisição para o servidor para iniciar o processo de carga inicial.

  • Chegando no servidor, será iterado cada implementação de etapa, buscando os registros do SGBD correspondentes a esta etapa através uma consulta SQL (otimizada), em seguida deve montar um JSON a partir do ResultSet (ou através da serialização de objetos, apesar de ser menos eficiente) e devolver a resposta via HTTP para que o aplicativo prossiga com a inserção desses dados no SQLite.

    • Existem muitos cuidados que eu me importaria nessa parte, como por exemplo a questão do consumo de recursos, a 1ª coisa que pensaríamos é usar a paginação a nível de SQL, efetuando uma query paginada a cada N registros, porém estimar quantos registros devem ser retornados por roundtrip é complicado, e fazer diversas roundtrips por etapa afeta a performance pois o banco de dados irá preparar o plano de execução N vezes e a aplicação irá devolver uma resposta HTTP com os registros a cada roundtrip, assim o nº de requisições HTTP ficariam iguais ao nº de consultas SQL.

    • Para aumentar a eficiência nesse processo, pensei na seguinte solução:
      HTTP Streaming / SSE (Server-Sent-Events) + JDBC Fetch Size:

      • Ao em vez do cliente solicitar o recurso disparando várias requisições e consultas paginadas, o servidor irá preparar o recurso devolvendo uma resposta contínua para o cliente, sem encerrar a conexão HTTP estabelecida, assim o cliente continua processando os registros e inserindo no SQLite a cada lote de registros recebido do servidor.
      • Para eliminar a paginação, pode ser usado um recurso normalmente usado para processamento em lote, o Fetch Size, por padrão o driver aloca um buffer na memória liberando o consumo dos registros retornados pelo cursor somente após chegar na última linha, isso é feito em uma única roundtrip, para a maioria dos SGBD’s funciona dessa forma com exceção do Oracle que define 10 registros por roundtrip. A ideia é aumentar esse valor do fetch-size para cerca de 50 registros e ir transmitindo esses dados para o client até chegar no último registro, desse modo se a consulta tiver 100.000 registros para retornar, serão 2.000 roundtrips, parece muito mas é uma roundtrip muito mais leve e rápida pois o banco já montou o plano de execução da consulta e abriu um cursor para retornar as linhas.
      • Costumo definir o fetch-size em uma java.sql.Connection e em modo transacional (auto-commit = false). Caso esteja usando o Hibernate, é possível ter acesso ao JDBC atráves da org.hibernate.Session ou definir a nível da aplicação, tem um post do Vlad explicando como definir essa propriedade: ResultSet statement fetching with JDBC and Hibernate - Vlad Mihalcea.
      • Também temos que monitorar a aplicação com o JVisualVM para garantir que realmente não temos um problema de memória ou consumo de CPU elevado causado por excesso de atividade do Garbage Collector, pois se a aplicação retornasse um ResultSet muito grande sem usar o fetch-size ou alguma outra forma de paginação, o GC pode disparar um Full GC para liberar espaços de memória, “congelando” as threads que estão em andamento, esse problema é conhecido como “Stop the world”. Pela minha experiência com o Fetch Size, não é vantajoso definir um valor maior que 100, evitando assim possíveis problemas de memória.
    • O próximo passo é otimizar a inserção desses dados no aplicativo, isso pode ser via JDBC ou usando alguma lib: basta chamar a instrução que “empilha na memória” o comando de inserção a cada registro iterado, e, assim que o limite de registros definidos no lote for atingido, deve ser chamado uma instrução para executar o lote de comandos na transação e outra instrução para limpar os comandos da memória que já foram executados. Para inserir ou alterar um registro que já foi sincronizado, pode ser usado a instrução INSERT OR REPLACE.

    • Como último passo da etapa, deve ser realizado o commit da transação. Com relação ao tamanho do banco de dados, é muito importante dar uma olhada nas otimizações que o SQLite recomenda: Clustered Indexes and the WITHOUT ROWID Optimization, VACUUM

  • Após a primeira carga de dados iniciais ser completada, começa a ficar mais interessante, pois as próximas cargas não precisarão consultar novamente e processar todos os registros que já estão sincronizados, porém para fazer isso de forma segura, mantendo a integridade dos dados, não é nada simples e exige uma série de cuidados, principalmente a nível de SQL, para isso é necessário:

    • Criar um campo de versionamento sequencial ou um campo com o Unix Timestamp para cada registro da tabela.
    • Criar triggers nas tabelas para atualizar a versão do registro sempre que um dos campos dependentes tiver sido inserido ou alterado.
    • Para controlar a exclusão, garantindo que o registro excluído no servidor também será excluído no aplicativo off-line, devemos criar um campo único como uma UUID para cada registro, uma tabela para armazenar essas UUID que foram deletadas na tabela principal e também uma etapa que sincronize essas UUID para realizar a exclusão off-line usando uma instrução de DELETE a partir da cláusula EXISTS.
    • Para consultas simples, sem cláusulas de filtragem e junções específicas, provavelmente é o suficiente. Porém como as regras de negócio nem sempre são simples, pode existir consultas mais complexas envolvendo várias junções, filtros dinâmicos e até mesmo chamada de procedures. Nesse caso é necessário criar triggers em todas as tabelas dependentes da consulta que atualizam o campo da versão do registro. No caso de filtros dinâmicos, é necessário manter uma tabela que armazena todos os eventos de alteração do filtro, essa tabela será usada para remover os registros off-line que foram sincronizados com o filtro antigo, nunca imaginei que poderia chegar nesse nível mas é importante pensar nisso se quiser manter a integridade desses dados que estão off-line e que podem ficar armazenadas por muito tempo até o usuário atualizar ou reinstalar o aplicativo.
  • Considerando tudo isso, imagino que o processo ficaria muito mais performático, pois o representante da empresa pode receber os dados algumas vezes por dia, então ele receberá somente os registros que foram cadastrados ou alterados no dia anterior ou em um curto período de tempo.

  • Tem outros detalhes a nível de manutenção que devemos pensar também, pois caso uma etapa ou consulta SQL seja muito alterada a ponto de interferir na filtragem dos registros, deve ser implementado para que essa etapa seja completamente regenerada na próxima carga. De qualquer forma, todos as informações de uma carga serão persistidas no SGBD, incluindo quais etapas foram executadas, então é possível ter controle sobre isso também.

Entendo que lidar com milhões de registros off-line não é algo comum pois com uma boa análise de requisitos é possível reduzir consideravelmente a quantidade de registros através da implementação de filtros, enviando somente os dados de clientes que o representante da empresa atende, através de uma carteira de clientes por representante, talvez até por região, por exemplo. Só que as vezes, os stakeholders do projeto acabam solicitando demandas que dependem da criação de novas etapas, e com o tempo, um grande volume de dados está sendo sincronizado, então cedo ou tarde a performance desse processo como um todo se tornará um grande requisito e o time de desenvolvimento terá que trabalhar de forma intensa para encontrar uma solução definitiva.

Ainda tenho algumas dúvidas quanto a esse processo de recebimento dos dados (carga):

  • Tem alguma forma de realizar a compressão do JSON usando G-ZIP ou Protocol Buffers para economizando o tráfego na rede?
  • Caso o celular do usuário não tenha um hardware legal ou possua limitações em seu plano de internet móvel, a inserção de registros pode ficar muito lenta mesmo com todas as otimizações? Se sim, vale a pena migrar esse processamento para o servidor de modo que o usuário realize o download desse banco de dados compactado?
  • É melhor usar uma solução No-SQL offline como o Realm no lugar de uma solução relacional simples como o SQLite?

Você pode armazenar o xml em um simples arquivo sequencial e posteriormente fazer um upload desses dados. Usar um banco de dados relacional para uma tarefa dessas me parece como matar um mosquito com um rifle. A única coisa que vejo de complexo em uma solução dessas é o que o kiko falou sobre protocolo de comunicação e um sistema de autenticação mais robusto. Usuário e senha não seria nem um pouco legal para autenticar diversos clients. Talvez um sistema de autenticação com chave pública seja legal.

1 Curtida

Entendo, mas a realidade de um projeto ERP não é simples e o volume de dados do servidor que serão transferidos para o aplicativo off-line não é pequeno, pode até ser em um primeiro momento mas no médio prazo pode crescer consideravelmente, imagina que um stakeholder do projeto decide que os usuários com permissão de gerente poderão consultar relatórios financeiros off-line.

Por estes e outros motivos sugeri o SQLite, a própria documentação do Android recomenda a utilização do Room como ORM para lidar com SQLite. No geral, serão realizados menos escritas e mais leituras nessa solução, então poderia ser utilizado alguma outra solução para realizar as escritas mas se para leitura será usado o SQLite, não vejo porque não adotá-lo para escrita também. Basta ter cuidados essenciais para não realizar operações de I/O na UIThread, e consequentemente você terá performance, o Kotlin tem recursos muito eficientes para lidar com Threads: Corrotinas do Kotlin no Android  |  Desenvolvedores Android  |  Android Developers. Se for necessário desenvolver multiplataforma com o KMM, é possível compartilhar a camada de repositório entre Android/iOS de forma fácil: https://cashapp.github.io/sqldelight/.

Além disso, é muito simples diagnosticar problemas de tabelas que estão pesando devido a um índice ou quantidade de registros, basta usar essa ferramenta: The sqlite3_analyzer.exe Utility Program. Outra ferramenta útil para identificar se uma alteração realizada em alguma etapa sincronização pode ter causado alguma divergência nos dados que serão sincronizados, é o sqlite diff: sqldiff.exe: Database Difference Utility.

Talvez seria bom alterar o título desse tópico já que o problema que eu coloquei para ser discutido é um aplicativo off-line com muitos fluxos de negócio e não um app offline-first, @kicolobo .

Com relação a autenticação, o que vocês recomendariam?

itexto