Problemas do processamento em lote de solicitações e suas soluções (parte 2)


Esta é uma continuação do artigo “Problemas do processamento em lote de solicitações e suas soluções . É recomendável que você se familiarize primeiro com a primeira parte, pois ela descreve em detalhes a essência do problema e algumas abordagens para sua solução. Aqui nós olhamos para outros métodos.


Breve repetição da tarefa


Há um bate-papo para coordenar o documento com um conjunto predefinido de participantes. As mensagens contêm texto e arquivos. E, como nos chats regulares, as mensagens podem ser respondidas e encaminhadas.

Modelo de mensagem de bate-papo
data class ChatMessage (
// nullable persist
val id : Long ? = null ,
/** */
val author : UserReference ,
/** */
val message : String ,
/** */
// - JPA+ null,
val files : List < FileReference > ? = null ,
/** , */
val replyTo : ChatMessage ? = null ,
/** , */
val forwardFrom : ChatMessage ? = null
)

Através da injeção de dependência, podemos implementar os seguintes serviços externos:
interface ChatMessageRepository {
fun findLast ( n : Int ) : List < ChatMessage >
}

data class FileHeadRemote (
val id : FileReference ,
val name : String
)

interface FileRemoteApi {
fun getHeadById ( id : FileReference ) : FileHeadRemote
   fun getHeadsByIds ( id : Set < FileReference > ) : Set < FileHeadRemote >
fun getHeadsByIds ( id : List < FileReference > ) : List < FileHeadRemote >
}

data class UserRemote (
val id : UserReference ,
val name : String
)

interface UserRemoteApi {
fun getUserById ( id : UserReference ) : UserRemote
   fun getUsersByIds ( id : Set < UserReference > ) : Set < UserRemote >
fun getUsersByIds ( id : List < UserReference > ) : List < UserRemote >
}

Precisamos implementar um controlador REST:

interface ChatRestApi {
fun getLast ( n : Int ) : List < ChatMessageUI >
}


Onde:
/** */
data class ReferenceUI (
/** url */
val ref : String ,
/** */
val name : String
)

data class ChatMessageUI (
val id : Long ,
/** */
val author : ReferenceUI ,
/** */
val message : String ,
/** */
val files : List < ReferenceUI >,
/** , */
val replyTo : ChatMessageUI ? = null ,
/** , */
val forwardFrom : ChatMessageUI ? = null
)

Na parte anterior, vimos a implementação ingênua de um serviço usando o processamento em lote e várias maneiras de acelerá-lo. Esses métodos são muito simples, mas seu aplicativo não fornece desempenho suficientemente bom.

Aumentar pacotes


O principal problema das soluções ingênuas era o tamanho pequeno dos pacotes.

Para agrupar chamadas em pacotes maiores, é necessário acumular solicitações de alguma forma. Esta linha não implica o acúmulo de solicitações:

author = userRepository . getUserById ( author ) . toFrontReference () ,

Agora, em nosso tempo de execução, não há um lugar especial para armazenar a lista de usuários - ela está sendo formada gradualmente. Isso terá que mudar.

Primeiro, você precisa separar a lógica da aquisição de dados do mapeamento no método ChatMessage.toFrontModel:

private fun ChatMessage . toFrontModel (
getUser : ( UserReference ) -> UserRemote ,
getFile : ( FileReference ) -> FileHeadRemote ,
serializeMessage : ( ChatMessage ) -> ChatMessageUI
) : ChatMessageUI =
ChatMessageUI (
id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
author = getUser ( author ) . toFrontReference () ,
message = message ,
files = files ?. let {
       it . map ( getFile ) . map { it . toFrontReference () }
} ?: listOf () ,
forwardFrom = forwardFrom ?. let ( serializeMessage ) ,
replyTo = replyTo ?. let ( serializeMessage )
)


Acontece que essa função depende apenas de três funções externas (e não de classes inteiras, como era no início).

Após esse retrabalho, o corpo da função não ficou menos claro e o contrato ficou mais difícil (isso tem prós e contras).

De fato, você não pode fazer esse estreitamento do contrato e deixar dependências nas interfaces. O principal é que definitivamente não há nada supérfluo neles, pois precisaremos fazer implementações alternativas.

Como a função serializeMessage é semelhante a uma função recursiva, isso pode ser feito como uma recursão explícita na primeira etapa da refatoração:

class ChatRestController (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : ChatRestApi {
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. map { it . toFrontModel () }


Fiz um stub para o método toFrontModel, que até agora funciona exatamente da mesma forma que em nossa primeira implementação ingênua (a implementação de todas as três funções externas permanece a mesma).

private fun ChatMessage . toFrontModel () : ChatMessageUI =
toFrontModel (
getUser = userRepository ::getUserById ,
getFile = fileRepository ::getHeadById ,
serializeMessage = { it . toFrontModel () }
   )


Mas precisamos fazer com que as funções getUser, getFile e serializeMessage funcionem com eficiência, ou seja, envie solicitações para os serviços apropriados em pacotes do tamanho certo (teoricamente, esse tamanho pode ser diferente para cada serviço) ou geralmente uma solicitação por serviço, se solicitações ilimitadas forem permitidas.

A maneira mais fácil de obter esse agrupamento é se tivermos todas as consultas necessárias em mãos antes de iniciar o processamento. Para fazer isso, antes de chamar oFrontModel, colete todos os links necessários, faça o processamento em lote e use o resultado.

Você também pode tentar o esquema com o acúmulo de solicitações e sua execução gradual. No entanto, esses esquemas exigirão execução assíncrona, mas, por enquanto, focaremos nos síncronos.

Portanto, para começar a usar o processamento em lote, de uma forma ou de outra, teremos que descobrir com antecedência o maior número possível de solicitações (de preferência todas) que precisaremos fazer. Se estamos falando de um controlador REST, seria bom combinar solicitações para cada serviço ao longo da sessão.

Agrupar todas as chamadas
Em algumas situações, todos os dados necessários dentro da sessão podem ser obtidos imediatamente e não causarão problemas com os recursos, desde o iniciador da solicitação ou do executor. Nesse caso, não podemos limitar o tamanho do pacote para chamar o serviço e receber imediatamente todos os dados de uma só vez.

Outra suposição que facilita muito a vida é supor que o iniciador tenha recursos suficientes para processar todos os dados. Solicitações para serviços externos também podem ser enviadas em pacotes limitados, se necessário.

A simplificação da lógica, neste caso, diz respeito a como os locais onde os dados são necessários serão comparados com os resultados das chamadas. Se considerarmos que os recursos do iniciador são muito limitados e, ao mesmo tempo, tentar minimizar o número de chamadas externas, teremos uma tarefa bastante difícil para o corte ideal do gráfico. Provavelmente, você só precisa sacrificar o desempenho para reduzir o consumo de recursos.

Consideraremos que, especificamente em nosso projeto de demonstração, o iniciador não é particularmente limitado em recursos, ele pode receber todos os dados necessários e armazená-los até o final da sessão. Se houver problemas com os recursos, faremos apenas uma paginação menor.

Como, na minha prática, apenas essa abordagem é mais procurada, outros exemplos envolverão essa opção.

Podemos distinguir esses métodos de obter grandes conjuntos de consultas:

  • engenharia reversa;
  • heurística de negócios;
  • agregados no estilo de DDD;
  • proxying e chamada dupla.

Vamos analisar todas as opções no exemplo do nosso projeto.

Engenharia reversa


Coletar todos os pedidos

Como temos um código para implementar todas as funções envolvidas na coleta de informações e na conversão para o front-end, podemos fazer engenharia reversa e, a partir desse código, podemos entender quais serão as solicitações:

class ChatRestController (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : ChatRestApi {
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
         // , forward reply
val allMessages = messages . asSequence () . flatMap {
           sequenceOf ( it , it . forwardFrom , it . replyTo ) . filterNotNull ()
} . toSet ()
val allUserReq = allMessages . map { it . author }
         val allFileReq = allMessages . flatMap { it . files ?: listOf () } . toSet ()


Todas as solicitações são coletadas, agora você precisa fazer o processamento em lote real.

Para allUserReq e allFileReq, fazemos consultas externas e as agrupamos por ID. Se não houver restrições no tamanho do pacote, será algo parecido com isto:

userRepository . getUsersByIds ( allMessages . map { it . author } . toSet ())
. associateBy { it . id } ::get
fileRepository . getHeadsByIds ( allMessages . flatMap { it . files ?: listOf () } . toSet ())
. associateBy { it . id } ::get


Se houver uma restrição, o código assumirá o seguinte formato:

val userApiChunkLimit = 100
allMessages . map { it . author } . asSequence () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id } ::get


Infelizmente, ao contrário do Stream, o Sequence não pode alternar facilmente para uma solicitação de pacote paralelo.

Se você considerar uma consulta paralela válida e necessária, poderá fazer, por exemplo, o seguinte:

allMessages . map { it . author } . parallelStream () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id } ::get


Pode-se ver que nada mudou muito. O uso de uma certa quantidade de magia Kotlin nos ajudou com isso:

fun < T , R > Stream < out T >. chunked ( size : Int , transform : ( List < T > ) -> R ) : Stream < out R > =
batches ( this , size ) . map ( transform )

fun < T > Stream < out Collection < T >>. flatten () : Stream < T > =
flatMap { it . stream () }

fun < T , K > Stream < T >. associateBy ( keySelector : ( T ) -> K ) : Map < K , T > =
collect ( Collectors . toMap ( keySelector , { it } ))


Agora resta juntar tudo:
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
       // , forward reply
val allMessages = messages . asSequence () . flatMap { message ->
         sequenceOf ( message , message . forwardFrom , message . replyTo )
. filterNotNull ()
} . toSet ()

messages . map ( ValueHolder < ( ChatMessage ) -> ChatMessageUI > () . apply {
         value = memoize { message : ChatMessage ->
           message . toFrontModel (
// ,
getUser = allMessages . map { it . author } . parallelStream () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id } ::get . orThrow { IllegalArgumentException ( "User $ it" ) } ,
//
getFile = fileRepository . getHeadsByIds ( allMessages . flatMap { it . files ?: listOf () } . toSet ())
. associateBy { it . id } ::get . orThrow { IllegalArgumentException ( "File $ it" ) } ,
//
serializeMessage = value
)
}
} . value )
}

Explicações e simplificações
A primeira coisa que certamente chamará sua atenção é a função de memorização. O fato é que a função serializeMessage quase certamente será chamada várias vezes para as mesmas mensagens (devido à resposta e encaminhamento). Não está claro por que devemos fazer o toFrontModel separadamente para cada uma dessas mensagens (em alguns casos, isso pode ser necessário, mas não o nosso). Portanto, você pode executar memorização para a função serializeMessage. Isso é implementado, por exemplo, da seguinte maneira:

fun < A , R > memoize ( func : ( A ) -> R ) = func as? Memoize2 ?: Memoize2 ( func )
class Memoize2 < A , R > ( val func : ( A ) -> R ) : ( A ) -> R , java . util . function . Function < A , R > {
private val cache = hashMapOf < A , R > ()
override fun invoke ( p1 : A ) = cache . getOrPut ( p1 , { func ( p1 ) } )
override fun apply ( t : A ) : R = invoke ( t )
}


Em seguida, precisamos construir uma função memorizada serializeMessage, mas ao mesmo tempo será usada dentro dela. É importante usar exatamente a mesma instância da função dentro, caso contrário, toda a memorização irá pelo ralo. Para resolver essa colisão, usamos a classe ValueHolder, que simplesmente armazena uma referência ao valor (você pode usar algo padrão em vez disso, por exemplo, AtomicReference). Para encurtar o registro de recursão, você pode fazer o seguinte:

inline fun < A , R > recursiveMemoize ( crossinline func : ( A , ( A ) -> R ) -> R ) : ( A ) -> R =
ValueHolder < ( A ) -> R > () . apply {
     value = memoize { a -> func ( a , value ) }
} . value


Se você conseguiu entender esse silogismo da flecha pela primeira vez - parabéns, você é um programador funcional :-)

Agora o código ficará assim:

override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
       // , forward reply
val allMessages = messages . asSequence () . flatMap { message ->
         sequenceOf ( message , message . forwardFrom , message . replyTo )
. filterNotNull ()
} . toSet ()

// ,
val getUser = allMessages . map { it . author } . parallelStream () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id } ::get . orThrow { IllegalArgumentException ( "User $ it " ) }

       //
val getFile = fileRepository . getHeadsByIds ( allMessages . flatMap { it . files ?: listOf () } . toSet ())
. associateBy { it . id } ::get . orThrow { IllegalArgumentException ( "File $ it" ) }

       messages . map ( recursiveMemoize { message , memoized : ( ChatMessage ) -> ChatMessageUI ->
         message . toFrontModel (
getUser = getUser ,
getFile = getFile ,
//
serializeMessage = memoized
         )
} )


Você também pode observar o orThrow, que é definido assim:

/** [exception] , null */
fun < P , R > (( P ) -> R ? ) . orThrow ( exception : ( P ) -> Exception ) : ( P ) -> R =
{ p -> invoke ( p ) . let { it ?: throw exception ( p ) } }


Se não houver dados sobre nosso ID em serviços externos e isso for considerado uma situação legal, você precisará lidar com isso de alguma maneira diferente.

Após essa correção, espera-se que o tempo de execução getLast seja de cerca de 300 ms. Além disso, esse tempo aumentará um pouco, mesmo que as solicitações não se ajustem mais às restrições no tamanho dos pacotes (já que os pacotes são solicitados em paralelo). Deixe-me lembrá-lo de que nossa meta mínima é de 500 ms e 250 ms podem ser considerados um trabalho normal.

Paralelismo

Mas você precisa seguir em frente. As chamadas para userRepository e fileRepository são completamente independentes e podem ser facilmente paralelizadas, em teoria aproximando-se de 200 ms.

Por exemplo, através da nossa função de junção:
override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
       // , forward reply
val allMessages = messages . asSequence () . flatMap { message ->
         sequenceOf ( message , message . forwardFrom , message . replyTo )
. filterNotNull ()
} . toSet ()

join ( {
         // ,
allMessages . map { it . author } . parallelStream () . distinct ()
. chunked ( userApiChunkLimit , userRepository ::getUsersByIds )
. flatten ()
. associateBy { it . id }
} , {
         //
fileRepository . getHeadsByIds ( allMessages . flatMap { it . files ?: listOf () } . toSet ())
. associateBy { it . id }
} ) . let { ( users , files ) ->
         messages . map ( recursiveMemoize { message , memoized : ( ChatMessage ) -> ChatMessageUI ->
           message . toFrontModel (
getUser = users ::get . orThrow { IllegalArgumentException ( "User $ it" ) } ,
getFile = files ::get . orThrow { IllegalArgumentException ( "File $ it " ) } ,
//
serializeMessage = memoized
           )
} )
}
}

Como mostra a prática, a execução leva cerca de 200 ms e é muito importante que o tempo não cresça muito com um aumento no número de mensagens.

Os problemas

Em geral, o código tornou-se, é claro, menos legível que a nossa primeira versão ingênua, mas é bom que a própria serialização (a implementação do toFrontModel) quase não tenha mudado e permaneça completamente legível. Toda a lógica do trabalho astuto com serviços externos vive em um só lugar.

A desvantagem dessa abordagem é que nossa abstração está em andamento.

Se precisarmos fazer alterações no toFrontModel, quase certamente teremos que fazer alterações na função getLast, que viola o princípio da substituição Barbara Liskov (Princípio da Substituição Liskov).

Por exemplo, concordamos em descriptografar os arquivos anexados apenas nas mensagens principais, mas não nas respostas e encaminhamentos (resposta / encaminhamento), ou apenas nas respostas e encaminhamentos do primeiro nível. Nesse caso, depois de fazer alterações no código toFrontModel, você precisará fazer as correções correspondentes no código de coleta de solicitação de arquivos. Além disso, a correção não será trivial:

fileRepository . getHeadsByIds (
allMessages . flatMap { it . files ?: listOf () } . toSet ()
)


E aqui estamos abordando sem problemas outro problema que está intimamente relacionado ao anterior: a operação correta do código como um todo depende da alfabetização da engenharia reversa. Em alguns casos complexos, o código pode não funcionar corretamente com precisão devido à coleção de consultas incorreta. Não há garantia de que você será capaz de apresentar rapidamente testes de unidade que cobrirão todas essas situações complicadas.

Conclusões

Prós:

  1. Uma maneira óbvia de pré-receber solicitações, que é facilmente separada do código principal.
  2. A quase completa ausência de sobrecarga de memória e tempo associada ao uso apenas dos dados que teriam sido recebidos de qualquer maneira.
  3. Boa escala e capacidade de criar um serviço, que em teoria será responsável por um tempo previsível, independentemente do tamanho da solicitação externa.

Contras:

  1. Código bastante complexo para o processamento em lote.
  2. Trabalho grande e responsável na análise de solicitações na implementação existente.
  3. A abstração fluida e, como conseqüência, a fragilidade de todo o esquema em relação às mudanças na implementação.
  4. Dificuldades no suporte: é difícil distinguir os erros no bloco de previsão de consultas dos erros no código principal. Idealmente, você precisa usar o dobro de testes de unidade; portanto, procedimentos com erros de produção serão duas vezes mais difíceis.
  5. Cumprindo os princípios do SOLID ao escrever o código: o código deve ser preparado para alienar a lógica do processamento em lote. A introdução desses princípios por si só fornecerá algumas vantagens, portanto esse menos é o mais insignificante.

É importante observar que você pode usar esse método sem fazer engenharia reversa. Precisamos obter um contrato getLast, do qual o contrato para o cálculo preliminar de solicitações depende (daqui em diante - pré-busca). Nesse caso, fizemos isso observando a implementação de getLast (engenharia reversa). No entanto, com essa abordagem, surgem dificuldades: a edição desses dois pedaços de código sempre deve ser síncrona e é impossível garantir isso (lembre-se de hashCode e igual, existe exatamente a mesma coisa). A próxima abordagem, que eu gostaria de mostrar, é projetada para resolver esse problema (ou pelo menos atenuar).

Heurística de negócios


Resolvendo um problema de contrato

E se operarmos não com um contrato exato e, portanto, com um conjunto exato de solicitações, mas com um contrato aproximado? Além disso, criaremos um conjunto aproximado para incluir estritamente o conjunto exato e basear-se nas características da área de assunto.

Portanto, em vez da dependência do contrato de pré-busca no getLast, estabelecemos a dependência de ambos em algum contrato comum que será ditado pelo usuário. A principal dificuldade será de alguma forma incorporar esse contrato geral na forma de código.

Procure limitações úteis

Vamos tentar fazer isso com o nosso exemplo.
No nosso caso, existem os seguintes recursos comerciais:

  • a lista de participantes do bate-papo é predefinida;
  • os chats são completamente isolados um do outro;
  • o aninhamento de cadeias de resposta / encaminhamento é pequeno (~ 2 a 3 mensagens).

Desde a primeira restrição, segue-se que você não precisa contornar as mensagens, ver quais usuários existem, escolher os únicos e fazer um pedido por eles. Você pode simplesmente consultar uma lista predefinida. Se você concordou com esta afirmação, eu te peguei.

De fato, nem tudo é tão simples. Uma lista pode ser predefinida, mas pode haver milhares de usuários. Tais coisas precisam ser esclarecidas. No nosso caso, normalmente haverá dois ou três participantes no chat, raramente mais. Portanto, é perfeitamente aceitável receber dados sobre todos eles.

Além disso, se a lista de usuários de bate-papo for predeterminada, mas essas informações não estiverem no serviço do usuário (o que é muito provável), não haverá sentido nessas informações. Faremos um pedido extra para a lista de usuários do bate-papo, e você ainda precisará fazer o pedido para o serviço do usuário.

Suponha que as informações sobre a conexão de usuários e bate-papo sejam armazenadas no serviço do usuário. No nosso caso, é assim, pois a conexão é determinada pelos direitos do usuário. Em seguida, para os usuários, o código de pré-busca será exibido:

Pode parecer surpreendente aqui que não estamos passando nenhum identificador de bate-papo. Fiz isso intencionalmente para não bagunçar o código de exemplo.

À primeira vista, nada segue da segunda restrição. De qualquer forma, ainda não consegui nada útil dele.

Já usamos a terceira restrição anteriormente. Pode ter um impacto significativo em como armazenamos e recebemos conversas. Não começaremos a desenvolver este tópico, pois ele não tem nada a ver com o controlador REST e o processamento em lote.

O que fazer com os arquivos? Gostaria de obter uma lista de todos os arquivos de bate-papo em uma solicitação simples. Sob os termos da API, precisamos apenas de cabeçalhos de arquivo, sem corpos, para que isso não pareça uma tarefa perigosa e que consome muitos recursos.

Por outro lado, devemos lembrar que não recebemos todas as mensagens de bate-papo, mas apenas o último N, e pode facilmente acontecer que elas não contenham nenhum arquivo.

Não pode haver resposta universal: tudo depende das especificações comerciais e dos casos de uso. Ao criar uma solução de produto, você pode ter problemas se definir uma heurística para um caso de uso e, em seguida, os usuários trabalharão com a funcionalidade de uma maneira diferente. Para demonstrações e pré-vendas, essa é uma boa opção, mas agora estamos tentando criar um serviço de produção honesto.

Portanto, será possível fazer heurísticas de negócios para arquivos aqui apenas com base nos resultados da operação e na coleta de estatísticas (ou após uma avaliação de um especialista).

Como ainda queremos aplicar nosso método de alguma forma, suponha que as estatísticas mostrem o seguinte:

  1. Uma conversa típica começa com uma mensagem que inclui um ou mais arquivos, seguida por mensagens de resposta sem arquivos.
  2. Quase todas as mensagens vêm em conversas típicas.
  3. O número esperado de arquivos exclusivos em um único bate-papo é de ~ 20.

Daqui resulta que, para exibir quase todas as mensagens, você precisará obter os cabeçalhos de alguns arquivos (porque o ChatMessageUI é organizado) e que o número total de arquivos é pequeno. Nesse caso, parece razoável receber todos os arquivos de bate-papo em uma solicitação. Para fazer isso, teremos que adicionar o seguinte à nossa API para arquivos:

fun getHeadsByChat () : List < FileHeadRemote >

O método getHeadsByChat não parece exagerado e criado puramente devido ao nosso desejo de otimizar o desempenho (embora esse também seja um bom motivo). Com frequência, nas salas de bate-papo com arquivos, os usuários desejam ver todos os arquivos usados ​​e na ordem em que foram adicionados (portanto, usamos Lista).

A implementação dessa conexão explícita exigirá o armazenamento de informações adicionais em um serviço de arquivo ou em nosso aplicativo. Tudo depende de em qual área de responsabilidade, em nossa opinião, essas informações redundantes sobre a conexão do arquivo com o bate-papo devem ser armazenadas. É redundante porque a mensagem já está associada ao arquivo e, por sua vez, está associada ao bate-papo. Você não pode usar a desnormalização, mas extraia essas informações rapidamente de mensagens, isto é, dentro do SQL, receba imediatamente todos os arquivos em todo o bate-papo (isso está em nosso aplicativo) e solicite todos de uma vez no serviço de arquivos. Essa opção funcionará pior se houver muitas mensagens de bate-papo, mas não precisamos de desnormalização. Eu ocultaria as duas opções atrás de getHeadsByChat.

O código é o seguinte:

override fun getLast ( n : Int ) =
messageRepository . findLast ( n )
. let { messages ->
       join (
{ userRepository . getUsersByChat () . associateBy { it . id } } ,
{ fileRepository . getHeadsByChat () . associateBy { it . id } }
       )
. let { //
}
}


Pode-se observar que, em comparação com a versão anterior, muito pouco mudou e apenas a parte de pré-busca foi afetada, o que é ótimo.

O código de pré-busca se tornou muito mais curto e claro.

O tempo de execução não mudou, o que é lógico, pois o número de solicitações permanece o mesmo. Teoricamente, existem casos em que o dimensionamento será melhor que a engenharia reversa honesta (apenas devido à remoção do link do cálculo complexo). No entanto, situações opostas são igualmente prováveis: as heurísticas enfileiram demais. Como mostra a prática, se você conseguir obter heurísticas adequadas, não haverá alterações especiais no tempo de execução.

No entanto, isso não é tudo. Não levamos em conta que agora o recebimento de dados detalhados sobre usuários e arquivos não está relacionado ao recebimento de mensagens e solicitações podem ser iniciadas em paralelo:

Esta opção fornece 100 ms estáveis ​​por solicitação.

Erros heurísticos

E se, ao usar heurísticas, o conjunto de consultas não for maior, mas um pouco menor do que deveria? Para a maioria das opções, essas heurísticas funcionarão, mas haverá exceções para as quais você precisará fazer uma solicitação separada. Na minha prática, essas decisões não tiveram êxito, pois cada exceção teve um grande impacto no desempenho e, no final, algum usuário fez uma solicitação que consistia inteiramente em exceções. Eu diria que em tais situações é melhor usar a engenharia reversa, mesmo que o algoritmo de coleta de consultas seja assustador e ilegível, mas, é claro, tudo depende da criticidade do serviço.

Conclusões

Prós:

  1. A lógica das heurísticas de negócios é fácil de ler e geralmente trivial. Isso é bom para entender os limites de aplicabilidade, verificar e modificar o contrato de pré-busca.
  2. A escalabilidade é tão boa quanto a engenharia reversa.
  3. A coerência do código sobre os dados é reduzida, o que pode levar a uma melhor paralelização do código.
  4. A lógica de pré-busca, como a lógica principal do controlador REST, é baseada em requisitos. Esta é uma vantagem fraca se os requisitos mudarem com frequência.

Contras:

  1. Dos requisitos, não é tão fácil derivar heurísticas para previsões de consulta. O esclarecimento dos requisitos pode ser necessário, na medida em que seja pouco compatível com o ágil.
  2. Você pode obter dados extras.
  3. Para garantir que o contrato de pré-busca funcione efetivamente, provavelmente será necessário desnormalizar o armazenamento de dados. Esse é um sinal de menos, pois essas otimizações seguem a lógica de negócios e, portanto, provavelmente serão reivindicadas por diferentes processos.

Do nosso exemplo, podemos concluir que a aplicação dessa abordagem é muito difícil e o jogo não vale a pena. De fato, em projetos de negócios reais, o número de restrições é enorme e, a partir dessa pilha, você geralmente consegue obter algo útil, o que permite particionar dados ou prever estatísticas. A principal vantagem dessa abordagem é que as restrições utilizadas são interpretadas pelos negócios, portanto, são facilmente compreendidas e validadas.

Geralmente, o maior problema ao tentar usar essa abordagem é a separação de atividades. O desenvolvedor deve estar bem imerso na lógica de negócios e fazer perguntas aos analistas que esclarecem perguntas, o que exige um certo nível de iniciativa.

Unidades no estilo DDD


Em projetos grandes, geralmente é possível ver o uso de práticas DDD, pois elas permitem estruturar seu código com eficiência. Não é necessário usar todos os modelos DDD no projeto - às vezes você pode obter bons retornos mesmo com a introdução de um. Considere o conceito de DDD como um agregado. Um agregado é uma união de entidades logicamente conectadas, cujo trabalho é realizado apenas através da raiz do agregado (geralmente essa é uma entidade que é a parte superior do gráfico de conectividade da entidade).

Do ponto de vista da obtenção de dados, o principal no agregado é que toda a lógica de trabalhar com listas de entidades está em um único local - o agregado. Existem duas abordagens para o que deve ser transferido para a unidade durante sua construção:

  1. Transferimos funções para a unidade para obter dados externos. A lógica para determinar os dados necessários vive dentro da unidade.
  2. Transferimos todos os dados necessários. A lógica para determinar os dados necessários está fora do agregado.

A escolha da abordagem depende em grande parte da facilidade com que a pré-busca pode ser movida para fora do agregado. Se a lógica de pré-busca é baseada em heurísticas de negócios, geralmente é fácil separá-la do agregado. Levar a lógica além do escopo de um agregado com base na análise de seu uso (engenharia reversa) pode ser perigoso, pois podemos distribuir códigos relacionados logicamente em diferentes classes.

A lógica de ampliar consultas dentro de um agregado

Vamos tentar esboçar um agregado que corresponda ao conceito de "bate-papo". Nossas classes ChatMessage, UserReference, FileReference correspondem ao modelo de armazenamento, para que possam ser renomeadas com algum prefixo apropriado, mas como temos um projeto pequeno, vamos deixá-lo como está. Chamamos o assembly de Chat, e seus componentes são ChatPage e ChatPageMessage: Até o momento, é obtida muita duplicação sem sentido. Isso se deve ao fato de nosso modelo de assunto ser semelhante ao modelo de armazenamento e ambos serem semelhantes ao modelo de front-end. Eu uso as classes FileHeadRemote e UserRemote diretamente, para não escrever código desnecessário, embora geralmente no domínio você deva evitar o uso dessas classes diretamente.

interface Chat {
fun getLastPage ( n : Int ) : ChatPage
}

interface ChatPage {
val messages : List < ChatPageMessage >
}

data class ChatPageMessage (
val id : Long ,
val author : UserRemote ,
val message : String ,
val files : List < FileHeadRemote >,
val replyTo : ChatPageMessage ? ,
val forwardFrom : ChatPageMessage ?
)






Se você usar esse agregado, nosso controlador REST poderá ser reescrito da seguinte maneira: Essa opção lembra muito nossa primeira implementação ingênua, mas possui uma vantagem importante: o controlador não está mais envolvido em receber dados diretamente e não depende das classes associadas ao armazenamento de dados, mas depende somente a partir da unidade, que é definida através das interfaces. Portanto, a lógica de pré-busca não está mais no controlador. O controlador lida apenas com a conversão da unidade em um modelo front-end, o que nos dá conformidade com o Princípio de Responsabilidade Única (SRP). Infelizmente, para todos os métodos descritos em conjunto, você precisará escrever uma implementação.

class ChatRestController (
private val chat : Chat
) : ChatRestApi {
override fun getLast ( n : Int ) =
chat . getLastPage ( n ) . toFrontModel ()

private fun ChatPage . toFrontModel () =
messages . map { it . toFrontModel () }

   private fun ChatPageMessage . toFrontModel () : ChatMessageUI =
ChatMessageUI (
id = id ,
author = author . toFrontReference () ,
message = message ,
files = files . toFrontReference () ,
forwardFrom = forwardFrom ?. toFrontModel () ,
replyTo = replyTo ?. toFrontModel ()
)
}






Vamos tentar apenas salvar a lógica do controlador implementada ao usar heurísticas de negócios.
class ChatImpl (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : Chat {
override fun getLastPage ( n : Int ) = object : ChatPage {
override val messages : List < ChatPageMessage >
get () =
runBlocking ( IO ) {
           val prefetch = async (
{ userRepository . getUsersByChat () . associateBy { it . id } } ,
{ fileRepository . getHeadsByChat () . associateBy { it . id } }
           )

withContext ( IO ) { messageRepository . findLast ( n ) }
             . map (
prefetch . await () . let { ( users , files ) ->
                 recursiveMemoize { message , memoized : ( ChatMessage ) -> ChatPageMessage ->
                   message . toDomainModel (
getUser = users ::get . orThrow { IllegalArgumentException ( "User $ it " ) } ,
getFile = files ::get . orThrow { IllegalArgumentException ( "File $ it " ) } ,
//
serializeMessage = memoized
                   )
}
}
             )
}
   }
}

private fun ChatMessage . toDomainModel (
getUser : ( UserReference ) -> UserRemote ,
getFile : ( FileReference ) -> FileHeadRemote ,
serializeMessage : ( ChatMessage ) -> ChatPageMessage
) = ChatPageMessage (
id = id ?: throw IllegalStateException ( " $ this must be persisted" ) ,
author = getUser ( author ) ,
message = message ,
files = files ?. map ( getFile ) ?: listOf () ,
forwardFrom = forwardFrom ?. let ( serializeMessage ) ,
replyTo = replyTo ?. let ( serializeMessage )
)

Verificou-se que a própria função getLastPage possui uma estratégia de aquisição de dados, incluindo pré-busca, e a função toDomainModel é puramente técnica e é responsável pela conversão de modelos armazenados em um modelo de domínio.

Reescrevi as chamadas paralelas para userRepository, fileRepository e messageRepository de uma forma mais familiar para o Kotlin. Espero que a compreensibilidade do código não tenha sofrido por causa disso.

Em geral, esse método já é totalmente funcional; o desempenho ao aplicá-lo será o mesmo que com o simples uso de engenharia reversa ou heurística de negócios.

A lógica de ampliar consultas fora do agregado

No processo de criação do agregado, encontraremos imediatamente um problema: para construir o ChatPage, o tamanho da página precisará ser definido como constante ao criar o Chat, e não passá-lo para getLast (), como de costume. Vai ter que me mudar interface de agregado: Uma vez que temos dochitka outras mensagens e queremos fortemente para obter toda a parte externa de dados da unidade, teremos de optar por sair do nível agregado de conversar e fazer ChatPage root: Em seguida, você precisa criar um código de pré-busca, se separar da unidade: Agora, a fim Para criar um agregado, você precisa acoplá-lo à pré-busca. No DDD, esse tipo de orquestração é feito pelo Application Services.

interface Chat {
fun getPage () : ChatPage
}




class ChatPageImpl (
private val messageData : List < ChatMessage >,
private val userData : List < UserRemote >,
private val fileData : List < FileHeadRemote >
) : ChatPage {
override val messages : List < ChatPageMessage >
get () =
messageData . map (
( userData . associateBy { it . id } to fileData . associateBy { it . id } )
. let { ( users , files ) ->
             recursiveMemoize { message , self : ( ChatMessage ) -> ChatPageMessage ->
               message . toDomainModel (
getUser = users ::get . orThrow () ,
getFile = files ::get . orThrow () ,
//
serializeMessage = self
               )
}
}
       )
}




fun chatPagePrefetch (
pageSize : Int ,
messageRepository : ChatMessageRepository ,
userRepository : UserRemoteApi ,
fileRepository : FileRemoteApi
) =
runBlocking ( IO ) {
     async (
{ userRepository . getUsersByChat () } ,
{ fileRepository . getHeadsByChat () } ,
{ messageRepository . findLast ( pageSize ) }
     )
}




class ChatService (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) {
private fun chatPagePrefetch ( pageSize : Int ) =
runBlocking ( IO ) {
       async (
{ messageRepository . findLast ( pageSize ) } ,
{ userRepository . getUsersByChat () } ,
{ fileRepository . getHeadsByChat () }
       ) . await ()
}

   fun getLastPage ( n : Int ) : ChatPage =
chatPagePrefetch ( n )
. let { ( messageData , userData , fileData ) ->
         ChatPageImpl ( messageData , userData , fileData )
}
}


Bem, o controlador não muda muito, você só precisa usar ChatService :: getLastPage em vez de Chat :: getLastPage. Ou seja, o código mudará assim:

class ChatRestController (
private val chat : ChatService
) : ChatRestApi


Conclusões

  1. A lógica de pré-busca pode ser colocada dentro da unidade ou em um local separado.
  2. Se a lógica de pré-busca estiver fortemente relacionada à lógica interna do agregado, é melhor não removê-la, pois isso pode interromper o encapsulamento. Pessoalmente, não vejo muito sentido em mover a pré-busca do agregado, pois isso limita muito as possibilidades e aumenta a coerência implícita do código.
  3. A própria organização agregada tem um efeito positivo no desempenho do processamento em lote, à medida que o controle sobre solicitações pesadas se torna maior e o local da lógica de pré-busca se torna bastante definido.

No próximo capítulo, consideraremos uma implementação de pré-busca que não pode ser implementada isoladamente da função principal.

Proxy e chamada dupla


Resolvendo o problema do contrato

Como já vimos nas partes anteriores, o principal problema do contrato de pré-busca é que ele está fortemente conectado ao contrato da função para a qual deve preparar os dados. Para ser mais preciso, depende de quais dados a função principal pode precisar. E se não tentarmos prever, mas tentar fazer engenharia reversa usando o próprio código? Em situações simples, a abordagem de proxy normalmente usada nos testes pode nos ajudar. Bibliotecas como o Mockito geram classes com implementações de interface, que também podem acumular informações sobre chamadas. Uma abordagem semelhante é usada em nossa biblioteca .

Se você chamar a função principal com repositórios com proxy e coletar informações sobre os dados necessários, poderá obter esses dados na forma de um pacote e chamar novamente a função principal para obter o resultado final.

A principal condição é a seguinte: os dados solicitados não devem afetar solicitações subsequentes. O proxy não retornará dados reais, mas apenas alguns stubs; portanto, todas as ramificações e recebimento dos dados associados desaparecerão.

No nosso caso, isso significa que é inútil proxy messageRepository, pois solicitações adicionais são feitas com base nos resultados da solicitação de mensagem. Isso não é um problema, pois temos apenas uma solicitação para messageRepository, portanto, nenhum processamento em lote é necessário aqui.

Como proxy das funções simples UserReference-> UserRemote e FileReference-> FileHeadRemote, você só precisa acumular duas listas de argumentos.

Como resultado, obtemos o seguinte:
class ChatRestController (
private val messageRepository : ChatMessageRepository ,
private val userRepository : UserRemoteApi ,
private val fileRepository : FileRemoteApi
) : ChatRestApi {
override fun getLast ( n : Int ) : List < ChatMessageUI > {
val messages = messageRepository . findLast ( n )

//
fun transform (
getUser : ( UserReference ) -> UserRemote ,
getFile : ( FileReference ) -> FileHeadRemote
     ) : List < ChatMessageUI > =
messages . map (
recursiveMemoize { message , self ->
           message . toFrontModel ( getUser , getFile , self )
}
       )

//
val userIds = mutableSetOf < UserReference > ()
val fileIds = mutableSetOf < FileReference > ()
transform (
{ userIds += it ; UserRemote ( 0L , "" ) } ,
{ fileIds += it ; FileHeadRemote ( 0L , "" ) }
     )

return runBlocking ( IO ) {
       //
async (
{ userRepository . getUsersByIds ( userIds ) . associateBy { it . id } ::get . orThrow () } ,
{ fileRepository . getHeadsByIds ( fileIds ) . associateBy { it . id } ::get . orThrow () }
       ) . await () . let { ( getUser , getFile ) ->
         transform ( getUser , getFile )
}
}
   }
}

Se você medir o desempenho, acontece que, com essa abordagem, não é pior do que usar métodos de engenharia reversa, embora chamemos a função duas vezes. Isso se deve ao fato de que, comparado ao tempo de execução de consultas externas, o tempo de execução da função de conversão pode ser negligenciado (no nosso caso).

Comparado com o desempenho ao usar heurísticas de negócios, no nosso caso, o acúmulo de consultas será menos eficaz. Mas lembre-se de que nem sempre é possível encontrar heurísticas tão boas. Por exemplo, se o número de usuários no bate-papo for grande, assim como o número de arquivos e os arquivos raramente forem anexados às mensagens, nosso algoritmo de heurística de negócios começará a perder imediatamente ao receber honestamente uma lista de solicitações.

Conclusões

Prós:

  1. prefetch - .
  2. prefetch.
  3. , -.

Contras:

  1. .
  2. , .

Apesar do aparente exotismo, o acúmulo de solicitações por meio de proxy e recall é bastante aplicável em situações em que a lógica da função principal não está ligada aos dados recebidos. A principal dificuldade aqui é a mesma da engenharia reversa: estamos trabalhando na implementação atual da função, embora em uma extensão muito menor (apenas no fato de que as consultas a seguir não dependem dos resultados de consultas anteriores).

O desempenho cairá um pouco, mas no código de pré-busca, você não precisará levar em consideração todas as nuances da implementação da função principal.

Você pode usar essa abordagem quando não puder criar boas heurísticas de negócios para previsão de consultas e desejar reduzir a conectividade de pré-busca e função.

Conclusão


Usar o processamento em lote não é tão simples quanto parece à primeira vista. Eu acho que todos os padrões de design têm essa propriedade (lembre-se do cache).

Para um processamento eficiente de solicitações em lote, é importante que o chamador colete o maior número possível de solicitações, o que geralmente é dificultado pela estrutura do aplicativo. Há duas maneiras: projetar o aplicativo com o objetivo de trabalhar com dados com eficiência (pode muito bem levar a um dispositivo reativo do aplicativo) ou, como geralmente acontece, tentar implementar o processamento em lote em um aplicativo existente sem uma reestruturação significativa.

A maneira mais óbvia de acumular consultas é fazer a engenharia reversa do código existente em busca de consultas pesadas. A principal desvantagem dessa abordagem será um aumento na conectividade implícita do código. Uma alternativa é usar informações sobre os recursos de negócios para dividir os dados em partes, que geralmente são compartilhadas e o todo. Às vezes, para uma separação tão eficaz, será necessário desnormalizar o armazenamento, mas, se for bem-sucedido, a lógica do processamento em lote será determinada pela área de assunto, o que é bom.

Uma maneira menos óbvia de obter todas as solicitações é implementar duas passagens. Na primeira etapa, coletamos todas as solicitações necessárias, na segunda trabalhamos com os dados já recebidos. A aplicabilidade dessa abordagem é limitada pelo requisito de independência de solicitações entre si.

Source: https://habr.com/ru/post/pt467919/


All Articles