Doctests, 패턴, 그리고 with
Doctests, 패턴, 그리고 with
이번 장에서는 첫 번째 장에서 다뤘던 명령들을 파싱하는 코드를 구현할 거예요.
CREATE shopping
OK
PUT shopping milk 1
OK
PUT shopping eggs 3
OK
GET shopping milk
1
OK
DELETE shopping eggs
OK
파싱이 끝나면, 서버를 업데이트해서 파싱된 명령을 관련 버킷(bucket)으로 전달하도록 만들 거예요.
본문
Doctests
언어 홈페이지에서 Elixir는 문서를 일급 시민(first-class citizen)으로 만든다고 했어요. 이 개념은 이 가이드 전체에서 mix help를 통해, 또는 IEx 콘솔에서 h Enum 같은 모듈을 입력해 여러 번 살펴봤어요.
이번 섹션에서는 파싱 기능을 구현하고 문서화한 뒤, doctest로 문서가 최신 상태인지 확인할 거예요. 이렇게 하면 정확한 코드 예시를 담은 문서를 제공할 수 있어요.
명령 파서를 lib/kv/command.ex에 만들고, doctest부터 시작해 봅시다.
defmodule KV.Command do
@doc ~S"""
Parses the given `line` into a command.
## Examples
iex> KV.Command.parse("CREATE shopping\r\n")
{:ok, {:create, "shopping"}}
"""
def parse(_line) do
:not_implemented
end
end
Doctest는 문서 문자열에서 네 칸 들여쓰기 다음에 iex> 프롬프트가 오는 방식으로 지정해요. 명령이 여러 줄에 걸치면 IEx에서처럼 ...>를 쓸 수 있어요. 기대하는 결과는 iex>나 ...> 줄 다음 줄에서 시작하며, 새 줄(newline)이나 새 iex> 접두사로 끝납니다.
또한 문서 문자열을 @doc ~S"""로 시작했다는 점을 주목하세요. ~S는 \r\n 문자가 테스트에서 평가되기 전에 캐리지 리턴과 라인 피드로 변환되는 것을 막아줘요.
doctest를 실행하기 위해 test/kv/command_test.exs 파일을 만들고 테스트 케이스 안에서 doctest KV.Command를 호출할 거예요.
defmodule KV.CommandTest do
use ExUnit.Case, async: true
doctest KV.Command
end
테스트 스위트를 실행하면 doctest가 실패할 거예요.
1) doctest KV.Command.parse/1 (1) (KV.CommandTest)
test/kv/command_test.exs:3
Doctest failed
doctest:
iex> KV.Command.parse("CREATE shopping\r\n")
{:ok, {:create, "shopping"}}
code: KV.Command.parse "CREATE shopping\r\n" === {:ok, {:create, "shopping"}}
left: :not_implemented
right: {:ok, {:create, "shopping"}}
stacktrace:
lib/kv/command.ex:7: KV.Command (module)
훌륭해요!
이제 doctest를 통과시켜 볼게요. parse/1 함수를 구현해 봅시다.
def parse(line) do
case String.split(line) do
["CREATE", bucket] -> {:ok, {:create, bucket}}
end
end
구현은 줄을 공백(whitespace) 기준으로 나눈 뒤 그 리스트에 대해 명령을 매칭해요. String.split/1을 쓰면 명령이 공백에 둔감(whitespace-insensitive)해져요. 앞뒤 공백은 물론 단어 사이의 연속된 공백도 무시되죠. 이 동작과 다른 명령들을 테스트하기 위해 새 doctest를 몇 개 추가해 볼게요.
@doc ~S"""
Parses the given `line` into a command.
## Examples
iex> KV.Command.parse "CREATE shopping\r\n"
{:ok, {:create, "shopping"}}
iex> KV.Command.parse "CREATE shopping \r\n"
{:ok, {:create, "shopping"}}
iex> KV.Command.parse "PUT shopping milk 1\r\n"
{:ok, {:put, "shopping", "milk", "1"}}
iex> KV.Command.parse "GET shopping milk\r\n"
{:ok, {:get, "shopping", "milk"}}
iex> KV.Command.parse "DELETE shopping eggs\r\n"
{:ok, {:delete, "shopping", "eggs"}}
Unknown commands or commands with the wrong number of
arguments return an error:
iex> KV.Command.parse "UNKNOWN shopping eggs\r\n"
{:error, :unknown_command}
iex> KV.Command.parse "GET shopping\r\n"
{:error, :unknown_command}
"""
doctest가 있으니 이제 여러분이 테스트를 통과시킬 차례예요! 준비가 되면 아래 해답과 비교해 보세요.
def parse(line) do
case String.split(line) do
["CREATE", bucket] -> {:ok, {:create, bucket}}
["GET", bucket, key] -> {:ok, {:get, bucket, key}}
["PUT", bucket, key, value] -> {:ok, {:put, bucket, key, value}}
["DELETE", bucket, key] -> {:ok, {:delete, bucket, key}}
_ -> {:error, :unknown_command}
end
end
명령 이름과 인자 수를 검사하는 if/else 절을 잔뜩 추가하지 않고도 명령을 우아하게 파싱했다는 점에 주목하세요!
마지막으로, 각 doctest가 우리 테스트 스위트에서 서로 다른 테스트에 대응한다는 걸 눈치챘을 거예요. 이제 총 7개의 doctest가 보고됩니다. ExUnit이 다음 둘을 서로 다른 두 개의 doctest로 간주하기 때문이에요.
iex> KV.Command.parse("UNKNOWN shopping eggs\r\n")
{:error, :unknown_command}
iex> KV.Command.parse("GET shopping\r\n")
{:error, :unknown_command}
아래처럼 새 줄 없이 쓰면 ExUnit이 이를 하나의 doctest로 컴파일해요.
iex> KV.Command.parse("UNKNOWN shopping eggs\r\n")
{:error, :unknown_command}
iex> KV.Command.parse("GET shopping\r\n")
{:error, :unknown_command}
이름이 말해주듯, doctest는 문서가 먼저이고 테스트가 나중이에요. 목적은 테스트를 대체하는 게 아니라 최신 상태의 문서를 제공하는 거예요. doctest에 대해 더 자세히 알고 싶다면 ExUnit.DocTest 문서를 참고하세요.
with 사용하기
이제 명령을 파싱할 수 있으니, 마침내 명령을 실행하는 로직을 구현하기 시작할 수 있어요. 일단 이 함수의 스텁(stub) 정의를 추가해 봅시다.
defmodule KV.Command do
@doc """
Runs the given command.
"""
def run(command, socket) do
:gen_tcp.send(socket, "OK\r\n")
:ok
end
end
이 함수를 구현하기 전에, 서버가 새 parse/1과 run/1 함수를 쓰도록 바꿔 봅시다. read_line/1 함수가 클라이언트가 소켓을 닫았을 때 크래시 했던 것도 기억하세요. 그 기회에 고치도록 하죠. lib/kv/server.ex를 열어 기존 서버 정의를 아래 것으로 교체해 봅시다.
defp serve(socket) do
socket
|> read_line()
|> write_line(socket)
serve(socket)
end
defp read_line(socket) do
{:ok, data} = :gen_tcp.recv(socket, 0)
data
end
defp write_line(line, socket) do
:gen_tcp.send(socket, line)
end
다음을 아래로 교체해요.
defp serve(socket) do
msg =
case read_line(socket) do
{:ok, data} ->
case KV.Command.parse(data) do
{:ok, command} ->
KV.Command.run(command, socket)
{:error, _} = err ->
err
end
{:error, _} = err ->
err
end
write_line(socket, msg)
serve(socket)
end
defp read_line(socket) do
:gen_tcp.recv(socket, 0)
end
defp write_line(_socket, :ok) do
:ok
end
defp write_line(socket, {:error, :unknown_command}) do
# Known error; write to the client
:gen_tcp.send(socket, "UNKNOWN COMMAND\r\n")
end
defp write_line(_socket, {:error, :closed}) do
# The connection was closed, exit politely
exit(:shutdown)
end
defp write_line(socket, {:error, error}) do
# Unknown error; write to the client and exit
:gen_tcp.send(socket, "ERROR\r\n")
exit(error)
end
서버를 시작하면 이제 명령을 보낼 수 있어요. 일단 두 가지 응답을 받게 될 거예요 — 명령이 알려져 있으면 "OK", 그렇지 않으면 "UNKNOWN COMMAND" 말이죠.
$ telnet 127.0.0.1 4040
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
CREATE shopping
OK
HELLO
UNKNOWN COMMAND
이건 우리 구현이 올바른 방향으로 가고 있다는 뜻이지만, 뭔가 우아해 보이지는 않죠?
이전 구현은 파이프라인을 사용해서 로직을 따라가기 쉬웠어요. 하지만 이제 중간에 여러 오류 코드를 처리해야 하다 보니, 서버 로직이 많은 case 호출 안에 중첩되어 있어요.
다행히 Elixir에는 with 구문이 있어서 위와 같은 코드를 단순화할 수 있어요. 중첩된 case 호출을 매칭 절의 체인으로 바꿔 주죠. serve/1 함수를 with를 쓰도록 다시 작성해 볼게요.
defp serve(socket) do
msg =
with {:ok, data} <- read_line(socket),
{:ok, command} <- KV.Command.parse(data),
do: KV.Command.run(command, socket)
write_line(socket, msg)
serve(socket)
end
훨씬 낫죠! with는 <- 오른쪽이 반환한 값을 가져와 왼쪽 패턴에 매칭해요. 값이 패턴과 일치하면 with는 다음 표현식으로 진행하고, 일치하지 않으면 매칭되지 않은 값을 반환합니다.
다시 말해 case/2에 주었던 각 표현식을 with의 한 단계로 바꾼 거예요. 어떤 단계라도 {:ok, x}와 일치하지 않는 값을 반환하면 with는 중단되고 그 매칭되지 않은 값을 반환해요.
with/1에 대해 더 알고 싶다면 우리 문서를 참고하세요.
명령 실행하기(Running commands)
마지막 단계는 KV.Command.run/1을 구현해 파싱된 명령을 버킷 위에서 실행하는 거예요. 구현은 아래와 같아요.
@doc """
Runs the given command.
"""
def run(command, socket)
def run({:create, bucket}, socket) do
KV.create_bucket(bucket)
:gen_tcp.send(socket, "OK\r\n")
:ok
end
def run({:get, bucket, key}, socket) do
lookup(bucket, fn pid ->
value = KV.Bucket.get(pid, key)
:gen_tcp.send(socket, "#{value}\r\nOK\r\n")
:ok
end)
end
def run({:put, bucket, key, value}, socket) do
lookup(bucket, fn pid ->
KV.Bucket.put(pid, key, value)
:gen_tcp.send(socket, "OK\r\n")
:ok
end)
end
def run({:delete, bucket, key}, socket) do
lookup(bucket, fn pid ->
KV.Bucket.delete(pid, key)
:gen_tcp.send(socket, "OK\r\n")
:ok
end)
end
defp lookup(bucket, callback) do
if bucket = KV.lookup_bucket(bucket) do
callback.(bucket)
else
{:error, :not_found}
end
end
각 함수 절은 적절한 명령을 적절한 버킷에 전달해요.
본문 없는 함수 헤드 def run(command, socket)이 있다는 걸 눈치챘을 거예요. 모듈과 함수 챕터에서 본문 없는 함수를 다중 절 함수의 기본 인자를 선언하는 데 쓸 수 있다는 걸 배웠죠. 여기서는 그와 다른 용도로, 인자가 무엇인지 문서화하기 위해 본문 없는 함수를 사용했어요.
또한 버킷을 조회해서 존재하면 그 pid를 반환하고, 그렇지 않으면 {:error, :not_found}를 반환하는 공통 기능을 돕는 lookup/2라는 비공개 함수도 정의했어요.
참고로, 이제 {:error, :not_found}를 반환하므로 KV.Server의 write_line/2 함수도 그런 오류를 출력하도록 수정해야 해요.
defp write_line(socket, {:error, :not_found}) do
:gen_tcp.send(socket, "NOT FOUND\r\n")
end
우리 서버 기능은 거의 완성됐어요. 이제 테스트만 남았네요.
통합 테스트(Integration tests)
KV.Command.run/1의 구현은 명령을 KV 모듈로 직접 보내는데, 이 모듈은 프로세스 이름을 지을 때 로컬 레지스트리를 사용해요. 즉 두 테스트가 같은 버킷에 메시지를 보내면 테스트끼리 충돌할 가능성이 커요. 어떤 사람은 이걸 이유로 mock 등 전략을 써서 테스트를 격리하고 싶어 할 수도 있지만, 그런 기법은 종종 테스트 환경을 실제 프로덕션 실행 방식과 너무 멀어지게 만들어서 버그가 숨어들 수 있어요.
다행히 이 가이드 내내 사용해 온 기법이 여기에도 똑같이 적용돼요. 각 테스트가 고유한 이름을 쓰는 한 로컬 레지스트리에 의존하는 건 괜찮아요. 테스트 모듈 이름과 테스트 이름을 조합하면 그걸 보장하기에 충분합니다.
그러니 고유한 이름에 의존하는 통합 테스트를 작성해서 TCP 서버부터 버킷까지 전체 스택을 동작시켜 볼게요.
test/kv/server_test.exs에 아래처럼 새 파일을 만드세요.
defmodule KV.ServerTest do
use ExUnit.Case, async: true
@socket_options [:binary, packet: :line, active: false]
setup config do
{:ok, socket} = :gen_tcp.connect(~c"localhost", 4040, @socket_options)
test_name = config.test |> Atom.to_string() |> String.replace(" ", "-")
%{socket: socket, name: "#{config.module}-#{test_name}"}
end
test "server interaction", %{socket: socket, name: name} do
# CREATE
assert send_and_recv(socket, "CREATE #{name}\r\n") == "OK\r\n"
# PUT
assert send_and_recv(socket, "PUT #{name} eggs 3\r\n") == "OK\r\n"
# GET
assert send_and_recv(socket, "GET #{name} eggs\r\n") == "3\r\n"
assert send_and_recv(socket, "") == "OK\r\n"
# DELETE
assert send_and_recv(socket, "DELETE #{name} eggs\r\n") == "OK\r\n"
# GET
assert send_and_recv(socket, "GET #{name} eggs\r\n") == "\r\n"
assert send_and_recv(socket, "") == "OK\r\n"
end
test "unknown command", %{socket: socket} do
assert send_and_recv(socket, "WHATEVER\r\n") ==
"UNKNOWN COMMAND\r\n"
end
test "unknown bucket", %{socket: socket} do
assert send_and_recv(socket, "GET whatever eggs\r\n") ==
"NOT FOUND\r\n"
end
defp send_and_recv(socket, command) do
:ok = :gen_tcp.send(socket, command)
{:ok, data} = :gen_tcp.recv(socket, 0, 1000)
data
end
end
mix test를 실행하면 테스트가 모두 통과해야 해요. 다만 현재 테스트와 개발 환경이 같은 포트(4040)에서 실행되고 있으므로, 실행 중인 iex -S mix 세션이 있다면 모두 종료해 주세요. 이 문제는 다음 장에서 다룹니다.
우리는 테스트 세 개를 추가했어요. 첫 번째는 대부분의 버킷 액션을 테스트하고, 나머지 둘은 오류 케이스를 다룹니다. 이 테스트들 사이에 공유할 설정이 많으므로 setup/2 매크로를 사용해 공통 보일러플레이트를 처리했어요. 이 매크로는 테스트가 받는 것과 같은 테스트 컨텍스트를 받고, 테스트마다 TCP 클라이언트 연결을 시작해요. 또한 모듈 이름과 테스트 이름을 사용해 고유한 버킷 이름을 정의하고, 테스트 이름에 있는 공백은 명령 파싱 로직을 방해하지 않도록 -로 바꿔 주죠.
그리고 각 테스트에서 테스트 컨텍스트를 패턴 매칭해 필요한 socket이나 name을 추출해요. 이는 test/kv/bucket_test.exs에서 쓴 코드와 비슷해요.
test "stores values by key on a named process", config do
다만 그때는 config 전체를 매칭했고, 이번에는 필요한 데이터만 매칭했어요.
다음 장으로 넘어가 볼게요. 드디어 시스템을 분산형으로 만들 거예요. 아주 조금의 설정만 추가하면 되고, 스포일러 말하자면 한 줄의 코드만 바꾸면 됩니다.