7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-02-01

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

元々はスペイン語で書かれた

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

この概念は、LiveView などの宣言型テクノロジに非常に似ている SwiftUI モバイル テクノロジに焦点を当てた Matteo Manferdini による記事に基づいています。

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

要件

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

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

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

HackerNews Client

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

MVVM と MVC パターン?

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

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

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

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

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

MVC

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

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

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

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

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

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

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

Apple MVC

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

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

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

MVVM

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

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

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

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

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

MVVMの重要性

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

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

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

MVVM は MVC ですか?

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

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

Model - View - View Model (MVVM) パターンは、主に MVC の別名バージョンです。

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

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

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

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

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

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

LiveView MVVM

MVVM を超えて

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

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

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

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

HackerNews API プロジェクト

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

├── 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 手法の一部です。

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 構造のみが考えられました。

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 ファイルが必要になります。

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

これは、QueriesTypes (一種の facade) の調整を担当します。 Queries からの応答を 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

live

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

controller.ex

View Model のインスタンス化、一般イベントまたはサーバー イベントの処理、パラメーターやセッションなどのプロパティの管理を担当します。

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

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

また、ビューがデータを取得して表示する前に、関数を使用してデータをフォーマットする方法にも注目してください。

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

ビューは主にコンポーネントを使用して作成されます。 この場合、ニュース項目のデータを表示する単一のコンポーネントです。

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

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): 結果を表示したり、そのデータを消費したりするにはどうすればよいですか?

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

7
3
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?