Introdução à programação reativa

Olá. Neste artigo, galoparei pela Europa, ou seja, direi o que eles querem dizer com programação reativa, apresentarei atores, fluxos reativos e, finalmente, usando fluxos reativos, reconheceremos os gestos do mouse, como na antiga Opera e seu sucessor espiritual - Vivaldi .

O objetivo é introduzir os conceitos básicos de programação reativa e mostrar que nem tudo é tão complicado e assustador quanto parece à primeira vista.

imagem
Fonte

O que é programação reativa?


Para responder a essa pergunta, nos voltamos para o site . Ele tem uma bela imagem que mostra quatro critérios principais que os aplicativos reativos devem atender.

imagem

A aplicação deve ser rápida, tolerante a falhas e dimensionar bem.
Parece que “somos para todos os bons versus todos os ruins”, certo?

O que se entende por estas palavras:

  1. Responsividade

    O aplicativo deve fornecer ao usuário o resultado em meio segundo. Isso também inclui o princípio de falha rápida - ou seja, quando algo der errado, é melhor retornar ao usuário uma mensagem de erro como “Desculpe, houve um problema. Tente novamente mais tarde para fazer o tempo esperar à beira-mar. Se a operação for longa, mostramos ao usuário uma barra de progresso. Se for muito longo - “sua solicitação será atendida provisoriamente em 18 de março de 2042. Enviaremos uma notificação pelo correio ".
  2. A escalabilidade é uma maneira de fornecer capacidade de resposta sob carga. Imagine o ciclo de vida de um serviço relativamente bem-sucedido:
    1. Lançamento - o fluxo de solicitações é pequeno, o serviço é executado em uma máquina virtual com um núcleo.
    2. O fluxo de solicitações aumenta - os kernels são adicionados à máquina virtual e os pedidos são processados ​​em vários threads.
    3. Ainda mais carga - conectamos lotes - solicitações ao banco de dados e ao disco rígido são agrupados.
    4. Ainda mais carga - você precisa aumentar mais servidores e fornecer trabalho no cluster.
      Idealmente, o próprio sistema deve aumentar ou diminuir, dependendo da carga.
  3. Tolerância a falhas

    Aceitamos que vivemos em um mundo imperfeito e tudo acontece. Caso algo dê errado em nosso sistema, devemos fornecer métodos de tratamento e recuperação de erros
  4. E, finalmente, somos convidados a conseguir tudo isso usando um sistema cuja arquitetura é baseada em mensagens orientadas a mensagens

Antes de continuar, quero me concentrar em como os sistemas acionados por eventos diferem dos sistemas acionados por mensagens.

Orientado a eventos:

  • Evento - o sistema relata que atingiu um determinado estado.
  • Pode haver muitos inscritos no evento.
  • A cadeia de eventos geralmente é curta e os manipuladores de eventos estão próximos (fisicamente e em código) da fonte.
  • A fonte de eventos e seus manipuladores geralmente têm um estado comum (fisicamente - eles usam a mesma peça de RAM para troca de informações).

Ao contrário do orientado a eventos, em um sistema orientado a mensagens:

  • Cada mensagem tem apenas um destinatário.
  • As mensagens são imutáveis: você não pode alterar nada na mensagem recebida para que o remetente saiba sobre ela e possa ler as informações.
  • Os elementos do sistema respondem (ou não respondem) ao recebimento de mensagens e podem enviar mensagens para outros elementos do sistema.

Tudo isso nos oferece

Modelo do ator


Marcos do desenvolvimento:

  • A primeira menção aos atores está em um artigo científico de 1973 - Carl Hewitt, Peter Bishop e Richard Steiger, "Um formalismo modular universal do ATOR para inteligência artificial".
  • 1986 - Erlang apareceu. Ericson precisava de uma linguagem para equipamentos de telecomunicações que proporcionasse tolerância a falhas e propagação sem erros. No contexto deste artigo, seus principais recursos são:

    • Tudo é um processo
    • As mensagens são o único meio de comunicação (Erlang é uma linguagem funcional e as mensagens nela são imutáveis).
  • ..
  • 2004 - a primeira versão da linguagem Scala. Suas características:
    • Desenvolvido por JVM,
    • Funcional
    • Para multiencadeamento, um modelo de ator foi selecionado.

  • 2009 - a implementação dos atores foi alocada em uma biblioteca separada - Akka
  • 2014 - Akka.net - foi portado para .Net.

O que os atores podem fazer?


Atores são os mesmos objetos, mas:

  • Diferentemente dos objetos comuns, os atores não podem chamar os métodos um do outro.
  • Os atores podem transmitir informações apenas através de mensagens imutáveis .
  • Após o recebimento da mensagem, o ator pode
    • Crie novos atores (eles serão mais baixos na hierarquia),
    • Envie mensagens para outros atores,
    • Pare os atores abaixo na hierarquia e você mesmo.

Vejamos um exemplo.

imagem

O ator A deseja enviar uma mensagem para o ator B. Tudo o que ele tem é o ActorRef (algum endereço). O ator B pode estar em qualquer lugar.
O ator A envia uma letra B pelo sistema (ActorSystem). O sistema coloca a letra na caixa de correio do ator B e "acorda" o ator B. O ator B pega a carta na caixa de correio e faz alguma coisa.

Comparado a chamar métodos em outro objeto, parece desnecessariamente complicado, mas o modelo de atores se encaixa perfeitamente no mundo real, se você imaginar que os atores são pessoas treinadas para fazer algo em resposta a determinados estímulos.

Imagine um pai e um filho:



O pai envia seu filho SMSku "Clean in the room" e continua a fazer suas próprias coisas. O filho lê SMSku e começa a limpar. Papai, enquanto isso, está jogando pôquer. O filho termina a limpeza e envia o SMS "Concluir". Parece simples, certo?

Agora imagine que pai e filho não são atores, mas objetos comuns que podem puxar os métodos um do outro. O pai puxa o filho para o método "limpar o quarto" e segue-o, esperando até que o filho termine a limpeza e transfira o controle de volta para o pai. Pai não pode jogar poker neste momento. Nesse contexto, o modelo de ator está se tornando mais atraente.

Agora vamos passar para

Akka.NET


Tudo o que está escrito abaixo é verdadeiro para o Akka original para a JVM, mas, para mim, o C # é mais próximo que o Java, então usarei o Akka.NET como exemplo.

Então, quais são os benefícios da Akka?


  • Multithreading através de mensagens. Você não precisa mais sofrer com todos os tipos de bloqueios, semáforos, mutexes e outros encantos característicos do multithreading clássico com memória compartilhada.
  • Comunicação transparente entre o sistema e seus componentes. Não há necessidade de se preocupar com códigos de rede complexos - o próprio sistema encontrará o destino da mensagem e garantirá a entrega da mensagem (aqui você pode inserir uma piada sobre UDP x TCP).
  • Arquitetura flexível que pode aumentar ou diminuir automaticamente. Por exemplo, sob carga, o sistema pode aumentar nós de cluster adicionais e distribuir uniformemente a carga.

Mas o tópico sobre dimensionamento é muito extenso e digno de uma publicação separada. Portanto, explicarei mais detalhadamente apenas sobre o recurso, que será útil em todos os projetos:

Tratamento de erros


Os atores têm uma hierarquia - ela pode ser representada como uma árvore. Cada ator tem um pai e pode ter "filhos".

imagem
Documentação do Akka.NET Copyright 2013-2018 Projeto Akka.NET

Para cada ator, você pode definir uma estratégia de supervisão - o que fazer se algo der errado para as "crianças". Por exemplo, “bata” em um ator com problemas e, em seguida, crie um novo ator do mesmo tipo e confie a ele o mesmo trabalho.

Por exemplo, fiz uma aplicação no Akka.net CRUD, na qual a camada de "lógica de negócios" é implementada nos atores. O objetivo deste projeto era descobrir se os atores devem ser usados ​​em sistemas não escalonáveis ​​- eles melhorarão a vida ou adicionarão mais sofrimento.

Como o tratamento de erros interno da Akka pode ajudar:

Gif


  1. está tudo bem, o aplicativo funciona,
  2. algo aconteceu com o repositório e agora ele fornece o resultado apenas 1 vez em 5,
  3. Defino a estratégia de supervisão para "tentar 10 vezes por segundo",
  4. o aplicativo está funcionando novamente (embora mais lento) e tenho tempo para descobrir qual é o problema.

Há uma tentação de dizer: "Vamos lá, eu vou escrever esse erro ao lidar comigo mesmo, por que alguns atores têm que cometer um erro?" Observação justa, mas apenas se os pontos de falha forem poucos.

E algum código. É assim que a inicialização do sistema de ator no contêiner de IoC se parece:

public Container() { system = ActorSystem.Create("MySystem"); var echo = system.ActorOf<EchoActor>("Echo"); //stop initialization if something is wrong with actor system var alive = echo.Ask<bool>(true, TimeSpan.FromMilliseconds(100)).Result; container = new WindsorContainer(); //search for dependencies //register controllers //register ActorSystem propsResolver = new WindsorDependencyResolver(container, (ActorSystem)system); system.AddDependencyResolver(propsResolver); actorSystemWrapper = new ActorSystemWrapper(system, propsResolver); container.Register(Component.For<IActorRefFactory>().Instance(actorSystemWrapper)); container.Register(Component.For<IDependencyResolver>().Instance(propsResolver)); } 

O EchoActor é o ator mais simples que retorna um valor ao remetente:

  public class EchoActor : ReceiveActor { public EchoActor() { Receive<bool>(flag => { Sender.Tell(flag); }); } } 

Para conectar os atores ao código "regular", o comando Ask é usado:

  public async Task<ActionResult> Index() { ViewBag.Type = typeof(Model); var res = await CrudActorRef.Ask<IEnumerable<Model>>(DataMessage.GetAll<Model>(), maxDelay); return View(res); } 

Total


Rindo com os atores, posso dizer:

  • Olhe para eles se precisar de escalabilidade.
  • Para lógica de negócios complexa, é melhor não usá-las por causa de
    • Injeção de Dependência estranha. Para inicializar um ator com as dependências necessárias, você deve primeiro criar um objeto Props e depois entregá-lo ao ActorSystem para criar um ator do tipo desejado. Para criar adereços usando contêineres IoC (por exemplo, Castle Windsor ou Autofac), existem wrappers prontos - DependencyResolvers. Mas me deparei com o fato de que o contêiner de IoC estava tentando controlar a vida útil da dependência e, depois de um tempo, o sistema caiu silenciosamente.

      * Talvez, em vez de injetar uma dependência em um objeto, você deva colocar essa dependência como ator-filho.
    • problemas de digitação. ActorRef não sabe nada sobre o tipo de ator a que se refere. Ou seja, em tempo de compilação, não se sabe se um ator pode processar uma mensagem desse tipo ou não.

Parte 2: Fluxos de jato


Agora vamos para um tópico mais popular e útil - fluxos de jato. Se você nunca conseguir encontrar atores no processo de trabalho, os fluxos Rx certamente serão úteis tanto no front-end quanto no back-end. Sua implementação está em quase todas as linguagens de programação modernas. Vou dar exemplos de RxJs, porque hoje em dia até os programadores de back-end às vezes precisam fazer algo em JavaScript.


Os fluxos Rx estão disponíveis para todas as linguagens de programação populares.

Introdução à programação reativa que você está perdendo ”, de Andre Staltz , licenciado sob CC BY-NC 4.0

Para explicar o que é o fluxo de jato, começarei com as coleções pull e push.
Valor de retorno únicoVários valores de retorno
Pull
Síncrona
Interativo
TIEnumerable <T>
Push
Assíncrono
Reativo
Tarefa <T>IObservable <T>

As coleções pull são o que estamos acostumados na programação. O exemplo mais impressionante é uma matriz.

 const arr = [1,2,3,4,5]; 

Ele já possui dados, ele próprio não os altera, mas pode fornecê-los mediante solicitação.

 arr.forEach(console.log); 

Além disso, antes de fazer algo com os dados, você pode processá-los de alguma forma.

 arr.map(i => i+1).map(I => “my number is ”+i).forEach(console.log); 

Agora vamos imaginar que, inicialmente, não haja dados na coleção, mas com certeza ele informará que eles apareceram (Push). E, ao mesmo tempo, ainda podemos aplicar as transformações necessárias a essa coleção.

Por exemplo:

 source.map(i => i+1).map(I => “my number is ”+i).forEach(console.log); 

Quando um valor como 1 aparece na fonte, o console.log exibe "meu número é 1".

Como funciona:

Uma nova entidade aparece - Assunto (ou Observável):

 const observable = Rx.Observable.create(function (observer) { observer.next(1); observer.next(2); observer.next(3); setTimeout(() => { observer.next(4); observer.complete(); }, 1000); }); 

Esta é uma coleção de envio que envia notificações sobre alterações em seu estado.

Nesse caso, os números 1, 2 e 3 aparecerão imediatamente nele, em um segundo 4, e a coleção "terminará". Este é um tipo tão especial de evento.

A segunda entidade é Observer. Ele pode se inscrever nos eventos do Assunto e fazer alguma coisa com os dados recebidos. Por exemplo:

 observable.subscribe(x => console.log(x)); observable.subscribe({ next: x => console.log('got value ' + x), error: err => console.error('something wrong occurred: ' + err), complete: () => console.log('done'), }); observable .map(x => 'This is ' + x) .subscribe(x => console.log(x)); 

Pode-se ver que um Assunto pode ter muitos assinantes.

Parece fácil, mas ainda não está claro por que isso é necessário. Darei mais duas definições que você precisa saber ao trabalhar com fluxos reativos e mostrarei na prática como eles funcionam e em quais situações todo o seu potencial é revelado.

Observáveis ​​frios


  • Notificar sobre eventos quando alguém os assinar.
  • Todo o fluxo de dados é enviado novamente para cada assinante, independentemente do horário da assinatura.
  • Os dados são copiados para cada assinante.

O que isso significa: digamos que a empresa (Assunto) decidiu organizar a distribuição de presentes. Cada funcionário (Observador) vem trabalhar e recebe sua cópia do presente. Ninguém permanece privado.

Observáveis ​​quentes


  • Eles tentam notificar o evento, independentemente da presença de assinantes. Se no momento do evento não houvesse assinantes, os dados serão perdidos.

Exemplo: pela manhã, bolos quentes para os funcionários são trazidos para a empresa. Quando são trazidas, todas as cotovias voam para o cheiro e separam as tortas no café da manhã. Mas as corujas que vieram depois não têm mais tortas.

Em que situações os fluxos de jato são usados?


Quando há um fluxo de dados distribuído ao longo do tempo. Por exemplo, entrada do usuário. Ou logs de qualquer serviço. Em um dos projetos, vi um criador de logs auto-criado que coletava eventos em N segundos e depois gravava simultaneamente o pacote inteiro. O código da bateria ocupava a página. Se os fluxos Rx fossem usados, seria muito mais simples:

imagem
Referência / Observável RxJs , documentação licenciada sob CC BY 4.0 .
(existem muitos exemplos e imagens explicando o que várias operações com fluxos reativos fazem)

 source.bufferTime(2000).subsribe(doThings); 

E, finalmente, um exemplo de uso.

Reconhecendo gestos do mouse com fluxos Rx


Na antiga Opera ou em seu sucessor espiritual - Vivaldi - havia um controle de navegador usando gestos do mouse.

Gif - gestos do mouse em Vivaldi


Ou seja, você precisa reconhecer os movimentos do mouse para cima / baixo, direita / esquerda e combinações dos mesmos. Ele pode ser escrito sem fluxos Rx, mas o código será complexo e difícil de manter.

E aqui está o que parece com os fluxos Rx:


Começarei do final - definirei quais dados e em que formato procurarei na sequência original:

 //gestures to look for const gestures = Rx.Observable.from([ { name: "Left", sequence: Rx.Observable.from([{ x: -1, y: 0 }]) }, { name: "Right", sequence: Rx.Observable.from([{ x: 1, y: 0 }]) }, { name: "Up", sequence: Rx.Observable.from([{ x: 0, y: -1 }]) }, { name: "Down", sequence: Rx.Observable.from([{ x: 0, y: 1 }]) }, { name: "Down+Up", sequence: Rx.Observable.from([{ x: 0, y: 1 }, { x: 0, y: -1 }]) }, { name: "Up+Right", sequence: Rx.Observable.from([{ x: 0, y: -1 }, { x: 1, y: 0 }]) } ]); 

Estes são vetores unitários e suas combinações.

Em seguida, você precisa converter os eventos do mouse em fluxos Rx. Todas as bibliotecas Rx possuem ferramentas internas para transformar eventos padrão em Observables.

 const mouseMoves = Rx.Observable.fromEvent(canvas, 'mousemove'), mouseDowns = Rx.Observable.fromEvent(canvas, 'mousedown'), mouseUps = Rx.Observable.fromEvent(canvas, 'mouseup'); 

A seguir, agrupo as coordenadas do mouse por 2 e encontro a diferença, obtendo o deslocamento do mouse.

 const mouseDiffs = mouseMoves .map(getOffset) .pairwise() .map(pair => { return { x: pair[1].x-pair[0].x, y: pair[1].y-pair[0].y } }); 

E agrupe esses movimentos usando os eventos 'mousedown' e 'mouseup'.

 const mouseGestures = mouseDiffs .bufferToggle(mouseDowns, x => mouseUps) .map(concat); 

A função concat corta movimentos muito curtos e agrupa movimentos aproximadamente alinhados na direção.

 function concat(values) {//summarize move in same direction return values.reduce((a, v) => { if (!a.length) { a.push(v); } else { const last = a[a.length - 1]; const lastAngle = Math.atan2(last.x, last.y); const angle = Math.atan2(vx, vy); const angleDiff = normalizeAngle(angle - lastAngle); const dist = Math.hypot(vx, vy); if (dist < 1) return a;//move is too short – ignore //moving in same direction => adding vectors if (Math.abs(angleDiff) <= maxAngleDiff) { last.x += vx; last.y += vy; } else { a.push(v); } } return a; }, []); } 

Se o movimento no eixo X ou Y for muito curto, ele será zerado. E então apenas o sinal permanece das coordenadas de deslocamento obtidas. Assim, os vetores unitários que procurávamos são obtidos.

 const normalizedMouseGestures = mouseGestures.map(arr => arr.map(v => { const dist = Math.hypot(vx, vy);//length of vector vx = Math.abs(vx) > minMove && Math.abs(vx) * treshold > dist ? vx : 0; vy = Math.abs(vy) > minMove && Math.abs(vy) * treshold > dist ? vy : 0; return v; }) ).map(arr => arr .map(v => { return { x: Math.sign(vx), y: Math.sign(vy) }; }) .filter(v => Math.hypot(vx, vy) > 0) ); 

Resultado:

 gestures.map(gesture => normalizedMouseGestures.mergeMap( moves => Rx.Observable.from(moves) .sequenceEqual(gesture.sequence, comparer) ).filter(x => x).mapTo(gesture.name) ).mergeAll().subscribe(gestureName => actions[gestureName]()); 

Usando sequenceEqual, você pode comparar os movimentos recebidos com os originais e, se houver uma correspondência, executar uma determinada ação.

Gif


Você pode brincar com gestos aqui

Observe que, além do reconhecimento de gestos, também há um desenho dos movimentos inicial e normalizado do mouse na tela HTML. A legibilidade do código não sofre com isso.

Das quais mais uma vantagem se segue - a funcionalidade escrita com a ajuda dos fluxos Rx pode ser facilmente complementada e expandida.

Sumário


  • Bibliotecas com fluxos Rx estão disponíveis para quase todas as linguagens de programação.
  • Os fluxos Rx devem ser usados ​​quando houver um fluxo de eventos estendido ao longo do tempo (por exemplo, entrada do usuário).
  • A funcionalidade escrita usando fluxos Rx pode ser facilmente complementada e expandida.
  • Não encontrei falhas significativas.

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


All Articles