BETA

Deno WebサーバのバックエンドをElmに任せて楽々開発

投稿日:2020-05-16
最終更新:2020-05-16

Denoの1.0.0がリリースされましたね。徐々に話題になってきたので触ってみました。1.0.0になりましたが、ライブラリはまだ充実していません。特にWebサーバを構築しようとしてもフレームワークすらない現状では、かなり厳しいと言っても過言ではないでしょう。またDenoの性質上、npmを利用できないためnodeの資産を使うことができません。npmの資産がないということはJS/TSのプレーンな力で頑張る必要があります。それではDenoでWebサーバー構築は、まだまだ早いのでしょうか・・・。そんなことはありません!Elmがあります!Elmは、コレクション操作やURLパーサなどWEBサーバに欠かせない機能が標準ライブラリに豊富に取り揃えられています。それでいてバンドルサイズの最適化がとてもすごく小さいサイズ(今回のトイアプリで16KB程度でした)で収まるのです。

追記: 既にDenoでは、Servestと言うWebフレームワークがあるようです。頭が正常な方は、この記事を閉じて、そちらを使いましょう。

Elmはフロント専用の言語では・・・

Elmガイドの冒頭では、こんな一文があります。

Elm is a functional language that compiles to JavaScript. It helps you make websites and web apps. It has a strong emphasis on simplicity and quality tooling.

しかし、完全にCLI用途では使えないかというとそんなことはありません。Elmには、workerと言う、HTMLを必要としないエントリーポイントが用意されています。ちなみに、elmのフォーマッタ(elm-format)やテストフレームワーク(elm-explorations/testなどは、workerを利用して作られています。

worker :  
    { init : flags -> ( model, Cmd msg )  
    , update : msg -> model -> ( model, Cmd msg )  
    , subscriptions : model -> Sub msg  
    }  
    -> Program flags model msg  
Create a headless program with no user interface.  

Webサーバ構築

それでは早速ソースコードの解説に参りたいと思います。今回実装したサーバの機能は以下になります。

  • echoサーバ
  • 足し算
  • 数列の合計
  • 404を始めとするエラーハンドリング
# httpieを利用しています。  

$ http http://localhost:8000/echo/hello  
HTTP/1.1 200 OK  
content-length: 5  

hello  


$ http http://localhost:8000/add/1/2  
HTTP/1.1 200 OK  
content-length: 1  

3  


$ http POST http://localhost:8000/sum values:='[1, 2, 3, 4]'  
HTTP/1.1 200 OK  
content-length: 2  

10  


$ http http http://localhost:8000/hoge  
HTTP/1.1 404 Not Found  
content-length: 12  

404 NotFound  

Deno部分の実装

今回サーバのロジックのほとんどは、Elmに任せています。Denoが担当している部分は、リクエストを受け取る部分とレスポンスを返す部分のみになります。Elmとは、app.ports.functionこのような形式で、値を送ったり受け取ったりのやり取りをすることができます。詳しくはElmガイドのポートのページをご覧ください。ここで一番長いコードがリクエストからBody(String)を取得するためのTypeScriptコードなのが面白いですね。

import { listenAndServe } from "https://deno.land/std/http/server.ts";  
import "./elm/dist/main.js";  

type Response = { body: string; status: number };  

const app = (window as any).app;  

const port = 8000;  
console.log(`listen... 0.0.0.0:${port}`);  

const options = { port };  

listenAndServe(options, async (req) => {  
  // Elmからレスポンスを受け取るためのPromise  
  const getResponse: Promise<Response> = new Promise((resolve) =>  
    app.ports.response.subscribe((r: Response) => {  
      console.log(`Response: ${r.status} ${r.body}`);  
      resolve(r);  
    })  
  );  

  // Bodyをリクエストから読み込む部分  
  const buf = new Uint8Array(req.contentLength!);  
  let bufSlice = buf;  
  let totRead = 0;  
  while (true) {  
    const nread = await req.body.read(bufSlice);  
    if (nread === null) break;  
    totRead += nread;  
    if (totRead >= req.contentLength!) break;  
    bufSlice = bufSlice.subarray(nread);  
  }  
  const bodyStr = new TextDecoder().decode(bufSlice);  

  console.log(`Request: ${req.method} ${req.url} ${bodyStr}`);  
 // Elmにリクエストを委譲している部分  
  app.ports.request.send({  
    url: `http://localhost${req.url}`,  
    method: req.method,  
    body: bodyStr,  
  });  

  // Elmからレスポンスを待ち、受け取る  
  const { body, status } = await getResponse;  

  // 実際にクライアントに、レスポンスを返す部分  
  req.respond({ body: body, status: status });  
});  

Elmの実装

それでは、ロジックが書いてあるElmの部分の実装解説に移っていきましょう。

リクエストの待ち受け・解析

今回Webサーバを構築していますが、DenoとElmの関係もクライアント<->サーバのような構造になっています。ElmはDenoからのリクエストを待ち続け、DenoもElmからのResponseを待ち受けます。先ほどのDenoのコードと見比べてみると面白いかもしれません。Denoからのリクエストの窓口となる関数は、port request関数になります。port関数は、Elm側でJsonの値を受け取ります。subscription関数では、port request関数で値を受け取りますよ。と言う登録を済ませておきます。実際にリクエストを捌くためのメッセージがHandleRequestとなります。HandleRequestはJson値をメッセージとともに受け取ります。

port request : (JE.Value -> msg) -> Sub msg  


subscriptions : Model -> Sub Msg  
subscriptions _ =  
    Sub.batch  
        [ request HandleRequest  
        ]  


type Msg  
    = HandleRequest JE.Value  

Elmは型にうるさい言語のため、Jsonの値をそのままは受け取れません。そのためパース(デコード)処理が必要になります。Request型が今回デコードしたい対象になります。細かい説明は省きますが、フィールド名を参照して、Elmの型に変換をしていきます。

type alias Request =  
    { url : String  
    , method : Method  
    , bodyMaybe : Maybe String  
    }  


requestDecoder : JD.Decoder Request  
requestDecoder =  
    JD.map3 Request  
        (JD.field "url" JD.string)  
        methodDecoder  
        (JD.field "body" <| JD.maybe JD.string)  


methodDecoder : JD.Decoder Method  
methodDecoder =  
    JD.field "method" JD.string |> JD.andThen methodDecoderHelp  


methodDecoderHelp : String -> JD.Decoder Method  
methodDecoderHelp str =  
    case str of  
        "GET" ->  
            JD.succeed GET  

        "POST" ->  
            JD.succeed POST  

        _ ->  
            JD.fail "un supported method."  

先ほど用意したデコーダを使い、メッセージ(HandleRequest)の制御部分を記述します。decodeValue requestDecoder requestJsonのようにしてデコードをします。デコードに成功(Ok)した場合は、Request型の値reqをhandleRequest関数に渡し返ってきたResponseをDenoに返信をするport response関数に渡して終了になります。デコードに失敗(Err)した場合は、500を返します。

type alias Response =  
    { status : Int  
    , body : String  
    }  


port response : Response -> Cmd msg  


update : Msg -> Model -> ( Model, Cmd Msg )  
update msg model =  
    case msg of  
        HandleRequest requestJson ->  
            case JD.decodeValue requestDecoder requestJson of  
                Ok req ->  
                    ( model  
                    , response <| handleRequest req  
                    )  

                Err _ ->  
                    ( model, response { status = 500, body = "Fail parse request." } )  

URLの解析

まずは、リクエストのURLを解析からみていきましょう。今回ハンドリングしたいのは、echo・足し算・数列の合計の3機能になります。それにNotFoundを加えてRouteとします。route関数は、文字列のパーサとRouteのマッピングをしています。toRoute関数は、URLのStringからRouteに変換します。パースに失敗したら、NotFoundとなります。

type Route  
    = Echo String  
    | Add String String  
    | Sum  
    | NotFound  


route : U.Parser (Route -> a) a  
route =  
    U.oneOf  
        [ U.map Echo (U.s "echo" </> U.string)  
        , U.map Add (U.s "add" </> U.string </> U.string)  
        , U.map Sum (U.s "sum")  
        ]  


toRoute : String -> Route  
toRoute urlString =  
    case Url.fromString urlString of  
        Nothing ->  
            NotFound  

        Just url ->  
            Maybe.withDefault NotFound (U.parse route url)  

リクエストのハンドリング

それでは実際にリクエストを捌き、レスポンスに変換していく部分を見ていきましょう。単なるリクエストのメソッドとURL(Route)の分岐をパターンマッチしているだけですが、まるでDSLのように書け かつ 安全にそれが行えるのが魅力ですね。各処理は、なんとなく読み解けると思いますので、解説は省略します。

handleRequest : Request -> Response  
handleRequest req =  
    let  
        intValuesDecoder : JD.Decoder (List Int)  
        intValuesDecoder =  
            JD.field "values" <| JD.list JD.int  
    in  
    case ( req.method, toRoute req.url ) of  
        ( GET, Echo str ) ->  
            Response 200 str  

        ( GET, Add n1Str n2Str ) ->  
            case ( String.toInt n1Str, String.toInt n2Str ) of  
                ( Just n1, Just n2 ) ->  
                    Response 200 (String.fromInt <| n1 + n2)  

                _ ->  
                    Response 400 "Parameter must be a number."  

        ( POST, Sum ) ->  
            case req.bodyMaybe of  
                Just body ->  
                    case JD.decodeString intValuesDecoder body of  
                        Ok values ->  
                            Response 200 <| String.fromInt <| List.sum values  

                        Err _ ->  
                            Response 400 """Body must be { "values" : number[] }"""  

                Nothing ->  
                    Response 400 "empty body."  

        ( _, _ ) ->  
            Response 404 "404 NotFound"  

Deno + Elmのハマりどころ

今回ハマったかつ理解をあんまりしていないポイントです。お分かりの方は是非、コメントにて教えていただけると助かります。今回Elmのコンパイルは、elm makeを利用していません。これは、モジュール形態の問題で上手くDenoから読み込めなかったためだと思われます。そのためparcelの-experimental-scope-hoistingオプションを入れています。Tree Shaikingがキーワードのようです。

...  
{  
    "build": "parcel build --experimental-scope-hoisting src/main.js"  
}  

また、ElmとDenoの橋渡しとなるポイントですが、ports関数が格納されているElm.Main.int()の戻り値を普通には渡せなかったため、苦肉の策としてwindowオブジェクトに突っ込みました。

import { Elm } from "./Main.elm";  
// @ts-ignore  
window.app = Elm.Main.init();  

そのため、Deno側では、以下のようにwindowオブジェクトからappを取り出しています。

const app = (window as any).app;  

しかし、ハマりポイントは環境構築時のみで、処理を追加していく分には、Elmを書き足すのがほとんどの作業になるため、問題にはならないと思います。

まとめ

Denoはまだまだ若いプラットフォームのため使いこなすのが難しいです。しかし、Elmの強力なパワーは、有力なWebフレームワークがDenoで誕生するまで、もしくは誕生した後でも、十分発揮できるのではないでしょうか。実際に書いてみるとわかりますが、非常に型システムが強力でTypeScriptよりも安全に運用することができます。是非試して遊んでみてください。これからのDenoとElmの発展に注目ですね!

技術ブログをはじめよう Qrunch(クランチ)は、プログラマの技術アプトプットに特化したブログサービスです
駆け出しエンジニアからエキスパートまで全ての方々のアウトプットを歓迎しております!
or 外部アカウントで 登録 / ログイン する
クランチについてもっと詳しく

この記事が掲載されているブログ

@vFwVY6p2UBYatzTAの技術ブログ

よく一緒に読まれる記事

0件のコメント

ブログ開設 or ログイン してコメントを送ってみよう