Create a gist now

Instantly share code, notes, and snippets.

@voluntas /wm.rst
Last active Jun 3, 2016

What would you like to do?
Webmachine コトハジメ

Webmachine コトハジメ

更新:2013-03-9
バージョン:0.2.1
作者:@voluntas
URL:http://voluntas.github.com/

Webmachine のバージョンは 1.9.3 で確認しています

TODO

  • リソース機能説明を追加する

注意

  • RESTful API について詳しくない人が書いているので、間違っていたらごめんなさい。
  • 確認は mochiweb を 2.3.2 に上げたオリジナル版で行っています。

Webmachine とは

Basho Technologies, Inc. が公開している RESTful API 向けの Web フレームワークです。 テンプレートエンジンや O/R マッパーなどこじゃれたモノは一切ついていません。

さらに JSON 変換についても mochiweb のおまけがついているだけです。

ただ、RESTful な API を作成する場合はとても理にかなっています。さらに強力なデバッグ機能がついています。

HTTP サービスはステートフルであるという元に考えられた仕組みです。

提供してくれるモノ

  • RESTful な API 向けのフレームワーク

提供してくれないモノ

  • テンプレートエンジン
  • データベース連携
  • セッション管理
  • 認証
  • 管理画面

その他諸々何も提供してくれません

事前準備

Webmachine は単独でも動くように作られていますし、簡単にデモアプリが作れるようなスクリプトも付属しています。

動作確認

Erlang がインストールされていること前提です

ここでは Basho 公式の Webmachine を使います。

準備:

# まずはディレクトリを作ります
$ mkdir try-webmachine
$ cd try-webmachine

# webmachine を取ってきてコンパイルします
$ git clone git://github.com/basho/webmachine.git
$ cd webmachine
$ make

# webmachine プロジェクトを生成します
$ ./scripts/new_webmachine.sh snowflake ../
$ cd ../snowflake
$ ls -a
./  ../  Makefile  README  deps/  priv/  rebar*  rebar.config  src/  start.sh*

# webmachine プロジェクトをコンパイルして起動してみます
$ make
$ ./start.sh

アクセスしてみる:

$ http http://localhost:8000/
HTTP/1.1 200 OK
Content-Length: 42
Content-Type: text/html
Date: Sat, 05 Jan 2013 07:31:03 GMT
Server: MochiWeb/1.1 WebMachine/1.9.2 (someone had painted it blue)

<html><body>Hello, new world</body></html>

200 が返ってきていることを確認します。

例を作るために POST 以外を受け付けない仕様にしてみましょう。

$ vim src/snowflake_resource.erl

修正前

%% @author author <author@example.com>
%% @copyright YYYY author.
%% @doc Example webmachine_resource.

-module(snowflake_resource).
-export([init/1, to_html/2]).

-include_lib("webmachine/include/webmachine.hrl").

init([]) -> {ok, undefined}.

to_html(ReqData, State) ->
    {"<html><body>Hello, new world</body></html>", ReqData, State}.

修正後

%% @author author <author@example.com>
%% @copyright YYYY author.
%% @doc Example webmachine_resource.

-module(snowflake_resource).
-export([init/1, to_html/2]).

-export([allowed_methods/2]).

-include_lib("webmachine/include/webmachine.hrl").

init([]) -> {ok, undefined}.

allowed_methods(RD, Ctx) ->
    {['POST'], RD, Ctx}.

to_html(ReqData, State) ->
    {"<html><body>Hello, new world</body></html>", ReqData, State}.

コンパイルして再度起動:

$ ./rebar compile
$ ./start.sh

GET を送ってみて怒られる:

$ http http://localhost:8000/
HTTP/1.1 405 Method Not Allowed
Allow: POST
Content-Length: 0
Date: Sat, 05 Jan 2013 07:39:30 GMT
Server: MochiWeb/1.1 WebMachine/1.9.2 (someone had painted it blue)

heroku

おまけで Heroku で Webmachine を動かす方法を追記しておきます。

古い記事ですが、まだ動くと思います http://voluntas.hatenablog.com/entry/20111218/1324167999

ディスパッチ

url:https://github.com/basho/webmachine/wiki/Dispatching

priv/dispatch.conf から呼ぶのが標準です。動的に追加削除も可能ではりますこのあたりは後ほど紹介します。

%% ガードなし
{PathSpec, Resource, Args}

%% ガードあり
%% Guard は {module(), atom()} または function() で指定可能です。
{PathSpec, Guard, Resource, Args}

ディスパッチルールの書き方

PathSpec の書き方は文字列、atom の二種類だけです。 / で区切られた値を表現します。 atom の定義で特殊なのが '*' という「全部」を定義できます。

まず /spam にマッチするディスパッチルールを書いてみます。

{["spam"], example_resource, []}

PathSpec はリストで表現します。上の URL の場合 /spam/eggs はマッチせず /spam のみがマッチします。

/spam/eggs がマッチするディスパッチルールを書いてみます

{["spam", '*'], example_resource, []}
{["spam", "eggs"], example_resource, []}

'*' は残り全部という意味です。もう一つそこに入る値を指定して取得するという方法もあります。

{["spam", "eggs", bacon], example_resource, []}

この場合 bacon という atom にマッチする値が wrq:path_info(bacon, RD) で取得できます。

/spam/eggs/ham という URL の場合は "ham" が戻り値になります。

もう一つリソースモジュールを指定する前にガードを指定出来ます。

このガードは is_post/1 など ReqData に対してフィルタリングする仕組みです。

ただ is_post するくらいなら allowed_methods で十分だと思ったりしないでもないです ... 。

dispatch.conf

公式ドキュメントでは priv/dispatch.conf にディスパッチルールを定義する事を推奨しています。 ただ、これ Webmachine 側で上手いことやってくれるわけでは無いんです。

以下のコードを時前で書いて取得する必要があります。デフォルトでは dispatch.conf みるようにしてくれてもイイ気がしないでも無かったりしますが、もともとは組み込み前提で考えられている事もあるのでそこは自分で指定してということなんでしょうね。ただ便利関数としては用意していい気がします。

公式に書いてある関数

{ok, Dispatch} = file:consult(
                     filename:join([filename:dirname(code:which(?MODULE)),
                                    "..", "priv", "dispatch.conf"])),

priv/dispatch.conf の例

chef_wm から実際の例を引っ張ってきました。

https://github.com/opscode/chef_wm/blob/master/priv/dispatch.conf

{["cookbooks"], chef_wm_cookbooks, []}.
{["cookbooks", qualifier], chef_wm_cookbooks, []}.

dspatch.conf を呼び出す

app_wm というアプリケーションを作っている場合は、app_wm_sup に設定するのが良いでしょう。

-module(app_wm_sup).

-behaviour(supervisor).

-export([start_link/0]).

-export([init/1]).

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

init([]) ->
    {ok, Ip} = application:get_env(app_wm, ip),
    {ok, Port} = application:get_env(app_wm, port),

    {ok, Dispatch} = file:consult(
                         filename:join([filename:dirname(code:which(?MODULE)),
                                        "..", "priv", "dispatch.conf"])),

    WebConfig = [{ip, Ip},
                 {port, Port},
                 {log_dir, "priv/log"},
                 {dispatch, Dispatch}],

    Web = {webmachine_mochiweb,
           {webmachine_mochiweb, start, [WebConfig]},
           permanent, 5000, worker, dynamic},

    {ok, { {one_for_one, 5, 10}, [Web]} }.

ディスパッチルールの動的追加と削除

%% ディスパッチルールを追加
webmachine_router:add_route/1
%% ディスパッチルールを削除
webmachine_router:remove_route/1
%% リソースディスパッチを削除する
webmachine_router:remove_resource/1
%% 現在のディスパッチルールを取得する
webmachine_router:get_route/0

remove_resource はリソースを削除します。 リソースとは単にモジュールの事を指していると考えて問題ありません。

三種類のディスパッチルールを追加します

> webmachine_router:add_route({["spam"], spam_resource, []}).
> webmachine_router:add_route({["eggs"], spam_resource, []}).
> webmachine_router:add_route({["bacon"], bacon_resource, []}).

その後 spam モジュールに対してに対してリソースを削除します

> webmachine_router:remove_resource(spam_resource).

削除後のディスパッチルールは以下の通りになります

> webmachine_router:add_route({["bacon"], bacon_resource, []}).

リソース毎に一気に消せる機能だと思って問題ありません。

wrq モジュール

ディスパッチした URL から情報を取得するのにいくつか便利な関数が用意されています。

wrq のエクスポートされてる関数一覧

-export([create/4, create/5,load_dispatch_data/7]).
-export([method/1,scheme/1,version/1,peer/1,disp_path/1,path/1,raw_path/1,
         path_info/1,response_code/1,req_cookie/1,req_qs/1,req_headers/1,
         req_body/1,stream_req_body/2,resp_redirect/1,resp_headers/1,
         resp_body/1,app_root/1,path_tokens/1, host_tokens/1, port/1,
         base_uri/1]).
-export([path_info/2,get_req_header/2,do_redirect/2,fresh_resp_headers/2,
         get_resp_header/2,set_resp_header/3,set_resp_headers/2,
         set_disp_path/2,set_req_body/2,set_resp_body/2,set_response_code/2,
         merge_resp_headers/2,remove_resp_header/2,
         append_to_resp_body/2,append_to_response_body/2,
         max_recv_body/1,set_max_recv_body/2,
         get_cookie_value/2,get_qs_value/2,get_qs_value/3,set_peer/2,
         add_note/3, get_notes/1]).

wrq:raw_path

wrq:path_tokens/1

wrq:disp_path/1

'*' にマッチした残りの URL 返ってきます

ディスパッチルール:["spam", "eggs", '*']
URL:/spam/eggs/a/b
"a/b" = wrq:disp_path(RD)

値が無かった場合は空文字列(空リスト)が返ってきます

ディスパッチルール:["spam", "eggs", '*']
URL:/spam/eggs/
"" = wrq:disp_path(RD)

wrq:path_info/1

atom で定義した値を引数に指定することでその値が取得できます。

ディスパッチルール:["spam", "eggs", bacon, ham]
URL:/spam/eggs/a/b
"a" = wrq:path_info(bacon, RD)

wrq:path_info/2

atom で定義した値を proplists 形式で取得できます。

ディスパッチルール:["spam", "eggs", bacon, ham]
URL:/spam/eggs/a/b
[{bacon, "a"}, {ham, "b"}] = wrq:path_info(RD)

wrq:get_qs_value/2

パラメータを取得できます

ディスパッチルール:["spam", "eggs", '*']
URL:/spam/eggs/a/b?spam=eggs&ham=bacon
"eggs" = wrq:get_qs_value("spam", RD)

wrq:method/1

メソッド名を取得できます。戻り値は atom で 'POST' や 'PUT' などです。

'GET' = wrq:method(RD)

wrq:scheme/1

プロトコルのスキーマを取得できます。戻り値は http や https といった atom です。

https = wrq:method(RD)

wrq:version/1

スキーマのバージョンを取得できます。戻り値は {1,0} や {1,1} のような二桁の tuple です。

{1,1} = wrq:version(RD)

wm_reqdata.hrl

-record(wm_reqdata, {method, scheme, version, peer, wm_state,
                     disp_path, path, raw_path, path_info, path_tokens,
                     app_root,response_code,max_recv_body, max_recv_hunk,
                     req_cookie, req_qs, req_headers, req_body,
                     resp_redirect, resp_headers, resp_body,
                     host_tokens, port, notes
                    }).

組み込み方法

supervisor にぶら下げる

application_sup にそのままぶら下げるのが普通か

WMConfig = [{ip, Ip},
            {port, Port},
            {log_dir, LogDir},
            {dispatch, ?DISPATCH}],
WM = {webmachine_mochiweb,
      {webmachine_mochiweb, start, [WMConfig]},
      permanent, 5000, worker, dynamic},

{ok, {{one_for_one, 10, 10}, [WM]}}.

ログ

デフォルトは webmachine_logger が呼ばれます。そのため log_dir で指定した場所に "access.log" として出力されます。

オレオレロガー

%% wm_logger というモジュールを新しく設定する
application:set_env(webmachine, webmachine_logger_module, wm_logger)

webmachine_logger_module に指定することで新しいロガーを設定できます。ロガーはいくつかルールがあります。webmachine の logger は webmachine_sup にぶら下がるためです。

必須実装のコールバックをとりあえず書いておく

-behaviour(gen_server).

-callback start_link([string()]) -> {ok, pid()} | {error, term()}.
-callback log_access(#wm_log_data{}) -> ok.

ロガーのテンプレート

-module(wm_logger).

-behaviour(gen_server).

-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
         terminate/2, code_change/3]).

-export([log_access/1]).

-include("webmachine_logger.hrl").

-record(state, {}).

-spec log_access(#wm_log_data{}) -> ok.
log_access(#wm_log_data{}=D) ->
    gen_server:cast(?MODULE, {log_access, D}).

start_link(BaseDir) ->
    gen_server:start_link({local, ?MODULE}, ?MODULE, [BaseDir], []).

init([_BaseDir]) ->
    {ok, #state{}}.

handle_call(_Msg, _From, State) ->
    {noreply, State}.

handle_cast({log_access, LogData}, State) ->
    %% ここにログをどうするか書く
    {noreply, State};
handle_cast(_Msg, State) ->
    {noreply, State}.

%% この部分は消してはいけない
handle_info({_Label, {From, MRef}, get_modules}, State) ->
    From ! {MRef, [?MODULE]},
    {noreply, State};
handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

webmachine_logger.hrl

-record(wm_log_data,
        {resource_module :: atom(),
         start_time :: tuple(),
         method :: atom(),
         headers,
         peer,
         path :: string(),
         version,
         response_code,
         response_length,
         end_time :: tuple(),
         finish_time :: tuple(),
         notes}).

エラーハンドラー

webmachine_error_handler がデフォルトで、render_errror/3 が呼び出される。 この Req は mocwhieb の Req なので、それ相応の実装が必要。

404 や 500 の際に返ってくる文字列が定義できる。

エラーハンドラーの変更は error_handler の値を変更する

application:set_env(webmachine, error_handler, wm_error_handler)

例として戻り値を HTML ではなく JSON で返すサンプルを追加します。

戻り値を全て json に変更するエラーハンドラー

-module(wm_error_handler).

-export([render_error/3]).

render_error(Code, Req, Reason) ->
    case Req:has_response_body() of
        {true,_} -> Req:response_body();
        {false,_} -> render_error_body(Code, Req:trim_state(), Reason)
    end.

render_error_body(Code, Req, _Reason) ->
    {ok, ReqState} = Req:add_response_header("Content-Type", "application/json"),
    ReasonPhrase = httpd_util:reason_phrase(Code),
    Result = mochijson2:encode({struct, [{<<"reason_phrase">>, ReasonPhrase},
                                         {<<"code">>, Code}]}),
    {Result, ReqState}.

リソース関数のメモ

URL:https://github.com/basho/webmachine/wiki/Resource-Functions

リソース関数の正常な戻り値は {Result, RD, Ctx} というタプルです。

  • Result はリソース関数毎に指定されている戻り値を指定します。
  • RD は #wm_reqdata{} というレコードです。
  • Ctx なんでも入れられますが普通は #ctx{} というレコードを作ってそこを自由にいじっていきます。

戻り値としてそれ以外に {halt, Code} と {error, Term} があります。

  • {error, Term}
    • この値が戻されると 500 が返ります。Term の部分が 500 のページの Body に含まれます。
  • {halt, Code}
    • 強制的にリクエストを戻します。戻すときのコードを指定することが出来ます。

resource_exists

タイプ:boolean()
  • 指定したリソースが存在するかどうか
  • false の場合は 404
  • リソース確認してあるなしで。
  • false の場合でも PUT の場合は大丈夫

service_available

タイプ:boolean()
  • まず一番最初に呼ばれる
  • ここで今後使う情報を Ctx に入れておくのが良い

is_authorized

タイプ:boolean() | AuthHead :: iodata()

認証結果を戻します。

デフォルトは true なので常に認証が成功している状態

AuthHead は WWW-Authenticate ヘッダーの中身を戻せます

forbidden

タイプ:boolean()

allow_missing_post

タイプ:boolean()

POST が送られてきた場合そのリソースが存在しない場合の処理

malformed_request

タイプ:boolean()

壊れているヘッダーなどが無いかをチェックする

uri_too_long

タイプ:boolean()

URI が長すぎないかどうかの確認

known_content_type

タイプ:boolean()

送られてきたコンテントタイプが処理出来るかどうか

valid_content_headers

タイプ:boolean()

ヘッダーのチェック

valid_entity_length

タイプ:boolean()

長さチェック

options

タイプ:[header()]

オプションメソッド

allowed_methods

タイプ:[atom()]

受け付けるメソッドを定義する

注意点としてはメソッド名は atom で指定する事

allowed_method(RD, Ctx) ->
    {['GET', 'POST', 'PUT', 'DELETE'], RD, Cxt}

このメソッド以外を送ると 405 Method Not Allowed が返される

delete_resource

タイプ:boolean()

DELETE メソッドの場合はここに来る

delete_completed

タイプ:boolean()
  • delete_resource が true で返された後の処理を書ける

post_is_create

タイプ:boolean()

create_path

タイプ:Path :: string()

post_is_create が true を返したとき Location に入れる Path を指定する

process_post

タイプ:boolean()

content_types_provided

提供

[{content_type, function atom}]

デフォルトは [{"text/html", to_html}] が呼ばれる。

to_html は定義されているわけでは無い。

content_types_provided(RD, Ctx) ->
    {[{"application/json", to_json}, {"text/html", to_html}], RD, Ctx}.

Content-Type 毎に指定される関数のの戻り値は {Result :: iolist(), RD, Ctx} を返す必要がある

content_types_accepted

受信

コンテンツタイプ毎にどの関数を呼ぶか指定します

content_types_accepted(RD, Ctx) ->
    {[{"application/json", from_json}], RD, Ctx}.

Content-Type 毎に指定される関数のの戻り値は {true, RD, Ctx} を返す必要がある

charsets_provided

no_charset | [{Charset, CharsetConverter}]

encodings_provided

[{Encoding, Encoder}]

デフォルトは [{"identity", fun(X) -> X end}]

サンプル

[{“identity”, fun(X) -> X end}, {“gzip”, fun(X) -> zlib:gzip(X) end}]

variances

[HeaderName :: string()]

デフォルトヘッダー以外にヘッダーを付与することが出来る

is_conflict

boolean()

デフォルトは false

重複していた場合の処理を追加する。コンフリクトした場合は 409 Conflict が返る。

multiple_choices

タイプ:boolean()
デフォルト:false

戻り値が複数存在するため自動で判定出来ない旨を返す。300 Multiple Choices が返る。

previously_existed

boolean()

moved_permanently

{true, MovedURI :: string()} | false

moved_temporarily

{true, MovedURI :: string()} | false

last_modified

タイプ:undefined | {{YYYY,MM,DD},{Hour,Min,Sec}}
デフォルト:undefined

expires

タイプ:undefined | {{YYYY,MM,DD},{Hour,Min,Sec}}
デフォルト:undefined

generate_etag

タイプ:undefined | ETag :: string()
デフォルト:undefined

finish_request

タイプ:boolean()
デフォルト:true

デバッグ

Webmachine の魅力の一つに強力なデバッグ機能が存在します。

キャンバスを使って経路情報を表示してくれます。

簡単な例を見てみましょう。user のリソースにデータを追加してみます。

デバッグモードを使うには二つの作業があります。

まずデバッグ対象リソースモジュールの init/1 をいじる必要があります

init(Args) ->
   %% デバッグモード
   {{trace, "/tmp"}, Args}.

   %% こちらが正常モード
   %% {ok, Args}.

さらにコンソールに以下のディスパッチルールを追加する必要があります

wmtrace_resource:add_dispatch_rule("wmtrace", "/tmp").

そうしたら PUT を投げてみましょう:

$ http PUT localhost:8080/users/1234 password=pass -j
HTTP/1.1 201 Created
Content-Length: 36
Content-Type: application/json
Date: Sat, 05 Jan 2013 06:45:04 GMT
Location: /users/1234
Server: MochiWeb/2.3.2 WebMachine/1.9.2 (someone had painted it blue)

{
    "password": "pass",
    "user_id": "1234"
}

正常にリソースが追加されました。

トレースする画面は以下の URL になります:

http://localhost:8080/wmtrace/

トレースに成功していると以下のようなリンクが生成されます

https://dl.dropbox.com/u/89936/gist/trace.png

トレース結果が表示されます。リソース関数がどのような判定を行ったのか、さらにどのようなリクエストが来たのか、最終的にどんな経路で 201 Created に行き着いたのかなどがわかります。

それぞれの判定ポイントにてどのリソース関数が呼ばれているのかも見ることが出来ます。

https://dl.dropbox.com/u/89936/gist/sample1.png

全体像

https://dl.dropbox.com/u/89936/gist/sample2.png

全体像が細かくてよくわからないと思うので拡大して二分割してみました

https://dl.dropbox.com/u/89936/gist/sample3.png

https://dl.dropbox.com/u/89936/gist/sample4.png

この機能を使うことでデバッグがかなりやりやすくなります。

サンプルコード

rebar の deps に webmachine を指定して使っている例

https://github.com/voluntas/snowflake/tree/feature/webmachine

参考

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment