Aplicando MVVM en Phoenix LiveView

clsource

Camilo

Posted on February 1, 2024

Aplicando MVVM en Phoenix LiveView

En el siguiente artículo vamos a crear un pequeño cliente de Hacker News utilizando Phoenix Framework y Surface UI (LiveView), aplicando los conceptos de MVVM (Modelo - Vista - Vista Modelo) para el diseño de la arquitectura.

Los conceptos están basados en los artículos de Matteo Manferdini quien se enfoca en la tecnología móvil SwiftUI, la cual es muy similar a las tecnologías declarativas como LiveView.

El patrón MVVM incorpora buenas ideas y algunas dificultades debido a las distintas interpretaciones del mismo. En este artículo veremos sus ventajas y como navegar sus desafíos.

Requisitos

Para poder seguir este artículo recomendamos tener instalado Elixir y configurado un proyecto con SurfaceUI.

Cliente Hacker News

Para tener un ejemplo de MVVM en LiveView, vamos a crear una pequeña aplicación para Hacker News, un portal de noticias para devs. Vamos a utilizar su API para obtener diez noticias desde la sección de mejores historias.

HackerNews

Pueden ver el resultado acá

GitHub logo ElixirCL / surface-hackernews

📰 An example HackerNews client made with Surface

HackerNews

This is an example HackerNews Client using Surface UI y MVVM.

To start your Phoenix server:

  • Run mix setup to install and setup dependencies
  • Start Phoenix endpoint with mix phx.server or inside IEx with iex -S mix phx.server

Now you can visit localhost:4000 from your browser.

Ready to run in production? Please check our deployment guides.

Learn more




¿Patrones MVVM y MVC?

La tecnología actual permite crear aplicaciones complejas con relativa sencillez, lo que ha facilitado algunas personas a utilizar prácticas que dificultan la mantenibilidad y robustez de las soluciones de software.

Para lograr productos de software robustos y fáciles de mantener en el tiempo, se necesita más que juntar piezas de código esparcidas sin un orden cohesivo. Si bien uno puede buscar en Google para resolver tareas específicas, copiando y pegando el código para que funcione de alguna forma. Al momento de salir de lo básico y entrar al terreno profesional, inevitablemente encontraremos dificultades.

Por esta razón la industria ha desarrollado patrones como el MVC y el MVVM.

¿Qué es el patrón MVC?

El patrón Modelo-Vista-Controlador (MVC), es uno de los primeros que deberías aprender. Es tan fundamental que ha sobrevivido décadas en la industria y sus ideas se han esparcido por muchas plataformas. Es el padre de muchos otros patrones derivados como MVVM, entre otros.

MVC

Este patrón es esencial debido a que ayuda a responder una de las preguntas más comunes:

¿Dónde debería poner esta pieza de código?

El patrón MVC es uno de arquitectura. Te entrega un mapa de la estructura de la aplicación y como su nombre dice, consiste en tres capas.

  • Capa modelo (model): Es la capa que maneja los datos y la lógica de negocios, independiente de su representación visual.

  • Capa vista (view): Es la capa que muestra la información al usuario y permite interacciones, independiente de la capa de datos.

  • Capa controlador (controller): Es la capa que actúa como puente entre modelo y vista. Almacena y manipula el estado de la aplicación y proporciona datos a las vista, interpreta las acciones del usuario según las reglas de negocio.

El siguiente diagrama de Apple muestra un poco la relación de las vistas y controladores.

Apple MVC

El principal problema de MVC y por qué razón nacieron otros patrones derivados es debido a la tendencia de que los controladores crecían de forma exponencial. Incluso llegando a ser llamado Massive View Controllers, por la cantidad de responsabilidades que tenían que cumplir.

¿Qué es el patrón MVVM?

El patrón Modelo-Vista-VistaModelo (MVVM), es un patrón de arquitectura que facilita estructurar la aplicación dividiéndola en tres roles.

MVVM

  • El modelo (model): representa los datos y lógica de negocio de la aplicación.

  • La vista (view): Muestra la información al usuario y permite la interacción.

  • La vista-modelo (view-model): Actúa como puente entre las capas de vista y modelo. Contiene el estado de la vista y maneja la lógica de interacciones.

¿Diferencias entre MVC y MVVM?

Al comparar los patrones de MVC y MVVM es notable la similitud y son casi idénticos.

La principal diferencia radica en que MVC hace énfasis en los controladores. Encargados de manejar las interacciones para varias vistas. En cambio en MVVM la vista-modelo es un único componente que controla el comportamiento y estado de una única vista. Comúnmente representado como un componente.

Otra diferencia es la forma de comunicación entre la vista y su controlador. En MVC la vista y el controlador tienen funciones definidas que son llamadas de forma imperativa para informar sobre una acción o requerir actualizar la información en la vista. Por otra parte en MVVM la vista y la vista-modelo están unidas por un mecanismo de enlazado (binding) que automáticamente informa sobre interacciones realizadas en la vista y cambios ocurridos en la vista-modelo. Estos mecanismos de enlazado varían según la plataforma, en el caso de LiveView ya viene todo configurado de fábrica y es más simple e intuitivo.

La importancia de MVVM

El utilizar un patrón de arquitectura como MVVM con roles claramente definidos nos ayudan cumplir principios de diseño como la separación de conceptos. Lo que es una piedra angular para mantener código bien organizado, fácilmente entendible y que sus pruebas unitarias son viables de implementar.

Utilizar patrones de arquitectura como MVVM es sumamente importante. A pesar de que LiveView nos da herramientas innovadoras para elaborar nuestras aplicaciones, si no utilizamos patrones de arquitectura el código se irá acumulando, aumentando de complejidad, para finalmente crear monolitos masivos que son difíciles de mantener y probar.

El hecho de que LiveView maneje automáticamente la actualización de las vistas no justifica abandonar las buenas prácticas en el desarrollo de software que han existido por décadas en múltiples plataformas.

¿MVVM es MVC?

Las capas de MVC interactúan y son interpretadas dependiendo de algunos factores como:

  • La plataforma donde se implementa.
  • La experiencia del profesional y su interpretación del patrón.
  • La moda del día (Los devs igual pueden seguir modas).

El patrón Modelo-Vista-VistaModelo (MVVM) es principalmente una versión de MVC bajo un nombre diferente.

Si bien hay ligeras diferencias, perfectamente se pueden utilizar los conceptos de MVC y MVVM de forma unificada sin problemas. Para poder simplificar, solamente nos referimos como MVVM, ya que es una de las formas válidas de interpretar este patrón.

¿Por qué MVVM es ideal para LiveView?

Vamos a repasar las distintas herramientas de LiveView y de qué forma podemos extrapolarlas a los conceptos de MVVM.

Para mayor detalle se puede ir al siguiente post:

Phoenix LiveView MVVM Descripción
LiveView Controller Es el encargado principal de gestionar eventos y estados generales o relativos al servidor y tener un árbol de vistas y vista-modelos
LiveComponent View-Model Es el encargado de gestionar eventos y estados relativos a la vista y coordinar la obtención de datos desde internet/base de datos.
Component View Es la vista y solamente tiene propiedades para mostrar los datos entregados por la vista-modelo

Phoenix no fuerza a seguir un patrón arquitectónico explícito. Sin embargo LiveView es particularmente apropiado para el patrón MVVM. Ofrece componentes que son independiente de los datos que se integran muy bien a la capa vista del patrón MVVM. Además LiveView proporciona mecanismos para enlazar las vistas a los datos y automáticamente actualizar las interfaces de usuario cuando los datos asociados tienen cambios.

El siguiente diagrama muestra una posible organización de arquitectura siguiendo MVVM con LiveView.

LiveView MVVM

Más allá de MVVM

Los patrones de arquitectura como MVC y MVVM tienen su foco en aplicaciones donde principalmente tenemos interacciones de usuario (UX), pero muchas veces las aplicaciones tienen que comunicar con servicios externos y otros elementos que necesitan otras formas de gestionar nuestra arquitectura de código.

Para esto recomendamos utilizar patrones como los definidos en el Diseño Orientado a Dominio (Domain Driven Design) y arquitectura Hexagonal.

Además de conceptos creados específicamente para la BEAM como Worker Bees y CRC.

Pero ver en mayor profundidad los conceptos de DDD y amigos quedará como tarea de auto estudio para el lector.

Proyecto Hacker News API

El proyecto consistirá en los siguientes archivos

├── lib
│   ├── hackernews
│   │   ├── infra
│   │   │   └── hackernews
│   │   │       └── beststories
│   │   │           ├── api.ex
│   │   │           └── mock.ex
│   │   ├── models
│   │   │   └── hackernews
│   │   │       └── beststories
│   │   │           ├── model.ex
│   │   │           ├── queries.ex
│   │   │           └── types.ex
│   ├── hackernews_web
│   │   ├── live
│   │   │   └── hackernews
│   │   │       └── beststories
│   │   │           ├── components
│   │   │           │   └── entry.ex
│   │   │           ├── controller.ex
│   │   │           └── viewmodel.ex
│   │   ├── router.ex
├── test
│   ├── hackernews_web
│   │   └── live
│   │       └── hackernews
│   │           └── beststories
│   │               └── beststories_test.exs
└── └
Enter fullscreen mode Exit fullscreen mode

infra

La infraestructura son todos aquellos servicios externos a nuestra aplicación. Acá se encontrarán los elementos que interactúan con ellos. Esto se consideraría una capa "Boundary".

api.ex y mock.ex

Parte de la infraestructura, contiene las llamadas a la API de HackerNews. No realiza ningún tipo de validación de parámetros o transformación de datos, ya que eso es responsabilidad de otros elementos. Simplemente se enfoca en llamar al servidor externo y devolver el resultado.

Notar que la base_url es modificada en el ambiente de test para utilizar una API Mock que utilizamos para validar en las pruebas.

Esto es parte de una técnica de mock que nos permite simplificar las pruebas sin acoplar nuestro cliente.

Pueden leer sobre esta técnica acá.

https://github.com/ElixirCL/ElixirCL/wiki/%5BArticulo%5D-Crear-Mocks-de-Endpoints-en-Phoenix

defmodule HackerNews.Infra.HackerNews.BestStories.API do
  @base_url if Mix.env() == :test,
              do: "http://localhost:4002/mocks/hackernews",
              else: "https://hacker-news.firebaseio.com/v0/"

  def all() do
    Req.new(
      base_url: @base_url,
      url: "beststories.json"
    )
    |> Req.get()
  end

  def get(story: id) do
    Req.new(
      base_url: @base_url,
      url: "item/#{id}.json"
    )
    |> Req.get()
  end
end
Enter fullscreen mode Exit fullscreen mode

models

Los archivos en este contexto son los encargados de procesar las llamadas y respuestas a los componentes de infraestructura.

types.ex

Las estructuras que serán utilizadas para llamadas y respuestas. Su única responsabilidad es estandarizar los datos, validarlos y transformarlos en estructuras.

En este contexto se pensó solamente en la estructura Item, la cual procesa la respuesta de HackerNews y será usada posteriormente en la vista.

defmodule HackerNews.Models.HackerNews.BestStories.Types.Item do
  defstruct ~w(id comment_count score author title date url footnote)a

  defp get_footnote(json) do
    url =
      Access.get(json, "url", "")
      |> URI.parse()

    time =
      Access.get(json, "time", System.os_time())
      |> DateTime.from_unix!()

    %{host: url.host, time: time, by: Access.get(json, "by", "unknown")}
  end

  def decode(json) do
    %__MODULE__{
      id: get_in(json, ["id"]),
      comment_count: get_in(json, ["descendants"]),
      score: get_in(json, ["score"]),
      author: get_in(json, ["by"]),
      title: get_in(json, ["title"]),
      date: get_in(json, ["time"]),
      url: get_in(json, ["url"]),
      footnote: get_footnote(json)
    }
  end
end
Enter fullscreen mode Exit fullscreen mode

queries.ex

Es el encargado de realizar las distintas llamadas a la API utilizando los endpoints con GET. Este archivo es parte de CQRS (Command, Query, Responsability, Segregation). Un patrón que nos recomienda separar las consultas de las operaciones. En el caso de HackerNews solamente realizamos consultas, pero si quisieramos realizar operaciones tendríamos que tener un archivo commands.ex para las llamadas a la API del tipo POST, PUT, PATCH y DELETE.

defmodule HackerNews.Models.HackerNews.BestStories.Queries do
  alias HackerNews.Infra.HackerNews.BestStories.API

  require Logger

  def get_top_story_ids(amount \\ 10) do
    with {:ok, ids} <- API.all() do
      ids.body
      |> Enum.take(amount)
    else
      err ->
        Logger.error(err)
        []
    end
  end

  def get_story(id) do
    API.get(story: id)
  end

  def get_stories(ids) do
    ids
    |> Enum.map(&get_story(&1))
  end
end
Enter fullscreen mode Exit fullscreen mode

model.ex

Es el encargado de coordinar Queries y Types, una suerte de facade. Transformamos las respuestas de Queries en estructuras definidas en Types.

defmodule HackerNews.Models.HackerNews.BestStories do
  alias __MODULE__.Types
  alias __MODULE__.Queries

  require Logger

  def top(amount \\ 10) do
    Queries.get_top_story_ids(amount)
    |> Queries.get_stories()
    |> Enum.map(fn
      {:error, error} ->
        Logger.error(error)
        nil

      {:ok, response} ->
        Types.Item.decode(response.body)
    end)
    |> Enum.filter(&(&1 != nil))
  end
end
Enter fullscreen mode Exit fullscreen mode

live

Directorio que contiene todos archivos de interfaz de usuario (UX). Acá utilizaremos el patrón MVVM para organizar nuestros archivos.

controller.ex

El encargado de instanciar al ViewModel y manejar eventos generales o de servidor y administrar propiedades como los parámetros y sesión.

defmodule HackerNewsWeb.HackerNews.Live.BestStories do
  use HackerNewsWeb, :surface_live_view

  alias __MODULE__.ViewModel

  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  def render(assigns) do
    ~F"""
    <ViewModel id="beststories" />
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

viewmodel.ex

Es el encargado de manejar los eventos de la vista y llamar a nuestros modelos para obtener información.

Notemos además como se utiliza una función para formatear los datos antes de que la vista los obtenga y muestre.

defmodule HackerNewsWeb.HackerNews.Live.BestStories.ViewModel do
  use Surface.LiveComponent

  alias HackerNewsWeb.HackerNews.Live.BestStories.View.Components.Entry
  alias HackerNews.Models.HackerNews.BestStories

  data entries, :list, default: []

  @impl true
  def mount(socket) do
    socket =
      socket
      |> assign(:entries, BestStories.top())

    {:ok, socket}
  end

  # This function is a small helper to have relative time.
  # To avoid using a library like Timex.
  # Extracted from: https://stackoverflow.com/a/65915005
  # And https://gist.github.com/h00s/b863579ec9c7b8c65311e6862298b7a0
  defp from_now_ago_in_words(later, now \\ DateTime.utc_now()) do

    seconds = DateTime.diff(now, later)
    minutes = round(seconds/60)

    case minutes do
      minutes when minutes in 0..1 ->
        case seconds do
          seconds when seconds in 0..4 ->
            "less than 5 seconds"
          seconds when seconds in 5..9 ->
            "less than 10 seconds"
          seconds when seconds in 10..19 ->
            "less than 20 seconds"
          seconds when seconds in 20..39 ->
            "half a minute"
          seconds when seconds in 40..59 ->
            "less than 1 minute"
          _ ->
            "1 minute"
        end
      minutes when minutes in 2..44 ->
        "#{minutes} minutes"
      minutes when minutes in 45..89 ->
        "about 1 hour"
      minutes when minutes in 90..1439 ->
        "about #{round(minutes/60)} hours"
      minutes when minutes in 1440..2519 ->
        "1 day"
      minutes when minutes in 2520..43199 ->
        "#{round(minutes/1440)} days"
      minutes when minutes in 43200..86399 ->
        "about 1 month"
      minutes when minutes in 86400..525599 ->
        "#{round(minutes/43200)} months"
      minutes when minutes in 525600..1051199 ->
        "1 year"
      _ ->
        "#{round(minutes/525600)} years"
    end
  end

  def render(assigns) do
    ~F"""
    <div id="beststories">
      <h1 class="text-5xl font-extrabold dark:text-white mb-10">HackerNews Best Stories</h1>
      {#for entry <- @entries}
        <Entry
          url={entry.url}
          title={entry.title}
          footnote={"#{entry.footnote.host} - #{from_now_ago_in_words(entry.footnote.time)} ago by #{entry.footnote.by}"}
          score={entry.score}
          comment_count={entry.comment_count}
        />
      {/for}
    </div>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

components/entry.ex

La vista esta principalmente creada usando componentes. En este caso un único componente que muestra los datos de una noticia.

defmodule HackerNewsWeb.HackerNews.Live.BestStories.View.Components.Entry do
  use Surface.Component

  prop url, :string
  prop title, :string
  prop footnote, :string
  prop score, :integer
  prop comment_count, :integer

  def render(assigns) do
    ~F"""
    <div class="entry mt-4">
      <h2 class="entry-title text-xl font-bold dark:text-white"><a class="entry-url" href={@url}>{@title}</a></h2>
      <h3 class="entry-footnote mt-2 text-lg dark:text-white">{@footnote}</h3> <div class="entry-stats flex mt-2">
        <span class="mr-2">🔼</span> <p class="entry-score font-bold">{@score}</p> <span class="mr-2 ml-4">💬</span> <p class="entry-comment-count font-bold">{@comment_count}</p>
      </div>
    </div>
    """
  end
end
Enter fullscreen mode Exit fullscreen mode

test/beststories_test.exs

Gracias a la técnica de mocks para la API nuestras pruebas solamente se concentran en evaluar si la renderización es correcta y contiene la información necesaria.

defmodule HackerNewsWeb.HackerNews.Live.BestStoriesTest do
  @moduledoc false
  use HackerNewsWeb.ConnCase, async: true
  use Surface.LiveViewTest
  import Phoenix.LiveViewTest

  alias HackerNews.Infra.Mocks.HackerNews.BestStories.API, as: Mock

  @route "/"

  describe "Best Stories" do
    test "that displays the 10 best stories", %{conn: conn} do
      {:ok, liveview, html} = live(conn, @route)

      # first check if we have the container element
      assert liveview
             |> element("#beststories")
             |> has_element?() == true

      # then we use Floki to parse the html
      {:ok, document} = Floki.parse_document(html)

      entries =
        Floki.find(document, ".entry")

      assert Enum.count(entries) == 10

      titles = Floki.find(document, ".entry-title")
      |> Enum.map(fn {_htag, _hattrs, [{_atag, _aattrs, [title]}]} -> title end)

      assert titles == Enum.map(Mock.data, fn {_k, v} -> v["title"] end)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Conclusión

El utilizar patrones como MVVM nos permite simplificar nuestra organización de código, mejorar la experiencia al crear pruebas y tener cierta estandarización en los proyectos.

Sin embargo no son los únicos patrones que podemos utilizar, ya que los proyectos de Phoenix van mucho más allá que las interfaces de usuario, tenemos a nuestra disposición todo un ecosistema unificado de frontend y backend.

Nuestras aplicaciones tienen que responder las siguientes preguntas, según el patrón CRC:

  • Crear: ¿Cómo se crean/obtienen los datos?.
  • Reducir: ¿Qué transformaciones necesitan y cómo se deben hacer?.
  • Consumir: ¿Cómo muestro el resultado o consumo dicho dato?.

Siguiendo estos conceptos podremos organizar y mejorar nuestras soluciones de software para que sean robustaz, eficientes y fáciles de mantener en el tiempo.

💖 💪 🙅 🚩
clsource
Camilo

Posted on February 1, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Aplicando MVVM en Phoenix LiveView
elixir Aplicando MVVM en Phoenix LiveView

February 1, 2024