Diseño de aplicaciones escalables con Elixir: del proyecto general al sistema distribuido

Las abstracciones de Elixir / Erlang OTP obligan a los desarrolladores a dividir los programas en partes independientes. Mientras que "gen_servers" encapsulan partes de la lógica de negocios a nivel micro, las "aplicaciones" presentan una parte más general ("servicio") del sistema. Los programas complejos escritos en Elixir son siempre una colección de aplicaciones OTP comunicantes.

La pregunta principal que apareció al desarrollar dichos programas es cómo dividir el sistema complejo en partes separadas. Pero el problema más importante es cómo organizar la comunicación entre ellos.

En el artículo, compartiría los principios de diseño que sigo al crear un proyecto de Elixir más o menos complejo. Discutiremos cómo dividir el proyecto en pequeños microservicios mantenibles (aplicaciones Elixir) y cómo organizar los módulos dentro de ellos utilizando "contextos".

Pero el enfoque principal estará en el diseño de interfaces flexibles entre aplicaciones Elixir. Verá cómo se pueden cambiar mientras se escala de un proyecto general simple a un sistema distribuido. Cubriré algunos enfoques: llamada a procedimiento remoto de Erlang, tareas distribuidas y protocolo HTTP. Y, como beneficio adicional, mostraré cómo se puede limitar el acceso simultáneo a microservicios.

Proyecto paraguas

Proyecto paraguas

Con el "proyecto paraguas" de Elixir, se puede dividir la lógica compleja en partes separadas al comienzo del proceso de desarrollo. Pero al mismo tiempo, permite mantener toda la lógica en un repositorio. Para que pueda comenzar a desarrollar microservicios futuros con un dolor de cabeza mínimo.

He preparado un proyecto de demostración de andamio para demostrar ejemplos de código real. El nombre del proyecto es "ml_tools", que indica "Herramientas de aprendizaje automático". El proyecto permite a los usuarios aplicar diferentes modelos predictivos a sus conjuntos de datos y elegir el mejor. Los usuarios deberían poder aplicar diferentes algoritmos a sus conjuntos de datos y visualizar los resultados.

La división del proyecto en varias aplicaciones es bastante obvia a partir de los requisitos:

  • conjuntos de datos: aplicación responsable de la gestión de datos: crear, leer y actualizar conjuntos de datos.
  • utils: un conjunto de diferentes servicios de utilidad que preprocesan y visualizan datos.
  • modelos: un servicio que implementa diferentes algoritmos para el modelado predictivo. "Modelo lineal", "bosque aleatorio", "máquina de vectores de soporte", etc.
  • main: aplicación de nivel superior que utiliza otras aplicaciones y expone API de nivel superior.

Cada aplicación se inicia bajo su propio supervisor, por lo tanto, actúa como un servicio independiente.

- - estructura del proyecto - -

aplicaciones /
  conjuntos de datos /
    lib /
      conjuntos de datos /
        buscadores /
          fetchers.ex
          aws.ex
          kaggle.ex
        colecciones /
          ...
        interfaces /
          fetchers.ex
          colecciones.ex
  modelos /
  utils /
  principal/
...

Habiendo dividido la responsabilidad de nivel superior en varias partes, ahora exploremos cada servicio en detalle. Dentro de cada aplicación, necesitamos dividir el código en módulos o conjuntos de módulos. Prefiero definir módulos de alto nivel basados ​​en contextos que están presentes en una aplicación específica.

Por ejemplo, la aplicación de conjuntos de datos es responsable de almacenar colecciones de datos en su propia base de datos y también de buscar datos de diferentes fuentes. Por lo tanto, la aplicación tendrá dos carpetas en el directorio lib / datasets: "colecciones" y "buscadores". Cada carpeta tiene un archivo .ex con el mismo nombre que contiene un módulo que implementa la interfaz de contexto y otros módulos de utilidad.

Echa un vistazo a lib / datasets / fetchers. La carpeta tiene un módulo Datasets.Fetchers que implementa una interfaz para el contexto “fetchers”, funciones que devuelven datos de “AWS Public Datasets” y “Kaggle Datasets”. Entonces, además de este módulo, hay Datasets.Fetchers.Aws y Datasets.Fetchers.Kaggle que implementarán el acceso a la fuente específica.

La misma división relacionada con el contexto puede implementarse en otras aplicaciones. los modelos se dividen por un algoritmo específico: Models.Lm (modelo lineal) o Models.Rf (bosque aleatorio). utils implementa el procesamiento previo de datos (Utils.PreProcessing) y la visualización (Utils.Visualization).

Y, por supuesto, hay una aplicación de nivel superior (Principal) que utiliza todos los microservicios. Esta aplicación también tiene varios contextos: módulo Main.Zillow para el código relacionado con la competencia de Zillow y Main.Screening module para Passenger Screening Algorithm Challenge.

La aplicación Main tiene otra aplicación como dependencias en Main.Mixfile:

defp deps hacer
  [
    {: conjuntos de datos, en_umbrella: verdadero},
    {: modelos, in_umbrella: true},
    {: utils, in_umbrella: true}
  ]
fin

Lo que hace que los módulos de diferentes aplicaciones estén disponibles dentro de la aplicación principal.

Entonces, en general, hay tres niveles de organización del código en el proyecto Elixir:

  • "Nivel de servicio": la forma más obvia de dividir el complejo sistema en aplicaciones separadas de Elixir (conjuntos de datos, modelos, utilidades).
  • "Nivel de contexto": rompe la responsabilidad dentro de un servicio particular mediante la implementación de "módulos de contexto" (conjuntos de datos, recopiladores, conjuntos de datos, colecciones).
  • “Nivel de implementación”: módulos particulares que definen estructuras y funciones de datos (Datasets.Fetchers.Aws, Datasets.Fetchers.Kaggle)

Proyecto paraguas pros y contras

Como se mencionó anteriormente, la principal ventaja de usar el "proyecto paraguas" es que tiene todo el código en un solo lugar y puede ejecutarlo en conjunto en el entorno de desarrollo y prueba. Puede jugar con todo el sistema y, lo más importante, escribir pruebas de integración que probarán los componentes por completo. ¡Esto es muy importante en la etapa inicial del desarrollo del proyecto!

Al mismo tiempo, su proyecto ya está dividido en partes relativamente independientes y listo para escalar.

Compare esto con un enfoque en muchos otros lenguajes de programación donde generalmente comienza desde un proyecto monolítico y luego intenta extraer algunas partes para separar la aplicación. Porque comenzar desde un enfoque de microservicio complica enormemente el proceso de desarrollo.

¡Pero es hora de comenzar a preocuparse por la encapsulación!

Es posible que haya notado que la idea de incluir todas las aplicaciones en las dependencias principales de la aplicación no es tan buena. ¡Y tienes razón!

El lenguaje elixir no tiene suficientes construcciones para una encapsulación adecuada. Solo hay módulos y funciones (públicas y privadas). Si agrega otro proyecto como dependencia, todos los módulos estarán disponibles para usted, por lo que puede llamar a cualquier función pública. Y una implementación ingenua del ajuste de datos de Zillow en la aplicación principal se verá así:

defmodule Main.Zillow do
  def rf_fit do
    Datasets.Fetchers.zillow_data
    |> Utils.PreProcessing.normalize_data
    |> Models.Rf.fit_model
  fin
fin

Donde Datasets.Fetchers, Utils.PreProcessing y Models.Rf son módulos de diferentes aplicaciones. ¡Esta libertad de uso irreflexivo de módulos de otra aplicación combinará sus servicios y convertirá el sistema en un monolito!

Entonces, hay dos lados. Todavía queremos que todas las partes del proyecto sean accesibles durante el desarrollo y la prueba. Pero de alguna manera necesitamos prohibir el acoplamiento de aplicaciones cruzadas.

La única forma de hacerlo es creando convenciones sobre qué funciones de una aplicación se pueden usar en otra. Y la mejor manera es extraer todas las funciones "públicas" en módulos separados de "interfaces".

Módulos de interfaz

Interfaces

La idea es mover todas las funciones de la aplicación "pública" (funciones que pueden ser llamadas por otras aplicaciones) en módulos separados. Por ejemplo, la aplicación de conjuntos de datos tiene un módulo especial de "interfaz" para las funciones de Fetchers:

Defmodule Datasets.Interfaces.Fetchers do
  conjuntos de datos de alias.

  defdelegate zillow_data, a: Fetchers
  defdelegate landsat_data, a: Recogedores
fin

En esta implementación simple, el módulo de interfaz simplemente delega llamadas de función al módulo correspondiente. Pero, en el futuro, cuando hayamos decidido extraer la aplicación de conjuntos de datos de ejecución en otro nodo, este módulo tendrá la parte principal de la lógica de comunicación.

Al hacerlo con otra aplicación, podemos reescribir el módulo Main.Zillow:

def rf_fit do
  Datasets.Interfaces.Fetchers.zillow_data
  |> Utils.Interfaces.PreProcessing.normalize_data
  |> Modelos.Interfaces.Rf.fit_model
fin

En términos generales, la convención es: si desea llamar a una función desde otra aplicación, debe hacerlo a través del módulo "interfaz".

¡Este enfoque todavía permite un desarrollo y una prueba sencillos, pero crea un conjunto de reglas simples que protegen el código del acoplamiento estrecho y crean una base para el escalado futuro!

Escalar a sistema distribuido

Aplicaciones de interfaz

Imagine que el procesamiento de datos lleva mucho tiempo, por lo que decidimos ejecutar modelos en un nodo separado. Por lo tanto, debemos eliminar la dependencia {: models, in_umbrella: true} y ejecutar esa aplicación en otro nodo.

Si ejecuta la consola Elixir (iex -S mix) desde la carpeta principal de la aplicación, ya no tendrá acceso a los módulos de la aplicación de modelos:

iex (1)> Modelos.Interfaces.Rf.fit_model ("datos")
** La función (UndefinedFunctionError) Models.Interfaces.Rf.fit_model / 1 no está definida (el módulo Models.Interfaces.Rf no está disponible)

La aplicación de código de modelos todavía está dentro del proyecto general, pero no se ejecuta con la aplicación principal, por lo que no es accesible. Los módulos y funciones de los modelos solo existen en otro nodo que ejecuta esta aplicación solamente.

Pero, ya sabes, BEAM VM está diseñado para las aplicaciones distribuidas, por lo que hay muchas formas de acceder al código que se ejecuta en otra máquina.

: rpc

Es fácil ejecutar una función en un nodo remoto utilizando Erlang: módulo rpc. : rpc usa el Protocolo de distribución de Erlang para la comunicación entre nodos.

Se puede reproducir un experimento simple: ejecute el proyecto principal con la opción principal --sname en una pestaña de terminal

iex --sname main -S mix

y proyecto de modelos en otra pestaña:

iex --sname modelos -S mix

Ahora puedes ejecutar cálculos:

iex (main @ ip-192–168–1–150) 1>: rpc.call (: ”models @ ip-192–168–1–150", Models.Interfaces.Rf,: fit_model, ["datos"] )
% {__ struct__: Models.Rf.Coefficient, a: 1, b: 2, data: “data”}

Entonces, ¿qué cambios debemos hacer en nuestro proyecto para utilizar este enfoque?

La idea es muy simple, necesitamos agregar una aplicación más a nuestro proyecto que implemente la lógica de comunicación: modelos_interfaz.

interfaz_modelos /
  config /
  lib /
    interfaz_modelos /
      models_interface.ex
        lm.ex
        rf.ex
    mix.ex

Esta es una capa muy delgada que ayuda a las principales a acceder a los modelos. Funciones de interfaz. Hay un par de pequeños módulos que simplemente duplican funciones de los módulos de Interfaces:

defmodule ModelsInterface.Rf do
  def fit_model (datos) hacer
    ModelsInterface.remote_call (Models.Interfaces.Rf,: fit_model, [datos])
  fin
fin

Este módulo solo llama a la función Models.Interfaces.Rf.fit_model / 1. La implementación de remote_call está en el módulo ModelsInterface:

desfmodule ModelsInterface do
  def remote_call (module, fun, args, env \\ Mix.env) do
    do_remote_call ({módulo, diversión, argumentos}, env)
  fin

  def remote_node do
    Application.get_env (: models_interface,: node)
  fin

  defp do_remote_call ({module, fun, args},: test) do
    aplicar (módulo, diversión, argumentos)
  fin
  
  defp do_remote_call ({module, fun, args}, _) do
    : rpc.call (remote_node (), módulo, diversión, args)
  fin
fin

El módulo obtiene la ubicación del nodo de la configuración y realiza una llamada a procedimiento remoto. Es posible que vea la implementación específica del entorno de do_remote_call, esto permite simplificar el proceso de prueba, lo discutiremos más adelante.

La próxima refactorización rápida: simplemente reemplace Models.Interfaces con ModelsInterface y ¡listo! Simplemente no olvide agregar la aplicación models_interface a las dependencias de la aplicación principal.

defp deps hacer
  [
    {: conjuntos de datos, en_umbrella: verdadero},
    {: modelos, en_umbrella: verdadero, solo: [: prueba]},
    {: models_interface, in_umbrella: true},
    {: utils, in_umbrella: true},
    {: espec, "1.4.6", solo:: prueba}
  ]
fin

Nuevamente, dejé la dependencia de los modelos, pero solo en el entorno de prueba. Esto permite realizar llamadas directas a la aplicación en un entorno de prueba.

Eso es. No, podemos acceder a los modelos a través de la consola iex:

iex (main @ ip-192–168–1–150) 1> ModelsInterface.Rf.fit_model (“datos”)
% {__ struct__: Models.Rf.Coefficient, a: 1, b: 2, data: “data”}

¡Resumamos! El único cambio que hicimos es una nueva aplicación de interfaz simple. ¡Todavía tenemos todo el código en un solo lugar y todavía tenemos todas las pruebas aprobadas!

Tareas distribuidas

Las llamadas directas a procedimientos remotos son útiles si necesita una interfaz síncrona simple con otra aplicación. Pero si desea ejecutar de manera efectiva el código asincrónico en el nodo remoto, es mejor que elija tareas distribuidas.

Elixir tiene una tarea específica. Supervisor que se puede utilizar para supervisar dinámicamente las tareas. Este supervisor comenzará dentro de la aplicación remota y supervisará las tareas que ejecutan el código. ¡Usemos tareas distribuidas para acceder a la aplicación de conjuntos de datos!

En primer lugar, debemos agregar Task.Supervisor a los hijos del supervisor de la aplicación de conjuntos de datos:

Defmodule Datasets.Application do
  @moduledoc falso

  utilizar la aplicación
  Supervisor de importación.

  def start (_type, _args) do
    niños = [
      supervisor (Supervisor de tareas,
        [[nombre: Datasets.Task.Supervisor]],
        [reiniciar:: temporal, apagado: 10000])
    ]

    opts = [estrategia:: one_for_one, nombre: Datasets.Supervisor]
    Supervisor.start_link (hijos, opta)
  fin
fin

El módulo DatasetsInterface (que es la aplicación de interfaz separada):

Defmodule DatasetsInterface do
  def spawn_task (module, fun, args, env \\ Mix.env) do
    do_spawn_task ({module, fun, args}, env)
  fin

  defp do_spawn_task ({module, fun, args},: test) do
    aplicar (módulo, diversión, argumentos)
  fin

  defp do_spawn_task ({module, fun, args}, _) do
    Task.Supervisor.async (remote_supervisor (), module, fun, args)
    |> Task.await
  fin

  defp remote_supervisor do
    {
      Application.get_env (: datasets_interface,: task_supervisor),
      Application.get_env (: datasets_interface,: node)
    }
  fin
fin

Entonces usamos el patrón asíncrono / espera aquí. La diferencia es: las tareas se generan en el nodo remoto y son supervisadas por un supervisor remoto. El nombre y la ubicación del supervisor se establecen en el archivo de configuración:

config: datasets_interface,
       task_supervisor: Datasets.Task.Supervisor,
       nodo:: "modelos @ ip-192-168-1-150"

Y, de nuevo, ¡existe el mismo truco con el entorno de prueba!

Otros protocolos

Las tareas RPC y distribuidas son abstracciones incorporadas de Erlang / Elixir que permiten comunicarse utilizando el término Elixir sin ninguna serialización y deserialización adicionales. Pero si necesita comunicarse con aplicaciones que no están escritas en Elixir, necesita un enfoque más común, como el protocolo HTTP.

Como ejemplo, implementemos una interfaz HTTP simple para nuestra aplicación de utilidades. Nuevamente, lo primero que necesitamos es una nueva aplicación utils_interface:

El módulo UtilsInterface tiene la estructura similar con ModelsInterface pero el do_remote_call / 2 se ve así:

defp do_remote_call ({module, fun, args}, _) do
  {: ok, resp} = HTTPoison.post (remote_url (),
                               serializar ({module, fun, args}))
  deserializar (resp. cuerpo)
fin

Para este ejemplo, he usado la serialización simple Erlang term_to_binary y binary_to_term:

defp serialize (term), do:: erlang.term_to_binary (term)
defp deserialize (datos), hacer:: erlang.binary_to_term (datos)

El proyecto de utilidades necesita un servidor HTTP para escuchar las solicitudes externas. He usado vaquero con enchufe para esto

defp deps hacer
  [
    {: vaquero, "~> 1.0.0"},
    {: plug, "~> 1.0"},
    {: espec, "1.4.6", solo:: prueba}
  ]
fin

El módulo de enchufe que es responsable de manejar las solicitudes:

defmodule Utils.Interfaces.Plug do
  use Plug.Router

  enchufe: partido
  enchufe: despacho

  publicar "/ remoto" hacer
    {: ok, body, conn} = Plug.Conn.read_body (conn)
    {module, fun, args} = deserializar (cuerpo)
    resultado = aplicar (módulo, diversión, argumentos)
    send_resp (conn, 200, serializar (resultado))
  fin
fin

Simplemente deserializa la tupla {module, fun, args}, llama a la función y envía un resultado al cliente.

Y no se olvide de iniciar el "enchufe" a través del servidor cowboy en la aplicación de utilidades

niños = [
  Plug.Adapters.Cowboy.child_spec (: http,
       Utils.Interfaces.Plug, [], [puerto: 4001])
]

Tenga en cuenta que no es una buena práctica llamar funciones directamente desde datos deserializados. Lo hice solo para simplificar el ejemplo. ¡En el mundo real, necesitas un enfoque más sofisticado!

Limitando la concurrencia con poolboy

La última característica que quiero describir en la publicación le permite proteger su aplicación y sus recursos del "desbordamiento". Imagine, por ejemplo, que la aplicación de modelos usa bastante memoria para el ajuste del modelo. Por lo tanto, queremos limitar el número de clientes que desean acceder a la aplicación de modelos. Para hacer esto, crearemos un grupo limitado de procesos de trabajo en el nivel de interfaz usando la biblioteca poolboy.

El supervisor de aplicaciones debe iniciar poolboy:

defmodule Models.Aplicación do
  utilizar la aplicación

  def start (_type, _args) do
    pool_options = [
      nombre: {: local, Models.Interface},
      worker_module: Models.Interfaces.Worker,
      tamaño: 5, max_overflow: 5]

    niños = [
      : poolboy.child_spec (Models.Interface, pool_options, []),
    ]

    opts = [estrategia:: one_for_one, nombre: Models.Supervisor]
    Supervisor.start_link (hijos, opta)
  fin
fin

Puede ver las opciones de poolboy aquí: nombre del supervisor, módulo de trabajo, tamaño de un grupo y max_overflow.

El módulo de trabajo es un GenServer simple que solo llama a la función correspondiente:

defmodule Models.Interfaces.Worker do
  usar GenServer

  def start_link (_opts) hacer
    GenServer.start_link (__ MODULE__,: ok, [])
  fin

  def init (: ok), hacer: {: ok,% {}}

  def handle_call ({module, fun, args}, _from, state) do
    resultado = aplicar (módulo, diversión, argumentos)
    {: respuesta, resultado, estado}
  fin
fin

Y el último cambio está en el módulo Models.Interfaces.Rf. En lugar de la delegación de funciones, generará el proceso de trabajo dentro del grupo:

defmodule Models.Interfaces.Rf do
  def fit_model (datos) hacer
    with_poolboy ({Models.Rf,: fit_model, [datos]})
  fin

  def with_poolboy (args) hacer
    trabajador =: poolboy.checkout (Models.Interface)
    resultado = GenServer.call (trabajador, argumentos,: infinito)
    : poolboy.checkin (Modelos.Interfaz, trabajador)
    resultado
  fin
fin

¡Eso es! Ahora está absolutamente seguro de que la aplicación de modelos puede manejar la única cantidad limitada de solicitudes.

Conclusión

Como conclusión, quiero darte algunas recomendaciones:

  • Comience con microservicios desde el principio. Es muy fácil de hacer con el proyecto paraguas Elixir.
  • Utilice los módulos de "contexto" e "implementación" para organizar la lógica dentro de una aplicación.
  • Piense detenidamente en las interfaces de la aplicación. No permita llamadas directas a funciones de implementación entre aplicaciones.
  • Al escalar a un sistema distribuido, coloque la lógica de "comunicación" en la aplicación separada. Utilice el protocolo de distribución Erlang para la comunicación entre aplicaciones BEAM

Espero que los enfoques y abstracciones descritos en el artículo te ayuden a escribir un mejor código con Elixir.

Pulse el si le gustó el artículo y no dude en ponerse en contacto conmigo si tiene preguntas o propuestas.

Ten una maravillosa semana,
Anton