GenServer 모듈 분리하기

posted by donghyun

5 min read

태그

GenServer 모듈 분리하기

Conding Gnome 강의에서 나왔던 이야기 한소절. 흥미로워서 기록해둠

다음은 대부분의 elixir 사용자들이 짜는 코드다. Game 서버를 만들고 Game의 로직을 집어넣는다.

defmodule Hangman.Game do
  defstruct(
    turns_left: 7,
    game_state: :initializing,
    letters: [],
    used: MapSet.new()
  )
  use GenServer

  alias Hangman.Game

  ##############################
  # Hangman API #
  #########################
  def new_game() do
    {:ok, pid} = Supervisor.start_child(Hangman.Supervisor, [])
    pid
  end

  def make_move(game_pid, guess) do
    GenServer.call(game_pid, {:make_move, guess})
  end

  def tally(game_pid) do
    GenServer.call(game_pid, {:tally})
  end

  ##################################
  # GenServer initialization #
  ################################

  def start_link() do
    GenServer.start_link(__MODULE__, Dictionary.random_word)
  end

  def start_link(word) do
    GenServer.start_link(__MODULE__, word)
  end

  ##################################
  # GenServer initialization #
  ################################

  def init(word) do
    {:ok, create_state(word)}
  end

  def handle_call({:make_move, guess}, _from, game) do
    do_make_move(game, guess)
    |> reply_call()
  end

  def handle_call({:tally}, _from, game) do
    {game, do_tally(game)}
    |> reply_call()
  end

  defp reply_call({game, tally}) do
    {:reply, tally, game}
  end

  ##################################
  # Game implementation #
  ################################

  defp do_make_move(game = %{game_state: state}, _guess) when state in [:won, :lost] do
    return_with_tally(game)
  end

  def do_make_move(game, guess) do
    accept_move(game, guess, MapSet.member?(game.used, guess))
    |> return_with_tally()
  end

  def do_tally(game) do
    %{
      game_state: game.game_state,
      turns_left: game.turns_left,
      used: game.used |> MapSet.to_list() |> Enum.sort()
      letters: game.letters |> reveal_guessed(game.used)
    }
  end

  defp create_state(word) do
    %Hangman.Game{
      letters: word |> String.codepoints()
    }
  end

  defp accept_move(game, _guess, _already_guessed = true) do
    Map.put(game, :game_state, :already_used)
  end

  defp accept_move(game, guess, _already_guessed) do
    Map.put(game, :used, MapSet.put(game.used, guess))
    |> score_guess(Enum.member?(game.letters, guess))
  end

  defp score_guess(game, _good_guess = true) do
    new_state =
      MapSet.new(game.letters)
      |> MapSet.subset?(game.used)
      |> maybe_won()

    Map.put(game, :game_state, new_state)
  end

  defp score_guess(game = %{turns_left: 1}, _not_good_guess = false) do
    Map.put(game, :game_state, :lost)
  end

  defp score_guess(game = %{turns_left: turns_left}, _not_good_guess = false) do
    %{
      game
      | game_state: :bad_guess,
        turns_left: turns_left - 1
    }
  end

  defp reveal_guessed(letters, used) do
    letters
    |> Enum.map(fn letter -> reveal_letter(letter, MapSet.member?(used, letter)) end)
  end

  defp reveal_letter(letter, _in_word = true), do: letter
  defp reveal_letter(_letter, _not_in_word), do: "_"

  defp maybe_won(true), do: :won
  defp maybe_won(_), do: :good_guess

  defp return_with_tally(game), do: {game, tally(game)}
end

서버 api 로직과 Game api 로직이 모두 모여서 알아보기 어렵게 만든다. 함수 각각이 api용 함수인지 비즈니스 로직용 함수인지 구분도 어렵다. 강사는 다음과 같이 서버 모듈과 비즈니스 로직 모듈을 나누길 추천한다.

# hangman/server.ex
defmodule Hangman.Server do
  use GenServer

  alias Hangman.Game

  def start_link(_) do
    GenServer.start_link(__MODULE__, nil)
  end

  def init(_) do
    {:ok, Game.new_game()}
  end

  def make_move(pid, guess) do
    GenServer.call(pid, {:make_move, guess})
  end

  #########

  def handle_call({:make_move, guess}, _from, game) do
    Game.make_move(game, guess)
    |> reply_call()
  end

  def handle_call({:tally}, _from, game) do
    {game, Game.tally(game)}
    |> reply_call()
  end

  defp reply_call({game, tally}) do
    {:reply, tally, game}
  end
end

# hangman/game.ex
defmodule Hangman.Game do
  defstruct(
    turns_left: 7,
    game_state: :initializing,
    letters: [],
    used: MapSet.new()
  )

  def new_game(word) do
    %Hangman.Game{
      letters: word |> String.codepoints()
    }
  end

  def new_game() do
    new_game(Dictionary.random_word())
  end

  def make_move(game = %{game_state: state}, _guess) when state in [:won, :lost] do
    return_with_tally(game)
  end

  def make_move(game, guess) do
    accept_move(game, guess, MapSet.member?(game.used, guess))
    |> return_with_tally()
  end

  def tally(game) do
    %{
      game_state: game.game_state,
      turns_left: game.turns_left,
      letters: game.letters |> reveal_guessed(game.used)
    }
  end

  #############################################################################

  defp accept_move(game, _guess, _already_guessed = true) do
    Map.put(game, :game_state, :already_used)
  end

  defp accept_move(game, guess, _already_guessed) do
    Map.put(game, :used, MapSet.put(game.used, guess))
    |> score_guess(Enum.member?(game.letters, guess))
  end

  defp score_guess(game, _good_guess = true) do
    new_state =
      MapSet.new(game.letters)
      |> MapSet.subset?(game.used)
      |> maybe_won()

    Map.put(game, :game_state, new_state)
  end

  defp score_guess(game = %{turns_left: 1}, _not_good_guess = false) do
    Map.put(game, :game_state, :lost)
  end

  defp score_guess(game = %{turns_left: turns_left}, _not_good_guess = false) do
    %{
      game
      | game_state: :bad_guess,
        turns_left: turns_left - 1
    }
  end

  defp reveal_guessed(letters, used) do
    letters
    |> Enum.map(fn letter -> reveal_letter(letter, MapSet.member?(used, letter)) end)
  end

  defp reveal_letter(letter, _in_word = true), do: letter
  defp reveal_letter(_letter, _not_in_word), do: "_"

  defp maybe_won(true), do: :won
  defp maybe_won(_), do: :good_guess

  defp return_with_tally(game), do: {game, tally(game)}
end