2014年6月23日

erlang - cowboy - examples

cowboy source code 裡面有一個 examples 目錄,列出多個範例程式,接下來我們藉由閱讀程式碼的方式,了解如何使用 cowboy。

hello_world

主要寫了四支程式

  1. hello_erlang.app.src
    application 設定

  2. hello_erlang_sup.erl
    application 的監督者 supervisor

  3. hello_erlang_app.erl
    application 的 callback module,必須有 start/2 跟 stop/1 function,重點是在 start/2 裡面要 compile Routing 資訊,並啟動 http protocol。

     start(_Type, _Args) ->
         Dispatch = cowboy_router:compile([
             {'_', [
                 {"/", toppage_handler, []}
             ]}
         ]),
         {ok, _} = cowboy:start_http(http, 100, [{port, 8080}], [
             {env, [{dispatch, Dispatch}]}
         ]),
         hello_world_sup:start_link().
  4. hello_handler.erl
    Cowboy 最基本的 HTTP handler,需要實作 init/3, handle/2 and terminate/3 三個 callback functions,細節可參閱 cowboy_http_handler 文件。

    重點是 handle 裡面直接產生 200 的 response,並回傳 Hello world! 的 text/plain 資料。

     handle(Req, State) ->
         {ok, Req2} = cowboy_req:reply(200, [
             {<<"content-type">>, <<"text/plain">>}
         ], <<"Hello world!">>, Req),
         {ok, Req2, State}.

ssl hello world

  1. ssl_hello_world.app.src
    application 設定

  2. ssl_hello_world_sup_erl
    supervisor

  3. ssl_hello_world_app.erl
    重點是在 start/2 裡面要 compile Routing 資訊,並啟動 https protocol,因為ssl的關係,必須指定keystore檔案位置,這些檔案都放在專案的 priv/ssl 目錄下面。

     Dispatch = cowboy_router:compile([
             {'_', [
                 {"/", toppage_handler, []}
             ]}
         ]),
         PrivDir = code:priv_dir(ssl_hello_world),
         {ok, _} = cowboy:start_https(https, 100, [
             {port, 8443},
             {cacertfile, PrivDir ++ "/ssl/cowboy-ca.crt"},
             {certfile, PrivDir ++ "/ssl/server.crt"},
             {keyfile, PrivDir ++ "/ssl/server.key"}
         ], [{env, [{dispatch, Dispatch}]}]),
         ssl_hello_world_sup:start_link().
  4. toppage_handler.erl
    跟 hello world 的 toppage_handler.erl 完全一樣。

測試時必須要連結 https://localhost:8443/ 這個網址。

chunked_hello_world

chunked data transfer with two one-second delays

  1. chunked_hello_world.app.src
    application 設定

  2. chunked_hello_world_sup.erl
    supervisor

  3. chunked_hello_world_app.erl
    跟 hello world 的 hello_erlang_app.erl 完全一樣。

  4. toppage_handler.erl
    handle 裡面直接產生 200 的 chunked_reply,並分階段回傳 Hello World Chunked! 的 text/plain 資料。

     handle(Req, State) ->
         {ok, Req2} = cowboy_req:chunked_reply(200, Req),
         ok = cowboy_req:chunk("Hello\r\n", Req2),
         ok = timer:sleep(1000),
         ok = cowboy_req:chunk("World\r\n", Req2),
         ok = timer:sleep(1000),
         ok = cowboy_req:chunk("Chunked!\r\n", Req2),
         {ok, Req2, State}.

rest_hello_world

根據 http request header 中可接受的 response data mine type 來決定回傳的 response 資料內容。

  1. rest_hello_world.app.src
    application 設定

  2. rest_hello_world_sup.erl
    supervisor

  3. rest_hello_world_app.erl
    application 的 callback module,跟 hello world 的 hello_erlang_app.erl 完全一樣。

  4. toppage_handler.erl
    init/3 要 upgrade protocol 為 cowboy_rest,增加實作content_types_provided/2,此 function 回傳的資料中,包含了支援的 mime type 與 callback function list。

    這些 hello_to_html, hello_to_json, hello_to_text 這些 callback function 裡面提供了不同 mine type 資料的 response Body。

     init(_Transport, _Req, []) ->
         {upgrade, protocol, cowboy_rest}.
    
     content_types_provided(Req, State) ->
         {[
             {<<"text/html">>, hello_to_html},
             {<<"application/json">>, hello_to_json},
             {<<"text/plain">>, hello_to_text}
         ], Req, State}.
    
     hello_to_html(Req, State) ->
         ...
    
     hello_to_json(Req, State) ->
         Body = <<"{\"rest\": \"Hello World!\"}">>,
         {Body, Req, State}.
     hello_to_text(Req, State) ->
         {<<"REST Hello World as text!">>, Req, State}.

測試時,要區分不同的 mine type Request

  1. html
     curl -i http://localhost:8080
  2. json
     curl -i -H "Accept: application/json" http://localhost:8080
  3. text
     curl -i -H "Accept: text/plain" http://localhost:8080

static world

static file handler

  1. staitc_world.app.src
    application 設定

  2. static_world_sup.erl
    supervisor

  3. static_world_app.erl
    編譯 routing 時,路徑為 "/[...]" 代表符合所有以 / 開頭的網址,handler 為 cowboy_static,後面指定 priv 目錄,並設定支援所有 mime types。

     start(_Type, _Args) ->
         Dispatch = cowboy_router:compile([
             {'_', [
                 {"/[...]", cowboy_static, {priv_dir, static_world, "",
                     [{mimetypes, cow_mimetypes, all}]}}
             ]}
         ]),
         {ok, _} = cowboy:start_http(http, 100, [{port, 8000}], [
             {env, [{dispatch, Dispatch}]}
         ]),
         static_world_sup:start_link().

測試網址為 http://koko.maxkit.com.tw:8000/video.html,此範例頁面是直接用 html5 的 video tag 指向 server 的影片檔位址。

web_server

serves files with lists directory entries

  1. web_server.app.src
    application 設定

  2. web_server_sup.erl
    supervisor

  3. web_server_app.erl
    跟上一個 static world 類似,但在 compile routing 時,增加一個 dir_handler,另外在start_http 中,增加一個 directory_lister middleware。

     start(_Type, _Args) ->
         Dispatch = cowboy_router:compile([
             {'_', [
                 {"/[...]", cowboy_static, {priv_dir, web_server, "", [
                     {mimetypes, cow_mimetypes, all},
                     {dir_handler, directory_handler}
                 ]}}
             ]}
         ]),
         {ok, _} = cowboy:start_http(http, 100, [{port, 8080}], [
             {env, [{dispatch, Dispatch}]},
             {middlewares, [cowboy_router, directory_lister, cowboy_handler]}
         ]),
         web_server_sup:start_link().
  4. directory_handler.erl
    這是使用 REST handlers,cowboy_rest 裡面支援多個 resource callback functions,

     init(_Transport, _Req, _Paths) ->
         {upgrade, protocol, cowboy_rest}.
    
      %% 處理 request 時,一開始就先呼叫 rest_init/2
     %% 這個 function 一定要回傳 {ok, Req, State}
     %% State 是 handler 所有 callbacks 的狀態物件。
     rest_init(Req, Paths) ->
         {ok, Req, Paths}.
    
     %% 支援的 HTTP methods
     %% 預設值為 [<<"GET">>, <<"HEAD">>, <<"OPTIONS">>]
     allowed_methods(Req, State) ->
         {[<<"GET">>], Req, State}.
    
     %% 是否存在這個檔案路徑的 resource
     resource_exists(Req, {ReqPath, FilePath}) ->
         case file:list_dir(FilePath) of
             {ok, Fs} -> {true, Req, {ReqPath, lists:sort(Fs)}};
             _Err -> {false, Req, {ReqPath, FilePath}}
         end.
    
     %% 支援什麼 response mime type
     %% 這裡設定支援 application/json 與 text/html 兩種
     content_types_provided(Req, State) ->
         {[
             {{<<"application">>, <<"json">>, []}, list_json},
             {{<<"text">>, <<"html">>, []}, list_html}
         ], Req, State}.
    
     list_json(Req, {Path, Fs}) ->
         Files = [[ <<(list_to_binary(F))/binary>> || F <- Fs ]],
         {jsx:encode(Files), Req, Path}.
    
     list_html(Req, {Path, Fs}) ->
         Body = [[ links(Path, F) || F <- [".."|Fs] ]],
         HTML = [<<"<!DOCTYPE html><html><head><title>Index</title></head>",
             "<body>">>, Body, <<"</body></html>\n">>],
         {HTML, Req, Path}.
    
     links(<<>>, File) ->
         ["<a href='/", File, "'>", File, "</a><br>\n"];
     links(Prefix, File) ->
         ["<a href='/", Prefix, $/, File, "'>", File, "</a><br>\n"].
  5. directory_lister.erl
    支援在瀏覽 http://localhost:8080/ 網址時,把網站的檔案以網頁方式呈現出來,而不是直接顯示 404 Not Found。
    這是middleware,主要就是要實作 execute/2 callback function。

     -module(directory_lister).
     -behaviour(cowboy_middleware).
    
     -export([execute/2]).
    
     execute(Req, Env) ->
         case lists:keyfind(handler, 1, Env) of
             {handler, cowboy_static} -> redirect_directory(Req, Env);
             _H -> {ok, Req, Env}
         end.

測試時,就直接瀏覽網頁 http://localhost:8080/。
另外,這個程式實際上還有些問題,例如:

  1. http://localhost:8080// 當網址後面多了一個 / 的時候,網頁上會列印出機器根目錄的目錄及檔案。

  2. 在 server 的 priv 目錄增加一個目錄 test,但是從瀏覽器瀏覽網頁 http://localhost:8080/test/video.html 卻會出現 500 Error response。瀏覽網頁 http://localhost:8080/test/ 雖然可以看到檔案列表,但 URL 卻都是錯誤的,前面有兩個 //。

echo_get

parse and echo a GET query string

  1. echo_get.app.src
    application 設定

  2. echo_get_sup.erl
    supervisor

  3. echo_get_app.erl
    跟 hello world 的 hello_erlang_app.erl 完全一樣。

  4. toppage_handler.erl
    在 handle 中,取出 GET Method 以及 echo 參數。

     handle(Req, State) ->
         {Method, Req2} = cowboy_req:method(Req),
         {Echo, Req3} = cowboy_req:qs_val(<<"echo">>, Req2),
         {ok, Req4} = echo(Method, Echo, Req3),
         {ok, Req4, State}.
     echo(<<"GET">>, undefined, Req) ->
         cowboy_req:reply(400, [], <<"Missing echo parameter.">>, Req);
     echo(<<"GET">>, Echo, Req) ->
         cowboy_req:reply(200, [
             {<<"content-type">>, <<"text/plain; charset=utf-8">>}
         ], Echo, Req);
     echo(_, _, Req) ->
         %% Method not allowed.
         cowboy_req:reply(405, Req).

測試時,要在網址上增加 echo 參數 http://localhost:8080/?echo=hello,如果測試時沒有 echo 參數,就會得到 400 Error response。

echo_post

  1. echo_post.app.src
  2. echo_post_sup.erl
  3. echo_post_app.erl
  4. toppage_handler.erl
    處理時,先判斷有沒有 POST body,然後在確認有沒有 echo 參數。

     handle(Req, State) ->
         {Method, Req2} = cowboy_req:method(Req),
         HasBody = cowboy_req:has_body(Req2),
         {ok, Req3} = maybe_echo(Method, HasBody, Req2),
         {ok, Req3, State}.
    
     maybe_echo(<<"POST">>, true, Req) ->
         {ok, PostVals, Req2} = cowboy_req:body_qs(Req),
         Echo = proplists:get_value(<<"echo">>, PostVals),
         echo(Echo, Req2);
     maybe_echo(<<"POST">>, false, Req) ->
         cowboy_req:reply(400, [], <<"Missing body.">>, Req);
     maybe_echo(_, _, Req) ->
         %% Method not allowed.
         cowboy_req:reply(405, Req).
    
     echo(undefined, Req) ->
         cowboy_req:reply(400, [], <<"Missing echo parameter.">>, Req);
     echo(Echo, Req) ->
         cowboy_req:reply(200, [
             {<<"content-type">>, <<"text/plain; charset=utf-8">>}
         ], Echo, Req).

用以下這樣的方式測試,第二個測試會得到 400 Error response。

curl -i -d echo=test http://localhost:8080
curl -i -d e=test http://localhost:8000

cookie

  1. cookie.app.src
  2. cookie_sup.erl
  3. cookie_app.erl
  4. toppage_handler.erl
    以 cowboy_req:set_resp_cookie 設定 cookie
     handle(Req, State) ->
         NewValue = integer_to_list(random:uniform(1000000)),
         Req2 = cowboy_req:set_resp_cookie(
             <<"server">>, NewValue, [{path, <<"/">>}], Req),
         {ClientCookie, Req3} = cowboy_req:cookie(<<"client">>, Req2),
         {ServerCookie, Req4} = cowboy_req:cookie(<<"server">>, Req3),
         {ok, Body} = toppage_dtl:render([
             {client, ClientCookie},
             {server, ServerCookie}
         ]),
         {ok, Req5} = cowboy_req:reply(200,
             [{<<"content-type">>, <<"text/html">>}],
             Body, Req4),
         {ok, Req5, State}.

erlydtl 編譯一直出現問題,所以我們就先修改 toppage_handler.erl,去掉 erlydtl 的相依性設定。

handle(Req, State) ->
    NewValue = integer_to_list(random:uniform(1000000)),
    Req2 = cowboy_req:set_resp_cookie(
        <<"server">>, NewValue, [{path, <<"/">>}], Req),
    {ClientCookie, Req3} = cowboy_req:cookie(<<"client">>, Req2, <<"default">>),
    {ServerCookie, Req4} = cowboy_req:cookie(<<"server">>, Req3, <<"default">>),
    Body=list_to_binary([<<"<html><body>client cookie=">>, ClientCookie, <<"<br/>server cookie=">>, ServerCookie, <<"</body></html>">>]),
    {ok, Req5} = cowboy_req:reply(200,
        [{<<"content-type">>, <<"text/html">>}],
        Body, Req4),
    {ok, Req5, State}.

重新編譯測試後,第一次瀏覽網頁 http://localhost:8080/ 會看到

client cookie=default
server cookie=default

再瀏覽一次 http://localhost:8080/ 會看到

client cookie=default
server cookie=443585

websocket

  1. websocket.app.src
  2. websocket_sup.erl
  3. websocket_app.erl
    依照網址規則順序,/ 為 index.html 靜態首頁,/websocket 以 module ws_handler 處理,/static/[...] 對應到 priv/static 目錄裡面的靜態頁面。

     Dispatch = cowboy_router:compile([
             {'_', [
                 {"/", cowboy_static, {priv_file, websocket, "index.html"}},
                 {"/websocket", ws_handler, []},
                 {"/static/[...]", cowboy_static, {priv_dir, websocket, "static"}}
             ]}
         ]),
  4. ws_handler.erl
    必須指定 -behaviour(cowboy_websocket_handler)

    將此連線改為 cowboy_websocket protocol

     init({tcp, http}, _Req, _Opts) ->
         {upgrade, protocol, cowboy_websocket}.

    實作 websocket_init, websocket_handle, websocket_info, websocket_terminate 四個 protocol

     websocket_init(_TransportName, Req, _Opts) ->
         %% 1s 後發送回應
         erlang:start_timer(1000, self(), <<"Hello!">>),
         {ok, Req, undefined_state}.
    
     %% 以 websocket_handle 接收 client 發送的資料
     websocket_handle({text, Msg}, Req, State) ->
         {reply, {text, << "That's what she said! ", Msg/binary >>}, Req, State};
     websocket_handle(_Data, Req, State) ->
         {ok, Req, State}.
    
     %% 以 websocket_info 發送系統訊息
     websocket_info({timeout, _Ref, Msg}, Req, State) ->
         erlang:start_timer(1000, self(), <<"How' you doin'?">>),
         {reply, {text, Msg}, Req, State};
     websocket_info(_Info, Req, State) ->
         {ok, Req, State}.
    
     websocket_terminate(_Reason, _Req, _State) ->
         ok.

測試時,不能使用IE,改使用Chrome,瀏覽網頁 http://localhost:8080/ ,網頁中以 WebSocket 連接 ws://localhost:8080/websocket 網址。

error hook

  1. error_hook.app.src
  2. error_hook_sup.erl
  3. error_hook_app.erl
    在啟動時,利用 onresponse 的 callback 機制,針對不同的 error code,提供不同的錯誤畫面,一般的 response code,就不處理,直接回應。

     start(_Type, _Args) ->
         Dispatch = cowboy_router:compile([
             {'_', []}
         ]),
         {ok, _} = cowboy:start_http(http, 100, [{port, 8080}], [
             {env, [{dispatch, Dispatch}]},
             {onresponse, fun error_hook/4}
         ]),
         error_hook_sup:start_link().
     error_hook(404, Headers, <<>>, Req) ->
         {Path, Req2} = cowboy_req:path(Req),
         Body = ["404 Not Found: \"", Path,
             "\" is not the path you are looking for.\n"],
         Headers2 = lists:keyreplace(<<"content-length">>, 1,     Headers,
             {<<"content-length">>, integer_to_list(iolist_size(Body))}),
         {ok, Req3} = cowboy_req:reply(404, Headers2, Body, Req2),
         Req3;
     error_hook(Code, Headers, <<>>, Req) when is_integer(Code), Code >= 400 ->
         ......
         {ok, Req2} = cowboy_req:reply(Code, Headers2, Body, Req),
         Req2;
     error_hook(_Code, _Headers, _Body, Req) ->
         Req.

測試

> curl -i http://localhost:8080/
HTTP/1.1 404 Not Found
connection: keep-alive
server: Cowboy
date: Wed, 23 Apr 2014 03:17:46 GMT
content-length: 56

404 Not Found: "/" is not the path you are looking for.