Como criar uma lista ordenada de eventos não relacionados

Dec 16 2020

Este é um exemplo meio inventado, mas acho que ilustra melhor minha pergunta: digamos que estou criando uma API de evento de replay de xadrez. Digamos que eu tenha muitos "eventos" diferentes que desejo acompanhar, em uma ordem especificada. Aqui podem estar alguns exemplos:

  1. Um evento de movimentação - contém o quadrado previouse new.

  2. Um evento de cronômetro - contém timestampque o cronômetro foi alternado entre os jogadores

  3. Um evento de mensagem de bate-papo - contém o player ID, o messageetime sent

... etc. O ponto é que o modelo de dados para cada evento é muito diferente - não há uma interface muito comum.

Quero projetar uma API que pode armazenar e expor essencialmente um List<Event>para um cliente que pode escolher processar esses eventos diferentes como desejar. Não sabemos o que os clientes farão com essas informações: talvez um cliente precise fazer uma análise de texto no ChatMessageEvents, e outro possa consumir e reproduzir esses eventos na IU. O desafio é que a ordem entre os eventos deve ser preservada, então não posso separar por métodos como getMoveEventse getTimerEventsjá que um TimerEventpode acontecer entre os eventos de movimentação e o cliente pode precisar dessas informações.

Eu poderia expor um visitante para permitir que os clientes tratassem cada tipo de evento de maneira diferente na lista, mas estou me perguntando se há uma maneira melhor de lidar com uma situação como essa.

Edit: Eu quero projetar isso com uma prioridade principal: fornecer aos clientes uma maneira fácil e flexível de iterar por meio desses eventos. Em um cenário ideal, eu imaginaria o usuário final escrevendo manipuladores para os tipos de eventos de seu interesse e, então, ser capaz de iterar sem lançar com base no tipo de tempo de execução.

Respostas

36 DocBrown Dec 16 2020 at 14:20

Tenho a forte impressão de que você está pensando demais nisso.

O desafio é que a ordem entre os eventos deve ser preservada, então não posso separar por métodos como getMoveEvents e getTimerEvents

Então, simplesmente não ofereça tais métodos em sua API. Deixe o cliente filtrar os eventos de que precisa e não implemente nada em sua API que possa se tornar sujeito a erros.

Eu poderia expor um visitante para permitir que os clientes tratassem cada tipo de evento de maneira diferente na lista

Isso soa sobredimensionado. Você descreveu o requisito como obter algo como um List<Event>, contendo eventos registrados. Para isso, um método simples List<Event> getEvents()seria totalmente suficiente (talvez um IEnumerable<Event>bastasse). Por razões de eficiência, pode ser necessário oferecer alguns métodos para restringir o conjunto de resultados a certas condições.

mas estou me perguntando se existe uma maneira melhor de lidar com uma situação como esta

Pedir uma abordagem "melhor" (ou "melhor" ou "correta") é muito inespecífico quando você não conhece nenhum critério para o que realmente entende por "melhor". Mas como encontrar critérios para o que é "melhor"? A única maneira confiável que conheço para resolver esse problema é:

Defina alguns casos de uso típicos para sua API!

  • Faça isso em código. Escreva uma função curta que tenta usar sua API, resolvendo um problema real que você sabe com certeza que os clientes irão encontrar (mesmo que a API não exista ou ainda não esteja implementada).

    Pode ser que o cliente precise de algo como uma propriedade para distinguir os tipos de eventos. Pode acontecer que o cliente precise de algo para obter apenas os eventos da última hora ou dos últimos 100 eventos, já que fornecer a ele sempre uma cópia completa de todos os eventos anteriores pode não ser eficiente o suficiente. Pode ser que o cliente precise receber uma notificação sempre que um novo evento for criado.

    Você só poderá decidir isso quando desenvolver uma ideia clara do contexto em que sua API será usada.

  • Se você adicionar algum código a esta função que verifica o resultado da API e colocar esse código em um contexto de uma estrutura de teste de unidade, você estará fazendo "Desenvolvimento Orientado a Testes"

  • Mas mesmo se você não quiser usar TDD ou não gostar de TDD, é melhor abordar isso da perspectiva do cliente .

  • Não adicione nada à sua API onde você tenha dúvidas se algum dia haverá um caso de uso para. As chances são altas de que ninguém jamais precisará desse tipo de função.

Se você não souber o suficiente sobre os casos de uso da API para usar essa abordagem, provavelmente fará mais algumas análises de requisitos primeiro - e isso é algo que não podemos fazer por você.

Deixe-me escrever algo para sua edição final, onde você escreveu

e, então, ser capaz de iterar sem lançar com base no tipo de tempo de execução.

A conversão com base no tipo de tempo de execução não é necessariamente um problema. Torna-se apenas um problema quando torna as extensões da Eventhierarquia de classes mais difíceis, porque o código do cliente existente seria forçado a mudar a cada extensão.

Por exemplo, digamos que haja um código de cliente manipulando todos os eventos de chat por um teste de tipo mais um elenco para ChatEvent. Se um novo tipo de evento for adicionado que não seja um evento de bate-papo, o código existente ainda funcionará. Se um novo evento semelhante a um bate-papo for adicionado, como uma derivação de ChatEvent, o código existente também funcionará, desde que o ChatEventtipo esteja em conformidade com o LSP. Para eventos de bate-papo específicos, o polimorfismo pode ser usado dentro da ChatEventparte da árvore de herança.

Portanto, em vez de evitar testes de tipo e projeções supersticiosamente sob todas as circunstâncias, porque você leu em um livro "isso é geralmente ruim", reflita por que e quando isso realmente causa problemas . E como escrevi acima, escrever alguns códigos de cliente para alguns casos de uso reais ajudará você a obter um melhor entendimento disso. Isso permitirá que você também valide o que acontecerá quando sua lista de eventos for estendida posteriormente.

7 RobertBräutigam Dec 16 2020 at 16:33

Em vez de se concentrar nos dados, tente pensar mais sobre o que eles devem fazer .

Então, um Eventdeve registrar algo que acontece no jogo. Eu imagino que a única coisa que você realmente deseja de um Eventé reproduzi-lo (eu sei que você tem outros casos de uso, apenas me escute :). Isso significaria algo como:

public interface Event {
   void replayOn(Game game);
}

Observe, você pode "preservar a ordem", porque você não precisa saber o tipo exato de evento que está tentando reproduzir. Você não precisa ter um enum ou quaisquer outras "propriedades" para distinguir entre diferentes tipos de eventos. Esses seriam antipadrões de qualquer maneira.

No entanto, você ainda precisa definir o Game. É aqui que você descreve coisas que podem acontecer em sua definição de um jogo de xadrez:

public interface Game {
   void move(...);
   void toggleClock(...);
   void sendText(...);
}

Agora, se você deseja analisar os chats, deve fazer uma implementação da Gameinterface que ignora todos os métodos, exceto sendText()por exemplo, e permitir que todos os eventos sejam reproduzidos nesta implementação. Se você deseja reproduzir na IU, crie uma implementação de um Gamepara isso. E assim por diante.

Observe também que, neste cenário, você não precisa expor uma List<Event>estrutura, apenas um Event. Um Eventpode conter vários eventos "atômicos" se quiser, uma vez que é definido apenas em termos do que faz, não do que contém.

Portanto, por exemplo, este é um evento válido:

public final class Events implements Event {
   private final List<Event> events;
   ...
   @Override
   public void replayOn(Game game) {
      events.forEach(event -> event.replayOn(game));
   }
}

Quanto a que "padrão" é esse, realmente não importa. Alguém poderia argumentar que é uma forma de fonte de eventos, uma vez que o estado do jogo é construído a partir de transições de estado. Ele também está quase fazendo despacho duplo / visitantes, exceto que não está usando tipos para fazer a segunda etapa, mas métodos reais relevantes para o domínio.

No entanto, é certamente orientado a objetos, porque em nenhum momento os dados são retirados de um objeto.

4 Flater Dec 16 2020 at 18:25

Concordo com a resposta postada de que você está exagerando na engenharia de sua abordagem. Além disso, existem várias opções aqui, e você foi bastante leve em detalhes e considerações que ajudariam a decidir entre essas opções.

Mas, por acaso, trabalhei em um problema semelhante não muito tempo atrás, então queria dar a você um exemplo do mundo real de como seu problema pode ser resolvido.


Processo interno

No nosso caso, estávamos retornando uma série de eventos de todos os tipos (criado pelo usuário, atualizado pelo usuário, ...) mas deveria ser uma lista única, sem filtros específicos (além da paginação).

Como havia uma miríade de tipos de eventos e, devido a considerações, eles eram mantidos o mínimo possível, optamos por serializar os dados do evento e armazená-los dessa forma. Isso significa que nosso armazenamento de dados não precisava ser atualizado toda vez que um novo evento era desenvolvido.

Um exemplo rápido. Estes foram os eventos capturados:

public class UserCreated
{
    public Guid UserId { get; set; }
}

public class UserDeleted
{
    public Guid UserId { get; set; }
}

Observe que nossos eventos foram mantidos mínimos. Você acabaria com mais dados aqui, mas o princípio permanece o mesmo.

E em vez de armazená-los diretamente em uma tabela, armazenamos seus dados serializados em uma tabela:

public class StoredEvent
{
    public Guid Id { get; set; }
    public DateTime Timestamp { get; set; }
    public string EventType { get; set; }
    public string EventData { get; set; }
}

EventTypecontinha o nome do tipo (por exemplo MyApp.Domain.Events.UserCreated), EventDatacontinha o JSON serializado (por exemplo { "id" : "1c8e816f-6126-4ceb-82b1-fa66e237500b" }).

Isso significa que não precisaríamos atualizar nosso armazenamento de dados para cada tipo de evento adicionado, em vez de podermos reutilizar o mesmo armazenamento de dados para todos os eventos, já que eles faziam parte de uma única fila de qualquer maneira.

Como esses eventos não precisam ser filtrados (o que também é um de seus requisitos), isso significa que nossa API nunca teve que desserializar os dados para interpretá-los. Em vez disso, nossa API simplesmente retornou os StoredEventdados (bem, um DTO, mas com as mesmas propriedades) para o consumidor.


Isso conclui como o back-end foi configurado e responde diretamente à pergunta que você está fazendo aqui.

Em suma, ao retornar duas propriedades (ou seja, os dados de evento serializados e o tipo específico de evento), você pode retornar uma grande variação de tipos de eventos em uma única lista, sem a necessidade de atualizar esta lógica sempre que um novo tipo de evento for adicionado. É preparado para o futuro e amigável com OCP.

A próxima parte concentra-se no exemplo particular de como escolhemos consumir esse feed em nossos aplicativos de consumidor. Isso pode ou não corresponder às suas expectativas - é apenas um exemplo do que você pode fazer com isso.

Como você projeta seus consumidores depende de você. Mas o design de back-end discutido aqui seria compatível com a maioria, senão com todas as maneiras de projetar seus consumidores.


A parte dianteira

Em nosso caso, o consumidor seria outro aplicativo C #, então desenvolvemos uma biblioteca cliente que consumiria nossa API e desserializaria os eventos armazenados de volta em suas respectivas classes de eventos.

O consumidor seria instalar um pacote Nuget disponibilizamos, que continha as classes de eventos ( UserCreated, UserDeleted...) e uma interface ( IHandler<TEventType>) que o consumidor usaria para definir como cada evento precisava ser tratada.

Internamente, o pacote também contém um serviço de eventos. Este serviço faria três coisas:

  1. Consulte a API REST para buscar os eventos
  2. Converta os eventos armazenados de volta em suas classes individuais
  3. Envie cada um desses eventos para seu manipulador registrado

A etapa 1 nada mais é do que uma chamada HTTP Get para nosso endpoint.

A etapa 2 é surpreendentemente simples, quando você tem o tipo e os dados:

var originalEvent = JsonConvert.DeserializeObject(storedEvent.EventData, storedEvent.EventType);

A etapa 3 dependia do consumidor ter definido manipuladores para cada tipo de interesse. Por exemplo:

public class UserEventHandlers : IHandler<UserCreated>, IHandler<UserDeleted>
{
    public void Handle(UserCreated e)
    {
        Console.WriteLine($"User {e.UserId} was created!"); } public void Handle(UserDeleted e) { Console.WriteLine($"User {e.UserId} was deleted!");
    }
}

Se um consumidor não estivesse interessado em um tipo de evento específico, ele simplesmente não criaria um manipulador para esse tipo e, portanto, quaisquer eventos desse tipo seriam efetivamente ignorados.

Isso também manteve as coisas compatíveis com versões anteriores. Se um novo tipo de evento fosse adicionado amanhã, mas esse consumidor não estivesse interessado nele, você poderia manter esse consumidor intocado. Ele não quebraria por causa do novo tipo de evento (ele apenas ignoraria esses novos tipos) e não o forçaria a reimplantar seu aplicativo.

A única causa real para reimplantação seria se uma alteração fosse feita nos tipos de eventos nos quais o consumidor estava realmente interessado, e isso é logicamente inevitável.

1 JTrana Dec 19 2020 at 14:37

Em um cenário ideal, eu imaginaria o usuário final escrevendo manipuladores para os tipos de eventos de seu interesse e, então, ser capaz de iterar sem lançar com base no tipo de tempo de execução.

Posso ter empatia com o sentimento aqui: certamente deve haver alguma outra maneira de fazer isso porque olhar para o tipo é um cheiro de código , certo? Provavelmente, todos nós já vimos código que faz coisas complicadas ao receber um objecte fazer alguma verificação de tipo pobre nele, levando a alguns anti-padrões.

Vejamos outra seção de sua pergunta:

O desafio é que a ordem entre os eventos deve ser preservada, então não posso separar por métodos como getMoveEventse getTimerEventsjá que um TimerEventpode acontecer entre os eventos de movimentação e o cliente pode precisar dessas informações.

Estendendo essa lógica - se estamos olhando para um manipulador verdadeiramente genérico, estamos dizendo que:

  • Ele poderia se preocupar em lidar com qualquer tipo único
  • Ou pode se preocupar em lidar com vários tipos
  • Tipos diferentes podem não ser independentes uns dos outros
  • Precisa de itens de diferentes tipos em ordem intercalada

Basicamente, isso se resume a dizer que não sabemos as interdependências na lógica de processamento, apenas que deve ser ordenado pelo tempo. Isso significa que não podemos escrever manipuladores de tipo único, e se escrevermos algo como "obter todos os itens do tipo A, B e C e despachá-los usando os manipuladores A e B e C", podemos encontrar esse manipulador A e B precisamos trabalhar juntos para fazer o processamento - o que complica enormemente as coisas. Existe algo mais simples, mas ainda flexível?

Bem, como os programadores têm resolvido historicamente esse tipo de problema? Em primeiro lugar, acho que vale a pena apontar que existem muitos termos inter-relacionados que aparecem nos comentários e respostas aqui que apontam basicamente para a mesma solução: "tipos de dados algébricos" e "tipos de soma", e adicionarei um poucos também - "união discriminada" , "união marcada" e "variante". Pode haver algumas diferenças aqui, mas o tema é que todos eles podem se parecer muito com sua descrição Event- eles são subtipos que podem transportar dados, mas devem ser diferentes, digamos, dos mais genéricos object. Outro termo relacionado mencionado é "correspondência de padrões", que se relaciona com a forma como você trabalha com seus sindicatos discriminados.

Como você deve ter adivinhado pelos muitos nomes em uso acima, esse é realmente um problema recorrente; isso tende a ser uma solução recorrente em vários idiomas. Essas construções são normalmente implementadas em um nível de linguagem - ou emuladas quando a linguagem não oferece suporte. Também não é apenas algo do passado distante ou totalmente substituído por outra construção - por exemplo, o C # 8.0 está expandindo a correspondência de padrões do C # 7.0 a partir de 2019 .

Agora, infelizmente, se você não viu isso antes, talvez não goste da aparência desta solução consagrada pelo tempo. Aqui está o exemplo de código C # 7.0 mais antigo do link acima:

Fruit fruit = new Apple { Color = Color.Green };
switch (fruit)
{
  case Apple apple when apple.Color == Color.Green:
    MakeApplePieFrom(apple);
    break;
  case Apple apple when apple.Color == Color.Brown:
    ThrowAway(apple);
    break;
  case Apple apple:
    Eat(apple);
    break;
  case Orange orange:
    orange.Peel();
    break;
}

Ou um exemplo do Swift :

    switch productBarcode {
    case let .upc(numberSystem, manufacturer, product, check):
        print("UPC : \(numberSystem), \(manufacturer), \(product), \(check).")
    case let .qrCode(productCode):
        print("QR code: \(productCode).")
    }
    // Prints "QR code: ABCDEFGHIJKLMNOP."

E se você limpar isso, você pode algo como isso na F #:

let getShapeWidth shape =
    match shape with
    | Rectangle(width = w) -> w
    | Circle(radius = r) -> 2. * r
    | Prism(width = w) -> w

E assim voltamos ao círculo completo, pelo menos se apertarmos um pouco os olhos. A solução amplamente recorrente tem alguma inteligência e açúcar sintático, mas ... parece uma versão mais segura de um caso de switch!

A linguagem em que você está trabalhando tem alguma versão desse conceito?

CortAmmon Dec 17 2020 at 03:55

Considere o uso de números de sequência. No entanto, acho que vale a pena examinar seus requisitos primeiro:

Eu imaginaria o usuário final escrevendo manipuladores para os tipos de evento de seu interesse e, então, ser capaz de iterar sem lançar com base no tipo de tempo de execução.

Isso está diretamente em oposição com

Quero projetar uma API que possa armazenar e expor essencialmente uma lista para um cliente que pode escolher processar esses eventos diferentes como desejar.

Você literalmente não pode fazer as duas coisas. Você pode expor as informações em um formulário digitado ou um formulário genérico. Mas ter uma API que produza em uma forma genérica (ou genérica) não é realmente possível. Ou você apaga a informação ou não.

Como solução, podemos relaxar uma de suas regras

Não consigo separar por métodos como getMoveEvents e getTimerEvents, pois um TimerEvent pode acontecer entre eventos de movimentação e o cliente pode precisar dessas informações.

Considere isso como uma solução: a cada evento no sistema é atribuído um "número de sequência" exclusivo que começa em 1 e conta para cima (gosto de começar em 1 para que 0 possa ser "número de sequência inválido"). Esse número de sequência é armazenado nos Eventobjetos.

Agora você pode ter getMoveEvents(), que retorna uma lista ordenada de todos MoveEvents, e a getTimerEvents(), que retorna uma lista ordenada de todos TimerEvents. Qualquer algoritmo que precise entender a interação entre eventos de diferentes tipos pode examinar o número de sequência. Se eu tiver [Move (seqnum = 1), Move (seqnum = 3)] e [Timer (seqnum = 2)], é bastante fácil ver que a ordem dos eventos era Move, Timer, Move.

A lógica aqui é que seu usuário sabe o tipo de dados que deseja operar (como MoveEvents). É razoável, então, que eles conheçam uma função específica de tipo a ser chamada para obter uma lista.

O usuário pode então mesclar os eventos da maneira que desejar. Como exemplo, considere um algoritmo que analisa MoveEventse TimerEvents, e nada mais. Ele poderia ter uma API como:

enum EventType {
    MOVE,
    TIMER
};
bool        moveNext(); // returns true if there's another event to move to
EventType   getCurrentType();
MoveEvent   getCurrentMoveEvent();  // error if current type is TIMER
TimerEvent  getCurrentTimerEvent(); // error if current type is MOVE

Ele então precisa apenas iterar em cada lista, descobrir qual lista tem o número de sequência de menor número e esse é o próximo evento. Observe que não fiz elenco e a enumeração é específica do algoritmo - um algoritmo diferente pode manter sua própria lista de eventos enumerados a serem considerados.

Se você vir um salto de número de sequência (em mais de 1), então sabe que ocorreram eventos de um tipo que você não está controlando. Cabe ao seu algoritmo decidir se isso é um erro ou se você pode simplesmente ignorar eventos não reconhecidos. Normalmente é bastante óbvio qual.

Se sua Eventclasse tiver algo diferente de um número de sequência, você também pode expor List<Event>com todos os eventos como uma forma de percorrê-los. Sempre se pode encontrar o número de sequência de um evento de interesse e, em seguida, procurá-lo nos eventos digitados que ele conhece. No entanto, se você não expor nenhuma informação adicional, não há necessidade disso List<Event>. Sabemos a ordem em que os números da sequência do evento procedem: 1, 2, 3, 4 ...

Um exemplo de algoritmo que poderia usar este padrão: atribua a cada movimento um intervalo de vezes em que o movimento poderia ter ocorrido. Se você verificar apenas as listas MoveEvente TimerEvent, poderá encontrar os dois TimerEventscujos limites de número de sequência cada um MoveEvent. Como você sabe que os eventos acontecem em ordem numérica de sequência, você sabe que a movimentação deve ter ocorrido entre o carimbo de data / hora no primeiro TimerEvente no segundo.

MichaelThorpe Dec 17 2020 at 07:26

Embora seu código-fonte de exemplo seja fortemente inspirado em Java, o que você está pedindo são sum types , que é um tipo formado por uma união de outros tipos.

Em seu exemplo acima, em uma linguagem como a ferrugem:

struct Move {
    previous: (u8,u8),
    new: (u8,u8)
}

struct GameTimer {
    timestamp: i64,
    new_player_id: i64,
}

struct Message {
    timestamp: i64,
    new_player_id: i64,
    message: String
}

enum Event {
  Move(Move),
  Timer(GameTimer),
  ChatMessage(Message)
}

fn length_all_chats(events: Vec<Event>) -> usize {
    events.iter().fold(0, |sum, event| 
        sum + match event {
            Event::Move(_) => 0,
            Event::Timer(_) => 0,
            Event::ChatMessage(Message{message: msg, ..}) => msg.len(),
        }
    )
}

O length_all_chatsitem acima retorna a soma dos comprimentos de todas as mensagens de bate-papo na lista de eventos.

Se um novo tipo de evento fosse introduzido, os consumidores precisariam atualizar para compilar (ou fornecer um padrão pega-tudo). Esta é uma maneira diferente de implementar polimorfismo de tempo de execução, permitindo padrões mais poderosos, como despacho múltiplo, onde você pode chamar uma função diferente com base nos tipos de dois (ou mais) argumentos.