cowboy로 websocket server 만들기

cowboy를 이용해서 웹소켓 연결을 받아 처리하는 예제를 작성해본다. 웹소켓 클라이언트는 node.js로 작성했다.

웹소켓 서버 코드는 cowboy의 websocket example을 기반으로 했다. 웹소켓 핸들러 코드는 cowboy 2.0 websocket handler guide 문서를 참고했다. rebar3를 이용한 cowboy 기반 서버 프로젝트 생성은 Erlang, Rebar3, and Cowboy article이 많은 도움이 되었다.

먼저, rebar3로 프로젝트를 생성하자.

$ rebar3 new app myapp

rebar.config 파일에 cowboy를 dependency package로 추가한다.

{deps, [
    {cowboy, {git, "https://github.com/ninenines/cowboy.git", {tag, "2.0.0-pre.7"}}}
  ]}.

cowboy2.0.0-pre.7 버전을 갖고 오라는 의미이다. 특정 버전 대신 최신 HEAD 버전을 갖고오고자 할 경우에는 아래와 같이 tag 정보를 빼면 된다.

{deps, [
    {cowboy, {git, "https://github.com/ninenines/cowboy.git"}}
  ]}.

최소 2.0.0-pre.4 이상 버전으로 갖고 와야 Cowboy 2.0 용 WebSocket Handlers 문서 대로 해볼 수 있다.

이 참에 rebar3의 rebar3_run 플러그인을 사용해서 쉽게 실행할 수 있도록 하자. _build/default/rel/myapp/bin/myapp console 처럼 실행하는 대신 간단하게 rebar3 run하고 실행할 수 있다. rebar.config에 아래와 같이 rebar3_run 플러그인을 추가한다. 이 플러그인은 release 관련 설정도 필요로 하기 때문에, relx 설정도 추가한다.

{plugins, [rebar3_run]}.

{relx, [{release, {myapp, "0.0.1"}, [myapp]},
        {dev_mode, true},
        {include_erts, false},
        {extended_start_script, true}]}.

myapp.app.srccowboy를 추가해서 app 시작 시 cowboy도 실행하도록 하자.

{applications,
 [kernel,
  stdlib,
  cowboy    % <- here
 ]},

여기에서 한번 잘 실행되는 지 확인해보자. 1> 프롬프트가 떨어지면 q().을 입력해서 종료하자.

$ rebar3 run
===> Verifying dependencies...
(...)
Eshell V8.2  (abort with ^G)
(myapp@MacBook-Pro)1> q().

rebar3로 생성한 소스 코드 중에 src/myapp_app.erl 파일을 수정한다. 웹 서버의 "/" 경로로 들어온 연결을 websocket으로 바꾸고 ws_handler 모듈을 통해 처리하게 될 것이다.

%% from
start(_StartType, _StartArgs) ->
  myapp_sup:start_link().
%% to
start(_Type, _Args) ->
  Routes = [
    {'_', [
      {"/", ws_handler, []}
    ]}],
  Dispatch = cowboy_router:compile(Routes),
  NAcceptors = 100,
  TransOpts = [{ip, {0, 0, 0, 0}}, {port, 8080}],
  ProtoOpts = #{env => #{dispatch => Dispatch}},
  {ok, _} = cowboy:start_clear(http, NAcceptors, TransOpts, ProtoOpts),
  myapp_sup:start_link().

그리고 src/ws_handler.erl 파일을 만들어 다음과 같이 작성한다. 코드는 examples/websocket/에 있는 것을 그대로 사용했다.

-module(ws_handler).

-export([init/2]).
-export([websocket_init/1]).
-export([websocket_handle/2]).
-export([websocket_info/2]).

init(Req, Opts) ->
  {cowboy_websocket, Req, Opts}.

websocket_init(State) ->
  erlang:start_timer(1000, self(), <<"Hello!">>),
    {ok, State}.

websocket_handle({text, Msg}, State) ->
  {reply, {text, << "That's what she said! ", Msg/binary >>}, State};
websocket_handle(_Data, State) ->
  {ok, State}.

websocket_info({timeout, _Ref, Msg}, State) ->
  erlang:start_timer(1000, self(), <<"How' you doin'?">>),
    {reply, {text, Msg}, State};
websocket_info(_Info, State) ->
  {ok, State}.

이제 웹소켓 클라이언트를 만들어보자. node.js나 npm은 이미 설치되어 있다고 가정한다. 위 웹소켓 서버 프로젝트의 루트 디렉토리에 client/ 디렉토리를 만들고 이 안에 다음과 같이 ws_client.js를 작성한다.

var WebSocket = require('ws');
var ws = new WebSocket('ws://localhost:8080/');

ws.on('open', function open() {
    ws.send('Hi!');
});

ws.on('message', function(data, flags) {
    console.log('receive: ' + data);
});

ws.on('error', function(error) {
    console.log('error: ' + error);
});

ws 모듈을 설치한다.

$ npm install --save ws

서버와 클라이언트를 차례대로 실행해서 테스트한다.

$ rebar3 run
===> Verifying dependencies...
===> Compiling myapp
===> Starting relx build process ...
(...)
Erlang/OTP 19 [erts-8.2.2] [source] [64-bit] [smp:4:4] [async-threads:10] [hipe] [kernel-poll:false] [dtrace]

Eshell V8.2.2  (abort with ^G)
(myapp@MacBook-Pro)1> 
$ node ws_client.js
receive: That's what she said! Hi!
receive: Hello!
receive: How' you doin'?
receive: How' you doin'?
receive: How' you doin'?
receive: How' you doin'?
receive: How' you doin'?
...