2014年7月7日

erlang - cowboy - file upload

因為 cowboy 在目前 git master 版本跟最新的 0.9 release 兩個版本,在實作 http multi-part request 的處理上,給了兩個不相容的處理方式,而且 cowboy 又在 1.0 開發的過程中,作者也沒有時間給出很多範例以及參考的資料,再加上網路上搜尋到的解決方案,幾乎都是舊版的 cowboy 支援的方式,因此,我們試著研究把 multi-part http request 的範例程式寫出來。

我們參考了 cowboy multipart 這一份唯一的官方文件,另外在 cowboy source code 裡面,有一個 http_multipart 測試程式,由這兩個資料,我們可以組合出一個可以運作的 multi-part http request 的範例程式。

project

這個範例專案已經放在 github 了,可以直接由clone 這個 project cowboy_fileupload source code in github

project settings

這個範例遵循 cowboy 範例的作法,使用了 erlang.mk 以及 relx 這兩個工具。

專案要寫 Makefile 與 relx.config 兩個設定檔,Makefile 要將 cowboy master branch 設定為 dependent library,接下來在 make 時,才會自動下載這些 libraries。

  1. Makefile

     PROJECT = upload
    
     DEPS = cowboy
     dep_cowboy = pkg://cowboy master
    
     include erlang.mk
  2. relx.config

     {release, {upload_example, "1"}, [upload]}.
     {extended_start_script, true}.

static html index.html

靜態網頁要放在 priv 的目錄中,index.html 裡面寫了三個 html form,以下只列出最多的第三個 form,這些 form 的 action 都設定為 /upload 這個網址,cowboy 的 routing 要設定 /upload 的網址,由 multipart 的程式碼處理。

這個 form 有兩個 text 欄位,另外還有兩個 file 的欄位,而且是一個 text 與一個 file 間隔的順序,接下來我們作的 cowboy mutipart handler 必須要能根據欄位資料的型態,自動判斷是不是 text 或是檔案,而有不同的對應處理方式。

<form id="uploadForm3" action="/upload" method="POST" enctype="multipart/form-data">
    <h1>Upload Form 3</h1>
    description 3.1: <input type="text" id="desc3_1" name="desc3_1" /><br/>
    file 3.1: <input type="file" name="file3_1" /><br/>
    description 3.2: <input type="text" id="desc3_2" name="desc3_2"><br>
    file 3.2: <input type="file" name="file3_2" /><br/>
    <button type="submit">Submit</button>
</form>

source codes

  1. upload.app.src
    relx 工具會自動根據這個檔案,產生 OTP upload.app 設定檔,兩個檔案的差異只有 modules 欄位。upload.app.src填寫為 [] 空的 list,而 upload.app 自動由 relx 把相關的 modules 填寫上去了。

     {modules, [upload_app, upload_sup, upload_handler]},
  2. upload_sup.erl
    這是 OTP 的 supervisor 程式,基本上內容就跟其他 cowboy samples 一樣。

  3. upload_app.erl
    重點是 cowboy 的 routing 部份,/ 指定為靜態檔案,路徑在 upload 的 priv 路徑裡面的 index.html。

    而 /upload 路徑指派由 upload_handler 處理。

     Dispatch = cowboy_router:compile([
             {'_', [
                 {"/", cowboy_static, {priv_file, upload, "index.html"}},
                 {"/upload", upload_handler, []}
             ]}
         ]),
         {ok, _} = cowboy:start_http(http, 100, [{port, 8000}], [
             {env, [{dispatch, Dispatch}]}
         ]),
  4. uplaod_handler.erl

    這個檔案是 multipart 程式的處理重點,首先我們定義這個 handler 的 behaviour。

     -behaviour(cowboy_http_handler).

    接下來是實作三個 callback functions

     init/3, handle/2, terminate/3

    重點是 handle,acc_multipart 是處理 multipart的遞迴程式,最後取得的結果 Result,是所有 multipart 資料的 header 與 body 的 list,但因為有些欄位是檔案,我們沒有必要把檔案內容放到 body 裡面傳回來這裡,所以在 acc_multipart 裡面有特別把檔案的 body 改寫為固定的文字內容 filecontent。

     handle(Req, State) ->
    
         {Result, Req2} = acc_multipart(Req, []),
         io:format( "Result= ~p~n", [Result] ),
         {ok, Req3} = cowboy_req:reply(200, [
             {<<"content-type">>, <<"text/plain; charset=UTF-8">>}
         ], <<"OK">>, Req2),
         %%writeToFile(term_to_binary(Result)),
         {ok, Req3, State}.

    這裡把測試時取得的 Result 資料記錄下來。

     %% Result= [{[{<<"content-disposition">>,<<"form-data; name=\"desc3_1\"">>}],
     %%          <<"desc1">>},
     %%         {[{<<"content-type">>,<<"text/plain">>},
     %%           {<<"content-disposition">>,
     %%            <<"form-data; name=\"file3_1\"; filename=\"userlist1.txt\"">>}],
     %%         <<"filecontent\r\n">>},
     %%         {[{<<"content-disposition">>,<<"form-data; name=\"desc3_2\"">>}],
     %%          <<"desc2">>},
     %%         {[{<<"content-type">>,<<"text/plain">>},
     %%           {<<"content-disposition">>,
     %%            <<"form-data; name=\"file3_2\"; filename=\"userlist2.txt\"">>}],
     %%          <<"filecontent\r\n">>}]

    參考 cowboy multipart 裡面 Reading a multipart message 這一段的內容,我們可以用 cow_multipart:form_data 回傳的資料內容的不同,直接將 text 與 file 兩個區分開來。file 的部份可直接取得 content type: CType 與檔名 Filename。

    因為要把檔案寫入磁碟中,但當檔案超過 8MB 的時候,cowboy 不能一次把所有資料都傳給 stream_file 處理,因此搭配 stream_file 的檔案寫入的處理,我們把檔案開啟 file:open 跟關閉檔案 file:close 分別寫在 stream_file 的前面與後面。

     acc_multipart(Req, Acc) ->
         case cowboy_req:part(Req) of
             {ok, Headers, Req2} ->
                 [Req4, Body] = case cow_multipart:form_data(Headers) of
                     {data, _FieldName} ->
                         {ok, MyBody, Req3} = cowboy_req:part_body(Req2),
                         [Req3, MyBody];
                     {file, _FieldName, Filename, CType, _CTransferEncoding} ->
                         io:format("stream_file filename=~p content_type=~p~n", [Filename, CType]),
                         {ok, IoDevice} = file:open( Filename, [raw, write, binary]),
                         Req5=stream_file(Req2, IoDevice),
                         file:close(IoDevice),
                         [Req5, <<"skip printing file content">>]
                     end,
                 acc_multipart(Req4, [{Headers, Body}|Acc]);
             {done, Req2} ->
                 {lists:reverse(Acc), Req2}
         end.

    參考 cowboy multipart 裡面 Skipping unwanted parts 這一段的內容,我們知道 cowboy 在還沒取得所有上傳檔案的資料時,cowboy_req:part_body 就會先回傳給呼叫端,並用 more 為信號告訴 client 還需要再呼叫一次,取得檔案後面的資料。

     stream_file(Req, IoDevice) ->
         case cowboy_req:part_body(Req) of
             {ok, Body, Req2} ->
                 io:format("part_body ok~n", []),
                 file:write(IoDevice, Body),
                 Req2;
             {more, Body, Req2} ->
                 io:format("part_body more~n", []),
                 file:write(IoDevice, Body),
                 stream_file(Req2, IoDevice)
         end.

編譯與測試

  1. 編譯

     > make

    它會自動取得所有需要的 libraries 包含了 cowboy, cowlib, ranch 還有封裝工具 relx。

  2. 啟動

     > _rel/bin/upload_example console
  3. 測試

    瀏覽網頁 http://localhost:8000/