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ページを返すだけのアプリケーションを作成してみます。
<!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サーバのコードを書いていきます。
先にコードの全量を示します。
{-# 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
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定義
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の定義は完了です。続く
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で作成してみます。