6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

elm & servantでwebアプリ - その1.servant導入

Last updated at Posted at 2019-12-23

servantの導入

先日、Elm&Haskellによるwebアプリケーションを作成しました。
作り終えてしまうと構築中につまづいたことなどを忘れてしまうため、残しておくことにしました。これからバックエンドにservantを利用してみようと思っている方の一助になれれば幸いです。
最初にアプリケーションルート(/)へのGET要求に対して静的なHTMLを返すWebサーバを作成してみたいと思います。

stackが導入済であることが前提です。
環境は以下の通りです。

  • CentOS 7.7
  • GHC 8.6.5
  • stack 2.1.3

プロジェクトの作成

webappというディレクトリに
frontend・backendというディレクトリを作成して、それぞれ
frontend : フロントエンドに関わるリソース(html, css, js, ...)
backend : バックエンドに関わるリソース(DBなど)
と使い分けするように作っていきます。

Servantのプロジェクトをbackendとして作成します。

[user@remote ~/webapp]$ stack new backend servant

これでServantのテンプレートとなる以下のような構成のプロジェクトが出来上がります。

[user@remote ~/webapp]$ tree backend/
backend/
    ├── LICENSE
    ├── README.md
    ├── Setup.hs
    ├── app
    │   └── Main.hs
    ├── backend.cabal
    ├── nohup.out
    ├── src
    │   └── Lib.hs
    ├── stack.yaml
    └── test
        └── Spec.hs

3 directories, 11 files
[user@remote ~/webapp]$

HTMLを返す

まずは、"/"にGETリクエストが来たら以下のHTMLページを返すだけのアプリケーションを作成してみます。

top.html
<!doctype html>
<html>
<head>
    <meta charset="UTF-8">
    <title>test</title>
</head>
<body>
    <h1>hello, servant!!</h1>
</body>
</html>

このファイルをwebapp/frontend/top.htmlとして保存しておきます。

続いてbackend/app/Main.hsにWebサーバのコードを書いていきます。
先にコードの全量を示します。

Main.hs
{-# LANGUAGE DataKinds             #-}
{-# LANGUAGE TypeOperators         #-}
{-# LANGUAGE OverloadedStrings     #-}
{-# LANGUAGE MultiParamTypeClasses #-}

import Servant
import Network.Wai.Handler.Warp
import Network.HTTP.Media ((//), (/:))
import qualified Data.ByteString.Lazy as BS

data HTML

instance Accept HTML where
    contentType _ = "text" // "html" /: ("charset", "utf-8")

instance MimeRender HTML BS.ByteString where
    mimeRender _ bs = bs

type API = Get '[HTML] BS.ByteString

api :: Proxy API
api = Proxy

server :: BS.ByteString -> Server API
server top = return top

main :: IO ()
main = do
    top <- BS.readFile "/home/user/webapp/frontend/top.html"
    run 8080 $ serve api (server top)

インポートしている以下のモジュールはビルドの依存関係に追加する必要があるので、
webapp/backend/backend.cabalの中にそれぞれ追記します。
※stack.yamlに慣れている方はそちらを編集してください。

  • Servant
  • Network.Http.Media
  • Network.Wai.Handler.Warp
  • Data.ByteString.Lazy
backend.cabal
executable backend-exe
  hs-source-dirs:      app
  main-is:             Main.hs
  ghc-options:         -threaded -rtsopts -with-rtsopts=-N
  build-depends:       base
                     , backend
                     -- add start
                     , servant-server
                     , http-media
                     , warp
                     , bytestring
                     -- add end
  default-language:    Haskell2010

コードの内容を見ていきます。

GHC拡張

まず先頭のGHC拡張

{-# LANGUAGE DataKinds             #-}
{-# LANGUAGE TypeOperators         #-}
{-# LANGUAGE OverloadedStrings     #-}
{-# LANGUAGE MultiParamTypeClasses #-}

DataKinds拡張はデータ型を種に拡張します。
これによって、型定義にIntなどのデータ型を使用することができます。

TypeOperatorsは型定義で演算子を使用可能にする拡張です。

OverloadedStringsはいちいちpackしなくてもStringをByteStringとしてくれる拡張です。
このコード内では

instance Accept HTML where
    contentType _ = "text" // "html" /: ("charset", "utf-8")

の部分で"text", "html", "charset", "utf-8"と指定していますが、これらは///:の引数で型はByteStringで定義されています。
それぞれBS.pack "text" などと指定するのは面倒ですが、OverloadedStringsを使えばこの手間は不要です。

最後のMultiParamTypeClassesは
インスタンス定義に複数の型を指定できるようにする拡張です。
Main.hs内の

instance MimeRender HTML BS.ByteString where

で、MimeRender型クラスの種はk0 -> * -> Constraintなので型を2つとる必要があります。
そのため、この拡張を有効にしてHTML と BS.ByteStringの2つを指定しています。

###import
割愛

API定義

Main.hs
data HTML

instance Accept HTML where
    contentType _ = "text" // "html" /: ("charset", "utf-8")

instance MimeRender HTML BS.ByteString where
    mimeRender _ bs = bs

まず、残念ながらServantにはHTMLという型が定義されてません。
ですが、利用者が簡単にAPIとして作ることができるようになっています。
ここに作り方が書いてあるので、これに従って作っています。

If you would like to support Content-Types beyond those provided here, then:

Declare a new data type with no constructors (e.g. data HTML).
Make an instance of it for Accept.
If you want to be able to serialize data into that Content-Type, make an instance of it for MimeRender.
If you want to be able to deserialize data from that Content-Type, make an instance of it for MimeUnrender.

まず、冒頭に書いてあるとおりデータ型を定義(data HTML)します。

Declare a new data type with no constructors (e.g. data HTML).

data HTML

次に作ったデータ型をAcceptのインスタンスにします。

Make an instance of it for Accept.

Acceptのインスタンスにするためには、contentType または contentTypesを実装する必要があり、contentTypeの型を調べると

contentType :: Proxy ctype -> MediaType

となっています。
今回は単にHTMLを返したいだけなので、入力のProxy ctypeは使用せずに捨てて
Content-Type: "text/html":charset=utf-8をレスポンスヘッダにつけて返すようにしています。

instance Accept HTML where
    contentType _ = "text" // "html" /: ("charset", "utf-8")

なお、///:の型は以下の通りです。

(//) :: ByteString -> ByteString -> MediaType

Builds a MediaType without parameters. Can produce an error if either type is invalid.

(/:) :: MediaType -> (ByteString, ByteString) -> MediaType

Adds a parameter to a MediaType. Can produce an error if either string is invalid.

上記でも述べましたが、ByteStringが入力となっています。

そして

If you want to be able to serialize data into that Content-Type, make an instance of it for MimeRender.

に従って、MimeRenderのインスタンスにします。mimeRenderを実装すればOKです。

instance MimeRender HTML BS.ByteString where
    mimeRender _ bs = bs

これでHTMLの定義は完了です。続く

Main.hs
type API = Get '[HTML] BS.ByteString

api :: Proxy API
api = Proxy

で、このHTMLを返すためのAPIを準備しています。

今回は"/"に対するGet要求にのみ応答するように作っているのでで、Get '[HTML] ByteStringとだけ記載しています。
HTTPメソッドを処理するためにServantではVerbというデータが定義されています。

data Verb method (statusCode :: Nat) (contentType :: [*]) a

具体的なメソッドに応じた別名が定義されており、GetもHTTP GETを処理するために別名として定義されています。

type Get    = Verb 'GET 200
type Delete = Verb 'DELETE 200
type Patch  = Verb 'PATCH 200
type Post   = Verb 'POST 200
type Put    = Verb 'PUT 200

受け付けるHTTPメソッドに応じてこれらを使用することができます。

さて、Get '[HTML] ByteStringですが、Verbの定義を見ると型パラメータとして

  • method
  • statusCode
  • contentType
  • a

を取ることになっています。
type Get = Verb 'GET 200でmethodとstatusCodeは渡されているので、残りを指定してやります。(contentTypeに'[HTML]、 aにByteString)
contentTypeは文字通りですが、HTTPレスポンスとして返却するデータの形式を指定します。ここに先ほど定義したデータ型のHTMLを指定してやります。
もしJSONを返したいなら'[JSON]と指定します。('[JSON]は用意されています)

aにはAPIに応じた実処理を行うHaskellプログラムが返す型を指定します。
今回はHTMLファイルを読みとって返すので、ByteStringとしています。

server

server :: ByteString -> Server API
server top = return top

ここには上記で定義したAPIに対応するHaskellの処理を記載します。
今回は読み込んだHTMLのByteStringをそのまま返すだけなので引数をそのままreturnしています。

main

main :: IO ()
main = do
    top <- BS.readFile "/home/user/webapp/frontend/top.html"
    run 8080 $ serve api (server top)

最後、mainです。
まずHTMLファイルを読み込むところは良いかと思います。
run の型は以下の通りです。

run :: Port -> Application -> IO ()

Run an Application on the given port. This calls runSettings with defaultSettings.

PortとApplicationを渡せばよいことがわかります。
PortはIntの別名です。
Applicationは以下のように定義されています。

type Application = Request -> (Response -> IO ResponseReceived) -> IO ResponseReceived

The WAI application.

Note that, since WAI 3.0, this type is structured in continuation passing style to allow for proper safe resource handling. This was handled in the past via other means (e.g., ResourceT). As a demonstration:

app :: Application
app req respond = bracket_
(putStrLn "Allocating scarce resource")
(putStrLn "Cleaning up")
(respond $ responseLBS status200 [] "Hello World")

Requestと"Responseを受けてIO ResponseReceivedアクションを返す関数"を受け取るようです。
今回のコードではserve api (server top)の部分です。
まず、serveはServant.Serverに定義されており、

serve :: HasServer api '[] => Proxy api -> Server api -> Application

という定義になっています。確かにApplicationを返します。
Proxy apiにはまさにAPIを渡しており、Server apiの部分には
server topを渡しています。これの型は何だったかというとserver :: ByteString -> Server API
であり、確かにあっています。serverでreturnすることでAPIを持ったServerを作っているわけですね。

apiとは

type API = Get '[HTML] BS.ByteString

api :: Proxy API

でした。Proxyは単にAPIを返すだけです。
肝心のAPIは
Get '[HTML] BS.ByteString
で前に見た通りGetはVerb 'GET 200の別名でした。

ビルド

Main.hsをひととおり見たところでstack buildします。
初回は多数のパッケージ導入が行われるためかなり時間がかかります(私の環境では4時間ほどかかりました)ので、nohup&バックグラウンド実行することをお勧めいたします。
nohup stack build --jobs 1 &

--jobs 1は最初メモリが足りず異常終了したため、付け加えました。

無事にビルドが完了したら、stack exec backend-exeで実行します。
するとポート8080で待ち受けされます。

別のコンソールを立ち上げてlocalホスト宛にGetを投げてみましょう。

[user@remote ~]$ curl -v 'localhost:8080'
* About to connect() to localhost port 8080 (#0)
*   Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> User-Agent: curl/7.29.0
> Host: localhost:8080
> Accept: */*
>
< HTTP/1.1 200 OK
< Transfer-Encoding: chunked
< Date: Mon, 25 Nov 2019 13:44:05 GMT
< Server: Warp/3.2.28
< Content-Type: text/html;charset=utf-8
<
<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <title>webapp</title>
    </head>
    <body>
        <h1>hello, servant!</h1>
    </body>
</html>
* Connection #0 to host localhost left intact
[user@remote ~]$

無事にHTMLが取得されました。レスポンスヘッダのContent-TypeとcharsetもAcceptのインスタンスにする際のcontentType定義が反映されてますね。

次回はElmを導入しtop.htmlをElmで作成してみます。

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?