Manipulando transações e simultaneidade no MikroORM

Martin Adámek Segue 17 de jun · 7 min ler

Espere o que? MikroORM?

Se você nunca ouviu falar do MikroORM , é um ORM de mapeamento de dados do TypeScript com Unidade de Trabalho e Mapa de Identidade. Suporta drivers MongoDB, MySQL, PostgreSQL e SQLite atualmente.

Você pode ler o artigo introdutório completo aqui ou navegar pelos documentos . O projeto está em desenvolvimento ativo, portanto, verifique também o log de alterações .

O MikroORM é fortemente inspirado pelo Doctrine ORM. Este artigo é altamente inspirado pela documentação da doutrina, já que o comportamento no MikroORM é praticamente o mesmo. Todos os créditos para a explicação geral deste tópico vão para eles!

Nota sobre a persistência

Existem 2 métodos que devemos primeiro descrever para entender como funciona a persistência no MikroORM: em.persist() e em.flush() .

em.persist(entity, flush?: boolean) é usado para marcar novas entidades para persistência futura. Isso fará com que a entidade seja gerenciada pelo EntityManager e, uma vez que o flush seja chamado, ele será gravado no banco de dados. O segundo parâmetro booleano pode ser usado para invocar o flush imediatamente. Seu valor padrão é configurável através da opção autoFlush .

O valor padrão de autoFlush está atualmente definido como true , que será alterado na próxima versão principal. Os usuários são incentivados a definir o autoFlush como false ou usar os em.persistLater() (igual a em. persist(entity, false) em.persistAndFlush() em. persist(entity, false) ) e em.persistAndFlush() . Toda vez que persistir é mencionado neste artigo, é com o autoFlush definido como false em mente.

Para entender o flush , vamos primeiro definir qual entidade gerenciada é: Uma entidade é gerenciada se for buscada no banco de dados (via em.find() , em.findOne() ou via outra entidade gerenciada) ou registrada como nova através do em.persist() .

em.flush() passará por todas as entidades gerenciadas, computará conjuntos de mudanças apropriados e executará de acordo com as consultas do banco de dados. Como uma entidade carregada do banco de dados é gerenciada automaticamente, não é necessário chamar persist neles e o flush é suficiente para atualizá-los.

Demarcação de transação

A demarcação de transação é a tarefa de definir seus limites de transação. Na maior parte, o MikroORM já cuida da demarcação de transação adequada para você: Todas as operações de gravação (INSERT / UPDATE / DELETE) são enfileiradas até que em.flush() seja invocado, o que envolve todas essas mudanças em uma única transação. No entanto, o MikroORM também permite (e encoraja) que você assuma e controle a demarcação de transação por conta própria.

Abordagem 1: Implicitamente

A primeira abordagem é usar o tratamento de transação implícito fornecido pelo EntityManager do MikroORM. Dado o trecho de código a seguir, sem nenhuma demarcação explícita de transação:

Como não fazemos nenhuma demarcação de transação personalizada no código acima, em.flush() começará e confirmar / reverter uma transação. Isso é suficiente se toda a manipulação de dados que faz parte de uma unidade de trabalho acontecer através do modelo de domínio e, portanto, do ORM – em outras palavras, a menos que você execute algumas consultas de gravação manualmente, via QueryBuilder ou use um de em.nativeInsert/Update/Delete ajudantes.

Aqui está um exemplo um pouco mais complexo, onde várias entidades estão envolvidas:

Nós carregamos um autor por id, todos os seus livros e suas tags, bem como o editor deles. Por simplicidade, vamos supor que o autor tenha um livro associado, que tenha uma tag de livro e uma editora.

Em seguida, atualizamos várias coisas no livro desse autor, editando o nome da tag, adicionando uma nova e alterando o nome da editora. Como estamos trabalhando com entidades já gerenciadas (recuperadas do EntityManager ), podemos simplesmente flush sem precisar persist essas entidades.

O flush call aqui calculará todas as diferenças e executará as consultas ao banco de dados de acordo. Eles serão todos encapsulados em uma transação, como você pode ver na seguinte lista de consultas disparadas:

Abordagem 2: explicitamente

A alternativa explícita é usar a API de transações diretamente para controlar os limites. O código fica assim:

A demarcação de transação explícita é necessária quando você deseja incluir operações DBAL personalizadas em uma unidade de trabalho (por exemplo, ao disparar consultas SQL UPDATE nativas) ou quando quiser usar alguns métodos da API EntityManager que exigem uma transação ativa (por exemplo, bloqueio) – tais métodos lançarão um ValidationError para informá-lo sobre esse requisito.

Uma alternativa mais conveniente para a demarcação explícita de transação é usar em.transactional(cb) . Ele iniciará a transação automaticamente, executará seu retorno de chamada assíncrono e o confirmará. No caso de uma exceção durante essas operações, a transação será revertida automaticamente e a exceção será lançada novamente. Um exemplo que é funcionalmente equivalente ao código mostrado anteriormente é o seguinte:

No parâmetro de retorno de chamada, você obterá o EntityManager bifurcado que conterá uma cópia do mapa de identidade atual. Você deve usar essa cópia em vez da pai para todas as consultas dentro da transação. Ele será liberado antes do commit da transação.

Manipulação de exceção

Ao usar a demarcação de transação implícita e ocorre uma exceção durante em.flush() , a transação é automaticamente revertida.

Ao usar a demarcação explícita de transação e ocorrer uma exceção, a transação deve ser revertida imediatamente, conforme demonstrado no exemplo acima. Os usuários são encorajados a usar o em.transactional(cb) que em.transactional(cb) com isso automaticamente.

Como resultado desse procedimento, todas as instâncias anteriormente gerenciadas ou removidas do EntityManager são desanexadas. O estado dos objetos desanexados será o estado no ponto em que a transação foi revertida. O estado dos objetos não é revertido e, portanto, os objetos estão fora de sincronia com o banco de dados. O aplicativo pode continuar usando os objetos desanexados, sabendo que seu estado não é mais preciso.

Se você pretende iniciar outra unidade de trabalho após uma exceção, deverá fazer isso com um novo EntityManager . Basta usar em.fork() para obter uma nova cópia com o mapa de identidade limpo.

Concorrência e bloqueio

Por que precisamos de controle de concorrência?

Se as transações forem executadas em série (uma por vez), não haverá concorrência de transações. No entanto, se transações simultâneas com operações de intercalação forem permitidas, você poderá facilmente encontrar um desses problemas:

  1. O problema da atualização perdida
  2. O problema da leitura suja
  3. O problema de resumo incorreto

Dê uma olhada neste artigo para uma explicação detalhada sobre eles.

Para mitigar esses problemas, o MikroORM oferece suporte para as estratégias de bloqueio Pessimista e Otimista nativamente. Isso permite que você obtenha um controle muito refinado sobre o tipo de bloqueio necessário para suas entidades em seu aplicativo.

Bloqueio Otimista

Transações de banco de dados são boas para controle de simultaneidade durante uma única solicitação. No entanto, uma transação de banco de dados não deve abranger todas as solicitações, o chamado "tempo de pensar do usuário". Portanto, uma "transação de negócios" de longa duração que abrange várias solicitações precisa envolver várias transações de banco de dados. Portanto, as transações do banco de dados sozinhas não podem mais controlar a simultaneidade durante uma transação comercial de longa duração. O controle de concorrência torna-se a responsabilidade parcial do próprio aplicativo.

O MikroORM possui suporte integrado para bloqueio otimista automático por meio de um campo de versão. Nessa abordagem, qualquer entidade que deva ser protegida contra modificações simultâneas durante transações comerciais de longa execução obtém um campo de versão que seja um número simples ou uma Data (registro de data e hora). Quando as alterações em tal entidade são persistidas no final de uma conversação de longa duração, a versão da entidade é comparada à versão no banco de dados e, se não corresponderem, um ValidationError é lançado, indicando que a entidade foi modificada por outra pessoa já.

Para definir um campo de versão, basta usar o @Property decorator com o sinalizador de version configurado como true . Apenas tipos de Date e number são permitidos.

Números de versão (não timestamps) devem ser preferidos, pois eles não podem entrar em conflito em um ambiente altamente concorrente, diferentemente dos timestamps em que isso é uma possibilidade, dependendo da resolução do timestamp na plataforma de banco de dados específica.

Quando um conflito de versão é encontrado durante em.flush() , um ValidationError é lançado e a transação ativa é revertida (ou marcada para reversão). Essa exceção pode ser capturada e manipulada. As respostas potenciais a um ValidationError são para apresentar o conflito ao usuário ou para atualizar ou recarregar objetos em uma nova transação e, em seguida, tentar novamente a transação.

O tempo entre mostrar um formulário de atualização e realmente modificar a entidade pode, no pior cenário, ser o tempo limite da sessão do aplicativo. Se ocorrerem alterações na entidade nesse intervalo de tempo, você desejará saber diretamente ao recuperar a entidade que você atingirá uma exceção de bloqueio otimista.

Você sempre pode verificar a versão de uma entidade durante uma solicitação ao chamar em.findOne() :

Ou você pode usar em.lock() para descobrir:

Usando o bloqueio otimista corretamente, você precisa passar a versão como um parâmetro adicional ao atualizar a entidade. Veja o exemplo a seguir:

Seu aplicativo de front-end carrega uma entidade da API, a resposta inclui a propriedade de versão. O usuário faz algumas alterações e aciona a solicitação PUT de volta para a API, com o campo de versão incluído na carga útil. O manipulador PUT da API lê a versão e a transmite para a chamada em.findOne() .

Bloqueio Pessimista

O MikroORM suporta o Bloqueio Pessimista no nível do banco de dados. Cada Entidade pode fazer parte de um bloqueio pessimista, não há metadados especiais necessários para usar esse recurso. Bloqueio pessimista requer transação ativa, portanto, você terá que usar a demarcação de transação explícita.

O MikroORM atualmente suporta dois modos de bloqueio pessimistas:

  • Pessimistic Write ( LockMode.PESSIMISTIC_WRITE ), bloqueia as linhas de banco de dados subjacentes para operações simultâneas de leitura e gravação.
  • Leitura pessimista ( LockMode.PESSIMISTIC_READ ), bloqueia outras solicitações simultâneas que tentam atualizar ou bloquear linhas no modo de gravação.

Você pode usar bloqueios pessimistas em três cenários diferentes:

  1. Usando em.findOne(className, id, { lockMode })
  2. Usando em.lock(entity, lockMode)
  3. Usando QueryBuilder.setLockMode(lockMode)

É assim que parece em ação: