Context

Batas antara lapisan web dan domain.

Daftar isi

Context adalah modul yang menjadi pintu masuk ke satu subdomain aplikasi sekaligus batas antara lapisan web dan logika domain. Topik ini membahas prinsip context pada Phoenix, membangun sebuah context Katalog yang tidak menyentuh HTTP sama sekali, lalu memanggilnya dari controller pada demo mandiri. Setelah membaca, pemisahan tugas antara Katalog, controller, dan router dapat ditetapkan dengan aturan yang jelas.

Context sebagai API domain#

Sebuah proyek Phoenix menempatkan context di folder lib/nama_proyek/, sedangkan lapisan web di lib/nama_proyek_web/. Aturannya tegas: kode di bawah _web/ hanya boleh memanggil fungsi context, dan context tidak boleh menyentuh conn, rute, maupun template. Dengan batas ini, logika domain dapat diuji tanpa menjalankan lapisan web, dapat digunakan dari LiveView, Task, maupun skrip Mix, dan lapisan web dapat berubah tanpa menyentuh aturan bisnis.

Ketergantungan yang mengalir satu arah inilah yang membuat context berguna. LiveView, controller, dan pekerjaan latar belakang memanggil fungsi context yang sama, sehingga aturan domain tidak bergantung pada pemanggilnya. Batas ini juga mempercepat pengujian: modul Katalog pada bagian berikutnya dapat diuji dengan ExUnit polos tanpa menjalankan endpoint, dan kegagalan tidak perlu dikaitkan dengan lapisan HTTP.

Generator resmi mengikuti prinsip yang sama. Perintah mix phx.gen.html Katalog Catatan catatan judul isi, misalnya, membuat Katalog sebagai context berisi fungsi list_catatan/0, get_catatan!/1, dan create_catatan/1. Controller yang dihasilkan hanya mengatur conn dan meneruskan pekerjaan ke Katalog. Nama fungsi context sengaja berbahasa domain, bukan berbahasa HTTP, sehingga create_catatan/1 tidak pernah mengembalikan status 201, melainkan {:ok, catatan} atau {:error, changeset}.

Context Katalog tanpa HTTP#

Demo pada topik ini menyusun context penyimpanan dalam memori dengan Agent, karena fokusnya adalah batas lapisan, bukan database. Tiga fungsi yang disediakan Katalog memadai untuk kasus nyata kecil, yaitu buat_catatan/1 yang menambah catatan dan mengembalikan hasilnya, daftar_catatan/0 yang mengembalikan seluruh catatan, dan ambil_catatan/1 yang mengembalikan satu catatan atau nil.

Perhatikan bentuk nilai kembaliannya. buat_catatan/1 menerima map kunci string dan mengembalikan struct Katalog.Catatan, bukan JSON dan bukan conn. Nilai nil untuk catatan yang tidak ada juga berbahasa domain, dan keputusan menerjemahkannya menjadi status 404 didelegasikan ke lapisan web. Context yang sama nantinya dapat mengganti Agent dengan Ecto.Repo tanpa mengubah controller sama sekali.

Pilihan Agent di sini murni demi kemudahan demo, dan keputusannya patut dijelaskan. Agent menyimpan satu map berisi penghitung id dan catatan yang sudah dibuat, sehingga nomor id diberikan oleh domain, bukan oleh database. Fungsi Agent.get_and_update/3 menggabungkan pembacaan dan penulisan state dalam satu operasi atomik, sehingga dua permintaan yang bersamaan tidak pernah mendapat id yang sama. Struktur Katalog.Catatan mendisiplinkan bentuk data, dan seluruh mekanisme ini dapat digantikan tanpa memodifikasi fungsi aksi satu baris pun.

Memanggil context dari controller mandiri#

Skrip berikut memuat kedua lapisan sekaligus. Bagian atasnya adalah Katalog yang murni, bagian tengahnya adalah controller dan router yang menerjemahkan HTTP menjadi panggilan Katalog, dan bagian bawahnya memperlihatkan penggunaan kedua jalur tersebut.

elixir katalog.exs
Mix.install([{:phoenix, "~> 1.8"}, {:jason, "~> 1.4"}])
Logger.configure(level: :warning)

# context: API domain yang tidak tahu HTTP sama sekali
defmodule Katalog do
  defmodule Catatan do
    defstruct [:id, :judul, :isi]
  end

  def start_link do
    Agent.start_link(fn -> %{id: 0, data: %{}} end, name: __MODULE__)
  end

  def buat_catatan(attrs) do
    Agent.get_and_update(__MODULE__, fn state ->
      id = state.id + 1
      catatan = %Catatan{id: id, judul: attrs["judul"], isi: attrs["isi"]}
      {catatan, %{state | id: id, data: Map.put(state.data, id, catatan)}}
    end)
  end

  def daftar_catatan do
    Agent.get(__MODULE__, fn state -> Map.values(state.data) end)
  end

  def ambil_catatan(id) do
    Agent.get(__MODULE__, fn state -> Map.get(state.data, id) end)
  end
end

# lapisan web: hanya menerjemahkan HTTP menjadi panggilan Katalog
defmodule CatatanController do
  use Phoenix.Controller, formats: [:json]

  def index(conn, _params) do
    data = Enum.map(Katalog.daftar_catatan(), fn c -> %{id: c.id, judul: c.judul} end)
    json(conn, %{data: data})
  end

  def show(conn, %{"id" => id}) do
    case Katalog.ambil_catatan(String.to_integer(id)) do
      %Katalog.Catatan{} = catatan ->
        json(conn, %{id: catatan.id, judul: catatan.judul, isi: catatan.isi})

      nil ->
        conn |> put_status(:not_found) |> json(%{error: "tidak ditemukan"})
    end
  end
end

defmodule CatatanRouter do
  use Phoenix.Router

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/api" do
    pipe_through :api

    get "/catatan", CatatanController, :index
    get "/catatan/:id", CatatanController, :show
  end
end

# pemakaian context secara langsung, tanpa HTTP
Katalog.start_link()
Katalog.buat_catatan(%{"judul" => "Puisi hujan", "isi" => "Sketsa pertama"})
Katalog.buat_catatan(%{"judul" => "Rumput beton", "isi" => "Sketsa kedua"})
IO.inspect(Katalog.ambil_catatan(1), label: "langsung")

# pemakaian context dari controller melalui pipeline
conn =
  Phoenix.ConnTest.build_conn(:get, "/api/catatan", nil)
  |> CatatanRouter.call(CatatanRouter.init([]))

IO.inspect(conn.status, label: "status daftar")
IO.inspect(Jason.decode!(conn.resp_body), label: "daftar")

conn =
  Phoenix.ConnTest.build_conn(:get, "/api/catatan/2", nil)
  |> CatatanRouter.call(CatatanRouter.init([]))

IO.inspect(conn.status, label: "status satu")
IO.inspect(Jason.decode!(conn.resp_body), label: "satu")

conn =
  Phoenix.ConnTest.build_conn(:get, "/api/catatan/99", nil)
  |> CatatanRouter.call(CatatanRouter.init([]))

IO.inspect(conn.status, label: "status hilang")
IO.inspect(Jason.decode!(conn.resp_body), label: "hilang")

Jalankan skripnya dengan elixir katalog.exs. Bagian pertama keluaran menunjukkan penggunaan langsung tanpa HTTP, sedangkan bagian berikutnya memperlihatkan jalur yang sama melalui pipeline router dan controller.

text Keluaran
langsung: %Katalog.Catatan{id: 1, judul: "Puisi hujan", isi: "Sketsa pertama"}
status daftar: 200
daftar: %{
  "data" => [
    %{"id" => 1, "judul" => "Puisi hujan"},
    %{"id" => 2, "judul" => "Rumput beton"}
  ]
}
status satu: 200
satu: %{"id" => 2, "isi" => "Sketsa kedua", "judul" => "Rumput beton"}
status hilang: 404
hilang: %{"error" => "tidak ditemukan"}

Aksi show memperlihatkan pola terjemahan status. Nilai nil dari Katalog.ambil_catatan/1 dicocokkan dengan case, dan cabang nil menerapkan put_status(:not_found) sebelum json/2. Terjemahan dari nil menjadi 404 berada di controller, tepat di batas lapisan web. Seluruh keputusan domain, termasuk cara id diberi nomor, tetap ada di Katalog.

Urutan definisi modul pada skrip juga menyampaikan satu pelajaran. Controller dan router menyebut modul Katalog tanpa kebutuhan khusus apa pun, karena Elixir menyelesaikan nama modul saat runtime. Pada proyek nyata, pemisahan yang sama berbentuk berkas terpisah di folder yang berbeda, dan Katalog cukup diberi alias di bagian atas controller.

Kesalahan umum#

Kesalahan pertama adalah membiarkan controller mengakses Ecto.Repo atau Agent secara langsung. Penulisan Repo.all(Catatan) di dalam fungsi aksi memang berjalan, tetapi batas lapisan jebol, sehingga query yang sama akhirnya ditulis ulang di beberapa controller. Perbaikannya, pindahkan akses data ke fungsi context, dan biarkan controller hanya memanggil fungsi itu.

Kesalahan kedua adalah menyusupkan bahasa web ke nama dan nilai context. Fungsi get_catatan_by_status_and_page/2 yang mengembalikan {:error, 404} adalah gejala context yang menyentuh HTTP. Perbaikannya, gunakan nama domain seperti list_catatan_aktif/0 dan nilai error seperti {:error, :not_found}, lalu biarkan controller yang menerjemahkan :not_found menjadi status 404.

Kesalahan ketiga adalah menganggap conn boleh dilewatkan ke context. Melewatkan conn ke Katalog.buat_catatan(conn.params) pernah terlihat wajar, tetapi fungsi tersebut kini membutuhkan conn untuk diuji. Melewatkan data yang sudah dipilih, misalnya Katalog.buat_catatan(params), menjaga context tetap dapat dipanggil dari tempat mana saja, termasuk dari skrip dan shell IEx. Tanda tangan fungsi context yang memuat conn adalah pertanda batas lapisan sudah jebol.

Ringkasan#

  • Context adalah pintu masuk ke satu subdomain dan menjadi batas antara nama_proyek dan nama_proyek_web.
  • Fungsi context berbahasa domain dan mengembalikan nilai domain, seperti struct, {:ok, _}, atau {:error, _}, bukan status HTTP.
  • Controller menerjemahkan hasil context menjadi respons, termasuk mengubah nil menjadi 404 dengan put_status/2.
  • Context yang tidak menyentuh conn dapat diuji dan dipanggil dari skrip, IEx, LiveView, maupun controller dengan perilaku yang sama.

Referensi#