MVVM パターンを使用した Phoenix LiveView および Surface UI を備えた HackerNews クライアント

こんにちは！私はチリ 🇨🇱 出身のカミロです。言語翻訳プログラムを使っています。この情報がお役に立てば幸いです。

元々はスペイン語で書かれた
- https://dev.to/javascriptchile/aplicando-mvvm-en-phoenix-liveview-5f1

次の記事では、アーキテクチャ設計に MVVM (モデル - ビュー - モデル ビュー) の概念を適用し、Phoenix Framework と Surface UI (LiveView) を使用して小さな Hacker News クライアントを作成します。

この概念は、LiveView などの宣言型テクノロジに非常に似ている SwiftUI モバイル テクノロジに焦点を当てた [Matteo Manferdini](https://matteomanferdini.com/mvvm-swiftui/) による記事に基づいています。

MVVM パターンには、優れたアイデアと、その解釈の違いによるいくつかの困難が組み込まれています。 この記事では、その利点とその課題を乗り越える方法を見ていきます。

## 要件

この記事に従うには、Elixir をインストールし、SurfaceUI でプロジェクトを構成することをお勧めします。

## ハッカーニュースクライアント

LiveView での MVVM の例を得るために、開発者向けのニュース ポータルである Hacker News 用の小さなアプリケーションを作成します。 API を使用して、トップ記事セクションから 10 件のニュース記事を取得します。

![HackerNews Client](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1dowm7unf9kfm21jtoyx.png)

結果はここで見ることができます

- https://github.com/ElixirCL/surface-hackernews
- https://github.com/HackerNews/API
- https://news.ycombinator.com/best

## MVVM と MVC パターン?

現在のテクノロジーでは、複雑なアプリケーションを比較的簡単に作成できるため、ソフトウェア ソリューションの保守性や堅牢性を妨げる慣行を簡単に使用してしまう人もいます。

長期間にわたって堅牢で保守可能なソフトウェア製品を実現するには、散在するコードの断片をまとまりのない順序でまとめるだけでは十分ではありません。 _Google_ で検索して特定のタスクを解決することもできますが、コードをコピーして貼り付けることで何らかの方法で機能させることができます。 基礎を離れて専門的な分野に入ると、必ず困難に遭遇します。

このため、業界は _MVC_ や _MVVM_ などのパターンを開発しました。

### MVC パターンとは何ですか?

モデル - ビュー - コントローラー (_MVC_) パターンは、最初に学習する必要があるパターンの 1 つです。 これは非常に基本的なものであるため、業界で数十年にわたって生き残り、そのアイデアは多くのプラットフォームに広がりました。 これは、_MVVM_ などの他の多くの派生パターンの父です。

![MVC](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iondmipso1rbafuolnj4.png)

このパターンは、最も一般的な質問の 1 つに答えるのに役立つため、不可欠です。

> このコードをどこに置けばよいでしょうか?

_MVC_ パターンはアーキテクチャ上のパターンです。 これはアプリケーションの構造のマップを提供し、その名前が示すように 3 つのレイヤーで構成されます。

- `model` 層: データとビジネス ロジックを、その視覚的表現から独立して管理する層です。


- `view` レイヤー: データ レイヤーとは独立して、ユーザーに情報を表示し、対話を可能にするレイヤーです。


- `controller` レイヤー: モデルとビューの間のブリッジとして機能するレイヤーです。 アプリケーションの状態を保存および操作し、データをビューに提供し、ビジネス ルールに基づいてユーザーのアクションを解釈します。

Apple が提供した次の図は、ビューとコントローラーの関係の一部を示しています。

![Apple MVC](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i7wq90k7pquhaz50xwln.png)

- https://developer.apple.com/library/archive/featuredarticles/ViewControllerPGforiPhoneOS/index.html

_MVC_ の主な問題と、他の派生パターンが生まれた理由は、コントローラーが指数関数的に増大する傾向にあるためです。 果たさなければならない責任の多さから、_Massive View Controllers_ と呼ばれるようになりました。

### MVVM パターンとは何ですか?

_Model - View - View Model_ (_MVVM_) パターンは、アプリケーションを 3 つの役割に分割することでアプリケーションの構造化を容易にするアーキテクチャ パターンです。

![MVVM](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7tjofioiya38l2kei3ux.png)

- `model`: アプリケーションのデータとビジネス ロジックを表します。
- `view`: ユーザーに情報を表示し、対話を可能にします。
- `view - model`: ビュー層とモデル層の間のブリッジとして機能します。 これにはビューの状態が含まれており、対話ロジックを処理します。

### MVC と MVVM の違いは何ですか?

_MVC_ と _MVVM_ のパターンを比較すると、類似性が顕著であり、ほとんど同一です。

主な違いは、_MVC_ が「コントローラー」を強調していることです。 さまざまなビューのインタラクションの管理を担当します。 一方、_MVVM_ では、「ビューモデル」は単一のビューの動作と状態を制御する単一のコンポーネントです。 通常はコンポーネントとして表されます。

もう 1 つの違いは、ビューとそのコントローラー間の通信方法です。 _MVC_ では、ビューとコントローラーには、アクションについて通知したり、ビュー内の情報の更新を要求したりするために強制的に呼び出される関数が定義されています。 一方、_MVVM_ では、ビューとビューモデルは、ビュー内で行われた対話とビューモデルで発生した変更を自動的にレポートするバインディング メカニズムによって結合されます。 これらのリンク メカニズムはプラットフォームによって異なりますが、_LiveView_ の場合、すべてが工場出荷時にすでに構成されており、よりシンプルで直感的です。

### MVVMの重要性

明確に定義された役割を持つ _MVVM_ のようなアーキテクチャ パターンを使用すると、概念の分離などの設計原則に準拠するのに役立ちます。 これは、コードを適切に整理し、理解しやすく、単体テストを実行可能に保つための基礎となります。

_MVVM_ のようなアーキテクチャ パターンを使用することは非常に重要です。 _LiveView_ はアプリケーションを開発するための革新的なツールを提供しますが、アーキテクチャ パターンを使用しないとコードが蓄積され、複雑さが増し、最終的には保守やテストが困難な大規模なモノリスが作成されます。

_LiveView_ がビューの更新を自動的に処理するという事実は、複数のプラットフォームにわたって数十年にわたって存在してきたソフトウェア開発の優れた慣行を放棄することを正当化するものではありません。

- https://en.wikipedia.org/wiki/Separation_of_concerns
- https://en.wikipedia.org/wiki/Coupling_(computer_programming)

### MVVM は MVC ですか?
_MVC_ レイヤーは相互作用し、次のようないくつかの要因に応じて解釈されます。

- 実装されるプラットフォーム。
- 専門家の経験とパターンの解釈。
- その日のファッション (開発者は引き続きトレンドを追跡できます)。

_Model - View - View Model_ (_MVVM_) パターンは、主に _MVC_ の別名バージョンです。

多少の違いはありますが、_MVC_ と _MVVM_ の概念は完全に統一して問題なく使用できます。 これはこのパターンを解釈する有効な方法の 1 つであるため、簡単にするためにこれを _MVVM_ としてのみ参照します。

### MVVM が LiveView に最適な理由は何ですか?

_LiveView_ のさまざまなツールと、それらのツールを _MVVM_ の概念に当てはめる方法を確認します。

| フェニックス ライブビュー | MVVM | 説明 |
| --- | --- | --- |
| ライブビュー | コントローラー | これは、イベントと一般的な状態またはサーバー関連の状態を管理し、ビューとビューモデルのツリーを持つ主な担当者です。
| _ライブコンポーネント_ | ビュー - モデル | ビューに関連するイベントと状態を管理し、インターネット/データベースからのデータの取得を調整する責任があります。 |
| _コンポーネント_ | 見る | これはビューであり、ビューによって提供されるデータを表示するためのプロパティのみを持っています。

Phoenix は、明示的なアーキテクチャ パターンに従うことを強制しません。 ただし、_LiveView_ は、_MVVM_ パターンに特に適しています。 これは、_MVVM_ パターンの `view` レイヤーにうまく統合される、データに依存しないコンポーネントを提供します。 さらに、_LiveView_ は、ビューをデータにバインドし、関連するデータが変更されたときにユーザー インターフェイスを自動的に更新するメカニズムを提供します。

次の図は、_MVVM_ と _LiveView_ に続く考えられるアーキテクチャ構成を示しています。

![LiveView MVVM](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/igmqpayh51cubungb05r.png)

### MVVM を超えて

_MVC_ や _MVVM_ などのアーキテクチャ パターンは、主にユーザー インタラクション (_UX_) が行われるアプリケーションに重点を置いていますが、多くの場合、アプリケーションはコード アーキテクチャを管理するために他の方法を必要とする外部サービスやその他の要素と通信する必要があります。

このためには、ドメイン駆動設計やヘキサゴナル アーキテクチャで定義されているようなパターンを使用することをお勧めします。

- https://www.youtube.com/watch?v=JNWPsaO4PNM
- https://www.youtube.com/watch?v=fx3BmpzitUg
- https://www.youtube.com/watch?v=sdM1KkjtCe8

**働き蜂**や**CRC**など、_BEAM_専用に作成されたコンセプトに加えて。

- https://www.youtube.com/watch?v=GiGs4QZK-J8
- https://www.youtube.com/watch?v=KTVQtATxivQ

しかし、_DDD_ とその仲間たちの概念をさらに詳しく調べることは、読者にとっての自習課題として残ります。

## HackerNews API プロジェクト
プロジェクトは以下のファイルで構成されます

```txt
├── 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
├── test
│   ├── hackernews_web
│   │   └── live
│   │       └── hackernews
│   │           └── beststories
│   │               └── beststories_test.exs
└── └
```

インフラストラクチャは、アプリケーションの外部にあるすべてのサービスです。 ここでは、それらと相互作用する要素を見つけます。 これは「_Boundary_」レイヤーとみなされます。

#### api.ex と mock.ex
インフラストラクチャの一部であり、_HackerNews_ API への呼び出しが含まれています。 パラメータの検証やデータ変換は他の要素の責任であるため、いかなるタイプのパラメータ検証もデータ変換も実行しません。 外部サーバーを呼び出して結果を返すことに重点を置いています。

`base_url` は、テストで検証するために使用する _Mock API_ を使用するように `test` 環境で変更されることに注意してください。

これは、クライアントをドッキングせずにテストを簡素化できる _mock_ 手法の一部です。

```elixir
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
```

### モデル
このコンテキストのファイルは、インフラストラクチャ コンポーネントへの呼び出しと応答の処理を担当します。

#### types.ex
呼び出しと応答に使用される構造体。 あなたの唯一の責任は、データを標準化し、検証し、構造に変換することです。

このコンテキストでは、_HackerNews_ からの応答を処理し、後のビューで使用される _Item_ 構造のみが考えられました。

```elixir
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
```

#### queries.ex

_GET_ でエンドポイントを使用して API へのさまざまな呼び出しを行う責任があります。 このファイルは、_CQRS_ (コマンド、クエリ、責任、分離) の一部です。 クエリを操作から分離することを推奨するパターン。 _HackerNews_ の場合はクエリを作成するだけですが、操作を実行したい場合は、_POST、PUT、PATCH、および DELETE_ タイプの API 呼び出し用の `commands.ex` ファイルが必要になります。


```elixir
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
```

#### model.ex

これは、_Queries_ と _Types_ (一種の _facade_) の調整を担当します。 _Queries_ からの応答を _Types_ で定義された構造に変換します。

```elixir
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
```

### live
すべてのユーザー インターフェイス (_UX_) ファイルを含むディレクトリ。 ここでは、_MVVM_ パターンを使用してファイルを整理します。

#### controller.ex
_View Model_ のインスタンス化、一般イベントまたはサーバー イベントの処理、パラメーターやセッションなどのプロパティの管理を担当します。
```elixir
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
```

#### viewmodel.ex
これは、ビューのイベントを処理し、モデルを呼び出して情報を取得する責任があります。

また、ビューがデータを取得して表示する前に、関数を使用してデータをフォーマットする方法にも注目してください。
```elixir
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
```

#### components/entry.ex
ビューは主にコンポーネントを使用して作成されます。 この場合、ニュース項目のデータを表示する単一のコンポーネントです。
```elixir
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
```

#### test/beststories_test.exs
_API_ のモック技術のおかげで、私たちのテストは、レンダリングが正しく、必要な情報が含まれているかどうかを評価することだけに焦点を当てています。
```elixir
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
```

## 結論
_MVVM_ のようなパターンを使用すると、コード構成が簡素化され、テスト作成時のエクスペリエンスが向上し、プロジェクトでの標準化が可能になります。

ただし、使用できるパターンはこれらだけではありません。_Phoenix_ プロジェクトはユーザー インターフェイスをはるかに超えており、フロントエンドとバックエンドの統合されたエコシステム全体を自由に使用できるためです。

私たちのアプリケーションは、**CRC** パターンに従って次の質問に答える必要があります。

- **作成** (Create): データはどのように作成/取得されますか?
- **削減** (Reduce): どのような変革が必要で、どのように行うべきですか?
- **消費** (Consume): 結果を表示したり、そのデータを消費したりするにはどうすればよいですか?

これらの概念に従って、ソフトウェア ソリューションを組織化し、改善することで、ソフトウェア ソリューションが堅牢で効率的で、長期にわたって保守が容易になるようにすることができます。