Phoenix Controller 직접 추가하기 2nd
한달 전에 Phoenix Controller를 generator 없이 직접 작성하여 목록을 출력하는 것까지 해봤었다.
위의 방법 대로 했을 때 목록과 상세정보 보기까지는 좋았지만, form을 이용한 추가와 편집을 하는 데에는 사용할 수 없었다. 한두 주 동안 시행착오를 겪고 나서 몇 가지 수정을 더했다.
Ecto 추가
Ecto 라이브러리를 최대한 배제하고 defstruct
구조체와 직접 작성한 validator를 사용하려고 했었지만, form을 이용하여 값을 주고 받는 데에는 불편한 점이 많았다. 특히 구조체나 map의 키가 모듈의 내부에서는 보통 atom 타입이지만 form과 주고받을 때는 string 타입이기 때문에 그 사이의 변환이 거추장스러워진다. 그래서 Ecto.Changeset까지는 적용해보기로 했다.
한달 전까지는 아래와 같이 defstruct
를 사용했었다.
defmodule Product do
defstruct [:id, :title, :description, :price, :views]
end
Ecto.Changeset에 기반하여 다음과 같이 수정했다.
lib/hello/product.ex 파일을 생성하여 추가한다. Hello.Product
모듈은 나중에 따로 추가할 것이다.
defmodule Product do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
embedded_schema do
field :id, :integer
field :title, :string
field :description, :string
field :price, :decimal
field :views, :integer
end
def changeset(product, attrs) do
product
|> cast(attrs, [:id, :title, :description, :price, :views])
end
end
우선 Ecto.Schema
와 Ecto.Changeset
관련 함수들을 사용할 수 있도록 import한다.
멤버 프로퍼티들은 defstruct
대신 embedded_schema
로 정의했다. DBMS에 테이블로 저장하는 경우면 schema
로 이름까지 추가하여 사용하겠지만, API call 또는 내부 메모리에 구조체로 저장할 것이라서 선택했다.
changeset/2
함수를 정의해서 html form과 값을 주고받을 수 있도록 하였다. Ecto validator도 필요하면 pipe 연산자 통해서 추가하면 된다.
:id
를 테이블 인덱스로 Ecto에서 사용하는 대신 직접 정의하여 사용하도록 하기 위해 @primary_key false
를 추가했다.
마지막으로 mix.exs
에 Ecto에 대한 dependency를 추가한다.
defp deps do
[
+ {:phoenix_ecto, "~> 4.4"},
{:phoenix, "~> 1.7.0"},
번외: MyRepo 추가하기
GenServer 기반 모듈을 별도로 작성하여 Repo 처럼 사용하였다. CRUD 하는 데이터는 메모리에서 관리한다. DBMS를 사용할 경우 Ecto.Repo를 사용하면 되고, 다른 API Server에 call을 해도 된다.
lib/hello/ 밑에 my_repo.ex를 작성한다. 처음부터 데이터 레코드 2개르 들고 있도록 했다.
defmodule Hello.MyRepo do
use GenServer
# api/helpers
def start_link(state \\ []) do
GenServer.start_link(__MODULE__, state, name: __MODULE__)
end
def get_list(), do: GenServer.call(__MODULE__, :get_list)
def get_info(id), do: GenServer.call(__MODULE__, {:get_info, id})
def create(product), do: GenServer.call(__MODULE__, {:create, product})
def delete(id), do: GenServer.call(__MODULE__, {:delete, id})
def update(id, product), do: GenServer.call(__MODULE__, {:update, id, product})
# callback functions
@product_list [
%Product{ id: 1,
title: "hello", description: "1st word of \"Hello World\".",
price: 0, views: 0},
%Product{ id: 2,
title: "world", description: "2nd word of \"Hello World\".",
price: 0, views: 0},
]
def init(state), do: {:ok, state ++ @product_list}
def handle_call(:get_list, _from, state), do: {:reply, state, state}
def handle_call({:get_info, id}, _from, state) do
id = to_int(id)
product = Enum.find(state, fn p -> p.id == id end)
product = if product == nil, do: %Product{}, else: product
{:reply, product, state}
end
def handle_call({:create, product}, _from, state), do: {:reply, :ok, [product | state]}
def handle_call({:delete, id}, _from, state) do
id = to_int(id)
new_state = Enum.reject(state, fn p -> p.id == id end)
{:reply, :ok, new_state}
end
def handle_call({:update, id, product}, _from, state) do
id = to_int(id)
case Enum.find(state, fn p -> p.id == id end) do
nil -> {:reply, :not_found, state}
_ ->
new_state = Enum.reject(state, fn p -> p.id == id end)
{:reply, :ok, [product | new_state]}
end
end
defp to_int(i) when is_binary(i), do: String.to_integer i
defp to_int(i) when is_bitstring(i), do: String.to_integer i
defp to_int(i) when is_integer(i), do: i
end
to_int/1
함수는 id
를 무지성으로 정수로 바꾸기 위한(...) 함수이다.
프로젝트 실행 시에 MyRepo
모듈이 실행될 수 있도록 lib/hello/application.ex에 다음과 같이 수정을 추가한다.
children = [
HelloWeb.Telemetry,
{Phoenix.PubSub, name: Hello.PubSub},
- HelloWeb.Endpoint
+ HelloWeb.Endpoint,
+ Hello.MyRepo
]
Hello.Product 추가
위에서 lib/hello/product.ex에 Product
모듈을 추가했는데, 그 아래에 다음과 같이 Hello.Product
모듈을 추가한다.
defmodule Hello.Product do
defp get_updated(product, updated) do
product
|> get_changeset(updated)
|> apply_changes()
end
def get_changeset(), do: get_changeset(%Product{})
def get_changeset(%Product{} = product, attrs \\ %{}) do
Product.changeset(product, attrs)
end
end
get_changeset
함수는 Product
모듈의 changeset/2
함수를 호출하여 체인지셋을 만들어 반환한다.
get_updated/2
함수는 원 데이터에 변경 사항을 반영한 %Product{} 구조체를 반환한다.
같은 모듈에 계속해서 MyRepo
와 인터페이싱하는 함수들을 추가한다.
alias Hello.MyRepo
def get_list(), do: MyRepo.get_list
def get_info(id), do: MyRepo.get_info(id)
def create(product_params) do
product = %Product{} |> get_updated(product_params)
:ok = MyRepo.create(product)
{:ok, MyRepo.get_info(product.id)}
end
def update(id, product_params) do
product = get_info(id) |> get_updated(product_params)
:ok = MyRepo.update(id, product)
{:ok, MyRepo.get_info(product.id)}
end
def delete(id), do: MyRepo.delete id
# => :ok
이 함수들은 HelloWeb.ProductController
모듈에서 호출한다.
HelloWeb.ProductController 수정
이제 내부 모듈의 로직에서 처리하는 값들을 웹 인터페이스를 통해 표출하는 부분을 수정한다. 지난 번에는 index/2
함수만 작성했었다. 이를 다음과 같이 수정한다.
def index(conn, _params) do
render(conn, :index, products: Hello.Product.get_list)
end
저장하고 바로 mix phx.server
로 실행한 다음 웹 브라우저에서 http://localhost:4000/products
에 접속한다. 바로 아이템 두 개를 볼 수 있다. 지금까지 작성한 바와 같이 HelloWeb.ProductController에서 Hello.Product를 거쳐 MyRepo에서 들고 있는 레코드들을 반환한 것이다.
이제 index 외에 나머지들도 수정한다. router.ex를 아래와 같이 수정한다.
get "/", PageController, :home
- resources "/products", ProductController, only: [:index]
+ resources "/products", ProductController
그리고 index/2
외의 나머지 함수들을 추가한다.
def new(conn, _params) do
changeset = Hello.Product.get_changeset()
render(conn, :new, changeset: changeset)
end
def create(conn, %{"product" => product_params}) do
{:ok, product} = Hello.Product.create(product_params)
conn
|> put_flash(:info, "Product created successfully.")
|> redirect(to: ~p"/products/#{product.id}")
end
def show(conn, %{"id" => id}) do
product = Hello.Product.get_info(id)
render(conn, :show, product: product)
end
def edit(conn, %{"id" => id}) do
product = Hello.Product.get_info(id)
changeset = Hello.Product.get_changeset(product)
render(conn, :edit, product: product, changeset: changeset)
end
def update(conn, %{"id" => id, "product" => product_params}) do
{:ok, product} = Hello.Product.update(id, product_params)
conn
|> put_flash(:info, "Product updated successfully.")
|> redirect(to: ~p"/products/#{product.id}")
end
def delete(conn, %{"id" => id}) do
:ok = Hello.Product.delete(id)
conn
|> put_flash(:info, "Product deleted successfully.")
|> redirect(to: ~p"/products")
end
각 함수들의 몸체를 보면 알 수 있듯이 각각 Hello.Product
모듈의 함수들을 호출하여 받은 결과를 처리한다. 여기서 HelloWeb.ProductController
와 Hello.Product
모듈은 귀찮고 간단하게 보여주는데 중점을 두었기 때문에, 보완의 여지가 많다. :ok
가 아닌 다른 값이 반환됐을 때의 동작이라든가, id
중복 체크 기능 등은 필수로 들어가야 할 기능들이다.
.heex 파일 작성
lib/hello_web/controllers/product_html/ 밑에 위치하는 .heex 파일들을 작성한다. 각 파일들은 다음의 경우에 호출된다.
- index.html.heex -
GET /products
에 의한 product list 출력 - show.html.heex -
GET /products/:id
로 특정 product의 정보 표시 - new.html.heex, product_new_form.html.heex -
GET /products/new
에 의해 새로운 product를 작성하기 위한 form을 출력한다. save 버튼을 누르면POST /products
에 의해 새 product 정보를 등록한다. - edit.html.heex, product_edit_form.html.heex -
GET /products/:id/edit
로:id
product의 내용을 수정하는 form을 출력한다. save 버튼을 누르면PUT /products/:id
로:id
product 정보를 갱신한다.
여기서는 이 중 update에 관련한 edit.html.heex 및 product_edit_form.html.heex 파일 내용을 리스트한다.
<%!-- edit.html.heex --%>
<.header>
Product "<%= @product.title %>"
<:subtitle>Edit product here.</:subtitle>
</.header>
<.product_edit_form changeset={@changeset} action={~p"/products/#{@product.id}"} />
<.back navigate={~p"/products"}>Back</.back>
@product
와 @changeset
바인딩은 HelloWeb.ProductController의 edit/2
함수의 마지막 render
함수에 의해 전달된다.
render(conn, :edit, product: product, changeset: changeset)
마지막의 .product_edit_form
에 따라 product_edit_form.html.heex 파일도 같이 엮이게 된다. 이 때 @changeset
과 @action
바인딩을 같이 넘기게 된다.
<.simple_form :let={f} for={@changeset} action={@action} method="PUT">
<.input field={f[:id]} type="number" label="ID" readonly />
<.input field={f[:title]} type="text" label="Title" />
<.input field={f[:description]} type="text" label="Description" />
<.input field={f[:price]} type="number" label="Price" />
<.input field={f[:views]} type="number" label="Views" />
<:actions>
<.button>Save</.button>
</:actions>
</.simple_form>
method="PUT"
코드는, simple_form
의 기본 method가 POST
이기 때문에 강제로 PUT
으로 바꾸어 사용토록 하기 위해 추가한 것이다.
마치며
초기의 '모든 코드를 직접 작성'하겠다는 취지에서 한 발 물러나서 Ecto 모듈을 받아들였지민 덕분에 Ecto에 대한 이해도도 높아지고 (Ecto 학습을 전제로 해야하지만) 코드를 간결하게 만들 수 있었다. Phoenix를 활용한 개발에 대해서는 이것저것 만져볼 수록 앞으로 해야 할 것이 더 많이 보이지만 그를 위한 기본기를 닦을 수 있는 좋은 경험이 되었다고 본다.