Desde o advento do async
/ await
, o Typescript publicou muitos artigos que exaltam essa abordagem de desenvolvimento ( hackernoon , blog.bitsrc.io , habr.com ). Nós os usamos desde o início, no lado do cliente (quando os ES6 Generators suportavam menos de 50% dos navegadores). E agora quero compartilhar minha experiência, porque a execução paralela não é tudo que seria bom saber nesse caminho.
Eu realmente não gosto do artigo final: algo pode ser incompreensível. Em parte devido ao fato de não poder fornecer código proprietário - apenas para descrever a abordagem geral. Portanto:
- não hesite em fechar a guia sem ler
- se você conseguir, peça detalhes pouco claros
- Terei prazer em aceitar conselhos e críticas dos mais persistentes e minuciosamente descobertos.
Lista de tecnologias principais:
- O projeto foi escrito principalmente em Typescript usando várias bibliotecas Javascript. A biblioteca principal é o ExtJS. O React tem uma qualidade inferior na moda, mas é mais adequado para um produto corporativo com uma interface rica: muitos componentes prontos, tabelas bem projetadas prontas para uso, um rico ecossistema de produtos relacionados para simplificar o desenvolvimento.
- Servidor multithread assíncrono.
- O RPC através do Websocket é usado como um transporte entre o cliente e o servidor. A implementação é semelhante ao .NET WCF.
- Qualquer objeto é um serviço.
- Qualquer objeto pode ser transmitido por valor e por referência.
- A interface de solicitação de dados se assemelha ao GraphQL do Facebook, apenas no Typescript.
- Comunicação bidirecional: a inicialização da atualização de dados pode ser iniciada no cliente e no servidor.
- O código assíncrono é gravado sequencialmente através do uso das funções
async
/ waitit do Typesrcipt. - A API do servidor é gerada no TypeScript: se for alterada, a compilação mostrará imediatamente em caso de erro.
Qual é a saída
Vou lhe contar como trabalhamos com isso e o que fizemos para a execução segura e não competitiva de código assíncrono: nossos decoradores de Typesrcipt que implementam a funcionalidade das filas. Do básico à solução da condição de corrida e outras dificuldades que surgem durante o processo de desenvolvimento.
Como os dados recebidos do servidor estão estruturados
O servidor retorna um objeto pai que contém dados (outros objetos, coleções de objetos, linhas etc.) em suas propriedades na forma de um gráfico. Isso se deve, inter alia, ao próprio aplicativo:
- faz da análise de dados / ML um gráfico direcionado de nós manipuladores.
- cada nó, por sua vez, pode conter seu próprio gráfico incorporado
- Os gráficos têm dependências: os nós podem ser "herdados" e novos nós são criados por sua "classe".
Mas a estrutura de consulta na forma de gráfico pode ser aplicada em quase qualquer aplicativo, e o GraphQL, tanto quanto eu sei, também menciona isso em suas especificações.
Estrutura de dados de exemplo:
Como um cliente recebe dados
É simples: quando você solicita uma propriedade de um objeto de um tipo não escalar, o RPC retorna Promise
:
let Nodes = Parent.Nodes;
Assincronia sem um "inferno de retorno de chamada".
Para organizar um código assíncrono "sequencial", a funcionalidade Typescript async
/ await
é usada:
async function ShowNodes(parent: IParent): Promise<void> {
Não faz sentido insistir nisso em detalhes, no hub já existe material detalhado suficiente. Eles apareceram no Typcript em 2016. Nós usamos essa abordagem desde que ela apareceu no ramo de recursos do repositório Typescript, por isso, temos problemas por um longo tempo e agora estamos trabalhando com prazer. Já faz algum tempo e em produção.
Resumidamente, a essência para aqueles que não estão familiarizados com o assunto:
Assim que você adicionar a palavra async
chave async
à função, ela retornará automaticamente o Promise<_>
. Características de tais funções:
- Expressões dentro de funções
async
com await
(que retornam Promise
) interromperão a execução da função e continuarão após a resolução da Promise
esperada. - Se ocorrer uma exceção na função
async
, a Promise
retornada será rejeitada com essa exceção. - Ao compilar no código Javascript, haverá geradores para o padrão ES6 (
function*
vez de async function
e yield
vez de await
) ou código assustador com a switch
para ES5 (máquina de estado). await
é uma palavra-chave que aguarda o resultado de uma promessa. No momento da reunião, durante a execução do código, a função ShowNodes
é interrompida e, enquanto aguarda os dados, o Javascript pode executar outro código.
No código acima, a coleção possui um método forEachParallel
que chama um retorno de chamada assíncrono para cada nó em paralelo. Ao mesmo tempo, await
antes que o Nodes.forEachParallel
aguarde todos os retornos de chamada. Por dentro da implementação - Promise.all
:
export async function forEachParallel<T>(items: IItemArray<T>, callbackfn: (value: T, index: int, items: IItemArray<T>) => Promise<void | any>, thisArg?: any): Promise<void> { let xCount = items ? await items.Count : 0; if (!xCount) return; let xActions = new Array<Promise<void | any>>(xCount); for (let i = 0; i < xCount; i++) { let xItem = items.Item(i); xActions[i] = ExecuteCallback(xItem, callbackfn, i, items, thisArg); } await Promise.all(xActions); } /** item callbackfn */ async function ExecuteCallback<T>(item: Promise<T> | T, callbackfn: (value: T, index: int, items: IItemArray<T>) => Promise<void | any>, index: int, items: IItemArray<T>, thisArg?: any): Promise<void> { let xItem = await item; await callbackfn.call(thisArg, xItem, index, items); }
Este é o açúcar sintático: esses métodos devem ser usados não apenas para suas coleções, mas também para matrizes Javascript padrão.
A função ShowNodes
parece extremamente não ideal: quando solicitamos outra entidade, esperamos por ela toda vez. A conveniência é que esse código possa ser escrito rapidamente, portanto essa abordagem é boa para a criação rápida de protótipos. Na versão final, você precisa usar o idioma da consulta para reduzir o número de chamadas para o servidor.
Idioma de consulta
Existem várias funções usadas para "criar" uma solicitação de dados do servidor. Eles “informam” ao servidor quais nós do gráfico de dados retornarão na resposta:
selectAsync<T extends IItem>(item: T, properties: () => any[]): Promise<T>; selectAsyncAll<T extends ICollection>(items: T[], properties: () => any[]): Promise<T[]>; select<T>(item: T, properties: () => any[]): T; selectAll<T>(items: T[], properties: () => any[]): T[];
Agora, vejamos a aplicação dessas funções para solicitar os dados incorporados necessários com uma chamada para o servidor:
async function ShowNodes(parentPoint: IParent): Promise<void> {
Um exemplo de uma consulta um pouco mais complexa com informações profundamente incorporadas:
A linguagem de consulta ajuda a evitar solicitações desnecessárias ao servidor. Mas o código nunca é perfeito e certamente conterá várias solicitações competitivas e, como resultado, condição de corrida.
Condição de Corrida e Soluções
Como FuncOne
eventos do servidor e escrevemos código com um grande número de solicitações assíncronas, uma condição de corrida pode ocorrer quando a função async
FuncOne
interrompida, aguardando Promise
. Nesse momento, um evento do servidor (ou da próxima ação do usuário) pode ocorrer e, após a execução competitiva, alterar o modelo no cliente. Então o FuncOne
após resolver a promessa, pode recorrer, por exemplo, aos recursos já excluídos.
Imagine uma situação tão simplificada: o objeto IParent
tem um IParent
servidor IParent
.
Parent.OnSynchronize.AddListener(async function(): Promise<void> {
É chamado quando a lista de nós INodes
no servidor é atualizada. Então, no cenário a seguir, uma condição de corrida é possível:
- Causamos a remoção assíncrona do nó do cliente, aguardando a conclusão para excluir o objeto do cliente
async function OnClickRemoveNode(node: INode): Promise<void> { let removedOnServer: boolean = await Parent.RemoveNode(node);
- Por meio de
Parent.OnSynchronize
, Parent.OnSynchronize
uma atualização do evento da lista de nós. Parent.OnSynchronize
processado e exclui o objeto do cliente.async OnClickRemoveNode()
continua a ser executado após a primeira await
e é feita uma tentativa de excluir um objeto de cliente já excluído.
Você pode verificar a existência de um objeto cliente no OnClickRemoveNode
. Este é um exemplo simplificado e nele uma verificação semelhante é normal. Mas e se a cadeia de chamadas for mais complicada? Portanto, usar uma abordagem semelhante após cada await
é uma má prática:
- O código tão inchado é complicado de suportar e estender.
- O código não funciona conforme o
OnClickRemoveNode
: a exclusão no OnClickRemoveNode
e a exclusão real do objeto cliente ocorre em outro lugar. Não deve haver uma violação da sequência definida pelo desenvolvedor, caso contrário, haverá erros de regressão. - Isso não é confiável o suficiente: se você esquecer de fazer uma verificação em algum lugar, haverá um erro. O perigo é, em primeiro lugar, que uma verificação esquecida não leve a um erro localmente e em um ambiente de teste, e para usuários com um atraso maior na rede, isso ocorrerá.
- E se o controlador ao qual esses manipuladores pertencem pode ser destruído? Depois de cada
await
para verificar sua destruição?
Outra questão surge: e se houver muitos métodos competitivos semelhantes? Imagine que existem mais:
- Adicionando um nó
- Atualização do nó
- Adicionar / Remover Links
- Método de conversão de vários nós
- Comportamento complexo do aplicativo: alteramos o estado de um nó e o servidor começa a atualizar os nós dependentes dele.
É necessária uma implementação arquitetônica que, em princípio, elimina a possibilidade de erros devido a condição de corrida, ações paralelas do usuário etc. A solução correta para eliminar a alteração simultânea do modelo do cliente ou servidor é implementar uma seção crítica com uma fila de chamadas. Decoradores datilografados serão úteis aqui para marcar declarativamente essas funções competitivas do controlador assíncrono.
Descrevemos os requisitos e os principais recursos desses decoradores:
- No interior, uma fila de chamadas para funções assíncronas deve ser implementada. Dependendo do tipo de decorador, uma chamada de função pode ser enfileirada ou rejeitada se houver outras chamadas.
- As funções marcadas exigirão um contexto de execução para vincular à fila. Você deve criar explicitamente uma fila ou fazê-lo automaticamente com base na exibição à qual o controlador pertence.
- São necessárias informações sobre a destruição da instância do controlador (por exemplo, a propriedade
IsDestroyed
). Para impedir que os decoradores façam chamadas em fila após a destruição do controlador. - Para o controlador View, adicionamos a funcionalidade de aplicar uma máscara translúcida para excluir ações no momento em que a fila é executada e indicar visualmente o processamento em andamento.
- Todos os decoradores devem terminar com uma chamada para
Promise.done()
. Nesse método, você precisa implementar o handler
exceções não tratadas. Uma coisa muito útil:
Agora, fornecemos uma lista aproximada desses decoradores com uma descrição e, em seguida, mostramos como eles podem ser aplicados.
@Lock @LockQueue @LockBetween @LockDeferred(300)
As descrições são bastante abstratas, mas assim que você vir um exemplo de uso com explicações, tudo ficará mais claro:
class GraphController implements ILockTarget { private View: IView; public GetControllerView(): IView { return this.View; } @Lock private async OnClickRemoveNode(): Promise<void> { ... } @Lock private async OnClickRemoveLink(): Promise<void> { ... } @Lock private async OnClickAddNewNode(): Promise<void> { ... } @LockQueue private async OnServerUpdateNode(): Promise<void> { ... } @LockQueue private async OnServerAddLink(): Promise<void> { ... } @LockQueue private async OnServerAddNode(): Promise<void> { ... } @LockQueue private async OnServerRemoveNode(): Promise<void> { ... } @LockBetween private async OnServerSynchronize(): Promise<void> { ... } @LockQueue private async OnServerUpdateNodeStatus(): Promise<void> { ... } @LockDeferred(300) private async OnSearchFieldChange(): Promise<void> { ... } }
Agora vamos analisar alguns cenários típicos de possíveis erros e sua eliminação pelos decoradores:
- O usuário inicia uma ação:
OnClickRemoveNode
, OnClickRemoveLink
. Para o processamento adequado, é necessário que não haja outros manipuladores em execução na fila (cliente ou servidor). Caso contrário, por exemplo, esse erro é possível:
- O modelo no cliente ainda é atualizado para o estado atual do servidor
- Iniciamos a exclusão do objeto antes que a atualização seja concluída (a fila tem um manipulador
OnServerSynchronize
). Mas esse objeto não está mais lá - apenas a sincronização completa ainda não foi concluída e ainda é exibida no cliente.
Portanto, todas as ações iniciadas pelo usuário, o decorador de Lock
devem rejeitar se houver outros manipuladores na fila com o mesmo contexto de fila. Dado que o servidor é assíncrono, isso é especialmente importante. Sim, o Websocket envia solicitações sequencialmente, mas se o cliente interromper a sequência, ocorreremos um erro no servidor.
- Iniciamos a adição de um nó:
OnClickAddNewNode
. OnServerSynchronize
, OnServerAddNode
eventos vêm do servidor.
OnClickAddNewNode
pegou a fila (se houvesse algo nela, o decorador de Lock
desse método rejeitaria a chamada)OnServerSynchronize
, OnServerAddNode
, executado sequencialmente após OnClickAddNewNode
, não competindo com ele.
- A fila possui
OnServerUpdateNode
e OnServerUpdateNode
. Suponha que durante a execução do primeiro, o usuário feche o GraphController
. Em seguida, a segunda chamada para OnServerUpdateNode
não deve ser executada automaticamente para não executar ações no controlador destruído, o que é garantido que levará a um erro. Para isso, a interface ILockTarget
possui IsDestroyed
- o decorador verifica o sinalizador sem executar o próximo manipulador da fila.
Lucro: não é necessário escrever if (!this.IsDestroyed())
após cada await
. - Alterações em vários nós são acionadas.
OnServerSynchronize
eventos OnServerSynchronize
, OnServerUpdateNode
vêm do servidor. Sua execução competitiva levará a erros irreprodutíveis. Mas desde LockQueue
eles estiverem marcados pelos LockBetween
e LockBetween
, eles serão executados sequencialmente. - Imagine que os nós podem ter gráficos de nós aninhados dentro deles.
GraphController #1
, — GraphController #2
. , GraphController
- , ( — ), .. . :
OnSearchFieldChange
, . - . @LockDeferred(300)
300 : , , 300 . , . :
- , 500 , . —
OnSearchFieldChange
, . OnSearchFieldChange
— , .
- Deadlock:
Handler1
, , await
Handler2
, LockQueue
, Handler2
— Handler1
. - , View . : , — .
, , . :
- -
<Class>
. <Method>
=> <Time>
( ). - .
- .
, , , . ? ? :
class GraphController implements ILockTarget { private View: IView; public GetControllerView(): IView { return this.View; } @Lock private async RunBigDataCalculations(): Promise<void> { await Start(); await UpdateSmth(); await End(); await CleanUp(); } @LockQueue private async OnChangeNodeState(node: INode): Promise<void> { await GetNodeData(node); await UpdateNode(node); } }
:
RunBigDataCalculations
.await Start();
- / ( )
await Start();
, await UpdateSmth();
.
:
RunBigDataCalculations
.OnChangeNodeState
, (.. ).await GetNodeData(node);
- / ( )
await GetNodeData(node);
, await UpdateNode(node);
.
- . :
export interface IQueuedDisposableLockTarget extends ILockTarget { IsDisposing(): boolean; SetDisposing(): void; }
function QueuedDispose(controller: IQueuedDisposableLockTarget): void {
, . QueuedDispose
:
- . .
QueuedDispose
controller
. — ExtJS .
, , .. . , ? , .
, :
vk.com
Telegram