GenServer

handle_call, handle_cast, dan kontrak servernya.

Daftar isi

Topik process berkondisi pada modul Process menunjukkan bahwa sebuah server dapat ditulis dengan loop receive manual beserta rekursi berekor. GenServer mengambil pola tersebut dan mengotomatiskan seluruh bagian repetitifnya, sehingga yang tersisa bagi penulis modul hanyalah logika tiap pesan. Topik ini membahas kontrak client/server GenServer, alasan pemisahan API dari callback, serta satu skrip utuh gudang stok buku yang memuat init/1, handle_call/3, handle_cast/2, dan handle_info/2. Hasil akhirnya adalah kemampuan menulis server OTP yang kontraknya terbaca, teruji, dan siap dipasang di bawah supervisor.

Kontrak client dan server#

GenServer adalah behaviour untuk membangun generic server, yaitu process yang menerima permintaan satu demi satu dari process lain dan mengolah statenya sesuai pesan tersebut. Sisi client berupa process pemanggil yang mengirim pesan melalui fungsi API, sedangkan sisi server berupa process GenServer yang menjawab pesan itu dengan callback. Kerangka GenServer menangani antrean pesan, pencocokan pola, pengiriman balasan, pencatatan error, dan pembatasan waktu tunggu, sehingga penulis modul tidak lagi menyentuh receive sama sekali.

Empat callback membentuk kontraknya. init/1 menerima argumen dari GenServer.start_link/3 dan mengembalikan state awal, sedangkan tiga callback lainnya menangani tiga jenis pesan yang berbeda. Callback yang dipanggil ditentukan oleh fungsi GenServer yang digunakan client, bukan oleh isi pesannya:

CallbackDipicu olehNilai balikan yang wajar
init/1GenServer.start_link/3{:ok, state}
handle_call/3GenServer.call/3, pemanggil menunggu balasan{:reply, balasan, state_baru}
handle_cast/2GenServer.cast/2, pemanggil tidak menunggu{:noreply, state_baru}
handle_info/2pesan apa pun di luar call dan cast{:noreply, state_baru}

Pilihan antara call dan cast menyangkut kebutuhan kepastian. call bersifat sinkron, yaitu pemanggil menunggu sampai server mengirim balasannya atau waktu tunggu habis, sehingga pemanggil tahu persis apakah permintaannya berhasil. cast bersifat asinkron dan langsung mengembalikan :ok, sehingga lebih murah tetapi tidak memberi jaminan bahwa pesannya sudah diproses. handle_info/2 melengkapi keduanya karena menampung pesan yang dikirim dengan send biasa, misalnya pesan berkala dari Process.send_after/3 atau notifikasi dari process lain.

Memisahkan API dari callback#

Server GenServer yang sehat memisahkan dua lapisan fungsi. Lapisan API berisi fungsi publik yang dipanggil process lain, misalnya Gudang.ambil/2, dan isinya hanya GenServer.call/3 atau GenServer.cast/2. Lapisan callback berisi handle_call/3 beserta callback lainnya yang hanya dijalankan di dalam process server. Kedua lapisan itu hidup dalam satu modul yang sama, tetapi perannya tidak boleh tertukar.

Pemisahan ini memberi dua manfaat yang nyata. Pertama, bentuk pesan menjadi detail internal yang tidak perlu diketahui pemanggil, sehingga struktur tuple pesan dapat diubah tanpa menyentuh satu pun pemanggilnya. Kontrak server pun terbaca dari daftar fungsi API saja, dan fungsi-fungsi tersebut dapat diuji secara langsung. Kedua, @impl true di atas tiap callback memerintahkan compiler untuk memeriksa kesesuaian dengan behaviour, sehingga salah nama callback atau salah bentuk nilai balikan tertangkap pada saat kompilasi, bukan pada saat server berjalan.

Pemisahan itu juga menentukan bentuk pengujian. Fungsi API diuji dengan memulai server lalu memanggil fungsi-fungsinya seperti pemanggil biasa, sedangkan callback diuji secara terisolasi dengan memanggil handle_call/3 bersama state tiruan. Kedua sisi tersebut tidak pernah saling memanggil secara langsung, karena satu-satunya jalan dari API menuju callback adalah pesan yang dikirim oleh kerangka GenServer.

Skrip utuh: gudang stok buku#

Skrip berikut membangun GenServer gudang yang statenya berupa map stok buku. API-nya terdiri atas ambil/2 berbasis call, tambah/2 berbasis cast, dan stok/0 untuk membaca seluruh isi stok. Pesan :lapor dikirim dengan send biasa sehingga ditangani handle_info/2, dan server diberi nama :gudang agar client tidak perlu menangkap pid. Pemberian nama pada start_link/3 juga membuat server mudah dicari kembali melalui Process.whereis/1 tanpa menyimpan pidnya:

elixir gudang.exs
# gudang.exs — GenServer gudang stok buku dengan callback init, handle_call, handle_cast, dan handle_info
defmodule Gudang do
  use GenServer

  # ---- API sisi client ----
  def start_link(stok_awal) do
    GenServer.start_link(__MODULE__, stok_awal, name: :gudang)
  end

  def ambil(judul, jumlah) do
    GenServer.call(:gudang, {:ambil, judul, jumlah})
  end

  def tambah(judul, jumlah) do
    GenServer.cast(:gudang, {:tambah, judul, jumlah})
  end

  def stok do
    GenServer.call(:gudang, :stok)
  end

  # ---- callback sisi server ----
  @impl true
  def init(stok_awal) do
    {:ok, stok_awal}
  end

  @impl true
  def handle_call({:ambil, judul, jumlah}, _from, stok) do
    tersedia = Map.get(stok, judul, 0)

    if tersedia >= jumlah do
      {:reply, {:ok, tersedia - jumlah}, Map.put(stok, judul, tersedia - jumlah)}
    else
      {:reply, {:error, :stok_kurang}, stok}
    end
  end

  @impl true
  def handle_call(:stok, _from, stok) do
    {:reply, stok, stok}
  end

  @impl true
  def handle_cast({:tambah, judul, jumlah}, stok) do
    {:noreply, Map.update(stok, judul, jumlah, &(&1 + jumlah))}
  end

  @impl true
  def handle_info(:lapor, stok) do
    IO.puts("laporan: #{inspect(stok)}")
    {:noreply, stok}
  end
end

{:ok, _} = Gudang.start_link(%{elixir_dasar: 5, otp_lanjutan: 2})

IO.inspect(Gudang.ambil(:elixir_dasar, 2), label: "ambil 2 buku elixir_dasar")
IO.inspect(Gudang.ambil(:otp_lanjutan, 5), label: "ambil 5 buku otp_lanjutan")

Gudang.tambah(:otp_lanjutan, 4)

# cast tidak menunggu proses selesai, jadi pengiriman laporan dijadwalkan dulu
Process.send_after(:gudang, :lapor, 10)
Process.sleep(100)

IO.inspect(Gudang.stok(), label: "stok terkini")
text Keluaran
ambil 2 buku elixir_dasar: {:ok, 3}
ambil 5 buku otp_lanjutan: {:error, :stok_kurang}
laporan: %{elixir_dasar: 3, otp_lanjutan: 6}
stok terkini: %{elixir_dasar: 3, otp_lanjutan: 6}

Keluaran di atas menunjukkan tiga keputusan yang dikembalikan kontrak call. Permintaan ambil pertama berhasil karena stoknya cukup, sedangkan permintaan kedua gagal dengan {:error, :stok_kurang} tanpa mengubah state apa pun, karena klausa else mengembalikan state lama. Baris laporan dicetak dari dalam handle_info/2 setelah pesan cast selesai diproses, sehingga isinya sudah memuat tambahan empat buku. Baris terakhir membaca state melalui call dan menghasilkan angka yang sama, yang membuktikan bahwa kedua callback membawa state yang konsisten. Jeda Process.sleep/1 setelah cast menegaskan sifat asinkronnya, karena tanpa jeda itu, laporan dapat dicetak sebelum perubahan stok selesai diproses. Skripnya dapat dijalankan langsung dari terminal setelah berkasnya disimpan:

bash
# menjalankan skrip gudang dari folder topik
elixir gudang.exs
text Keluaran
ambil 2 buku elixir_dasar: {:ok, 3}
ambil 5 buku otp_lanjutan: {:error, :stok_kurang}
laporan: %{elixir_dasar: 3, otp_lanjutan: 6}
stok terkini: %{elixir_dasar: 3, otp_lanjutan: 6}

Kontrak call saat server mati#

GenServer.call/3 bukan sekadar fungsi pengirim pesan, melainkan juga penjaga kepastian balasan. Apabila server sudah berhenti sebelum call dikirim, pemanggil menerima exit dengan alasan :noproc, dan exit itu ikut menghentikan process pemanggil apabila tidak ditangkap. Perilaku ini dapat diamati secara terkendali dengan try/catch yang menangkap :exit:

elixir Contoh: call ke server yang tidak ada
# call ke nama yang tidak terdaftar membuat pemanggil menerima exit :noproc
try do
  GenServer.call(:tidak_ada, :cek)
catch
  :exit, alasan -> IO.puts("exit ditangkap: #{inspect(alasan)}")
end
text Keluaran
exit ditangkap: {:noproc, {GenServer, :call, [:tidak_ada, :cek, 5000]}}

Angka 5000 pada keluaran adalah batas waktu default call, yaitu lima detik. Apabila server ada tetapi lambat menjawab, call melempar exit dengan alasan :timeout, dan pemanggil yang telah melewati batas itu berhenti. Batas tersebut dapat diganti per pemanggilan melalui argumen ketiga, misalnya GenServer.call(:gudang, :stok, 1000). Angka batas yang jauh melebihi lima detik biasanya menjadi tanda bahwa pekerjaan berat sebaiknya dipindahkan ke Task, karena pemanggil yang menunggu terlalu lama juga menahan processnya sendiri.

Kontrak call dengan demikian bukan hanya janji balasan, melainkan juga ukuran kesehatan server. Pemanggil yang sering menerima :timeout pada satu server biasanya menunjuk pada callback yang terlalu berat, bukan pada batas waktu yang terlalu ketat. Penanganan yang lebih halus dibahas pada modul Toleransi kegagalan.

Kesalahan umum#

Kesalahan pertama adalah memanggil API milik server sendiri dari dalam callback-nya. Callback handle_call/3 dijalankan oleh process server, sehingga GenServer.call(:gudang, ...) di dalamnya berarti server mengirim pesan kepada dirinya lalu menunggu balasan yang baru bisa disusun setelah callback saat ini selesai. Kondisi ini adalah deadlock, yaitu saling menunggu tanpa akhir, dan seluruh pesan berikutnya ikut tertahan. Perbaikannya adalah menghitung langsung dari argumen state, karena state terbaru memang sudah berada di tangan callback.

Kesalahan kedua adalah menggunakan cast untuk operasi yang hasilnya dibutuhkan pemanggil. Karena cast langsung mengembalikan :ok, pemanggil yang membaca state tepat setelahnya dapat menerima state lama, persis seperti jarak antara tambah/2 dan laporan pada skrip utuh yang harus dijembatani oleh Process.sleep/1. Apabila hasil perubahan dibutuhkan, gunakan call, dan sisakan cast untuk operasi yang kegagalannya dapat terlihat belakangan.

Kesalahan ketiga adalah menangani pesan send biasa di handle_cast/2. Pesan yang dikirim dengan send selalu masuk ke handle_info/2, sehingga klausa yang diletakkan di tempat lain tidak pernah dipanggil dan pesan itu menumpuk di mailbox. Batas waktunya pun berbeda, yaitu call dibatasi lima detik, sedangkan cast dan pesan info tidak memiliki batas sama sekali.

Ringkasan#

  • GenServer mengotomatiskan loop process berkondisi, dan penulis modul hanya menyediakan init/1, handle_call/3, handle_cast/2, serta handle_info/2 sesuai jenis pesannya.
  • call bersifat sinkron dengan balasan dan batas waktu, cast bersifat asinkron tanpa jaminan pemrosesan, sedangkan handle_info/2 menampung pesan yang dikirim dengan send.
  • API publik dan callback hidup dalam satu modul tetapi berperan berbeda, sehingga bentuk pesan menjadi detail internal yang bebas diubah.
  • Pemanggilan call ke server yang berhenti menghasilkan exit :noproc pada pemanggil, dan pemanggilan API dari dalam callback server sendiri berujung deadlock.

Referensi#