四択クイズwebアプリ with Servant
動機
マーク式の資格試験などに使える,クイズアプリケーションを作りたい.
しかしyesodなどで一からweb作るのは面倒だし,そもそもyesodは大量のメモリを
消費するので手持ちのVPSでは走らない...
web APIとして軽量に,しかもさくっと作りたい!(そうしないと資格勉強時間が...)
ということで,はじめてのwebアプリ開発のツールに選んだのはServantです.
素晴らしいことに,webアプリ開発経験0の私ですら,Servant側で詰まったことは
一度もなかったので,(サーバーの仕組みよくわからん)(webアプリ難しそう)
といった超初心者にこそお勧めしたいフレームワークです.
javascriptでAPIを呼ぶところだけつまずきましたが,
こちらも多くの場合ブラウザの"賢い"キャッシュ機能によるものでした.
そもそもServantって?
詳しい記事はたくさんあるのであちこち参照してみてください. webアプリ知識0なので他の言語・フレームワークでは どういう風に開発するのか全く知りませんが,開発を終えてみて理解したことは
- Haskellによるwebフレームワーク.割と軽量.
- いつも通り型を合わせていけば動く.間違いなく動く
- 通信部で書くことが本当に少ないので,サーバーの仕組みを理解しやすい
- 通信部と処理部を完全に分離して実装することができる
- メンテも簡単!
webアプリ開発って難しそう〜〜と思っていましたが,本当に簡単に書けてしまいます.
はじめに
本記事のソースコードは全て https://github.com/wvogel00/quiz4/ に置いてあります. GHC拡張やimportモジュールはそちらを参照してください.
そもそもwebAPIって?
(私が勝手につまずいていただけなので読み飛ばしてもらって大丈夫です)
私はこれまで測定器やハードウェアまわりのAPIしか触ったことがありませんでした. 例えばYOKOGAWAのTIA装置TA720を使って, 通信機の基準信号を1日かけて測定する測定系(アプリケーション)を作るとしましょう.
TA720(タイムインターバルアナライザ)
この場合は,GPIBを介してPCと測定器を接続して,
文字列:STOP
を送信すれば測定の停止,
:CALCULATION:TPTOPEAK?
でp-to-p測定値を取得できますし,他にも沢山のAPIがあります.
この:STOP
のように,
「アプリケーションをプログラミングするために提供されている
インターフェース」がAPIです.
webも測定器と同様で,urlを住所ではなくAPI関数,
そこにPOSTするJSONやらの値をAPIの引数として考えれば良いだけでした.
では早速,web APIを作るために,仕様を作っていきましょう.
仕様決め
クイズのデータ型
今回はこれがサーバーとクライアントの間でやりとりするJSONに含む情報となります.
data Quiz = { statement :: Text -- 問題文 , a :: Text -- 選択肢A , b :: Text -- 選択肢B , c :: Text -- 選択肢C , d :: Text -- 選択肢D , answer :: Text -- 解答 , explanation :: Text -- 解説 }
また,4択クイズですので,statement,a,b,c,d,answer
は必須,
explanation
は文字列が空でも良いことにします.
必要なAPI
今回作成するweb APIでは,クイズの作成とクイズの出題に関する2つのAPIが必要になります.
/quiz4/make
... POSTメソッド. 上述のQUizデータをもつJSONを渡してクイズを登録するAPIです.登録結果も返します./quiz4/get
... GETメソッド 上述のQuizデータをもつJSON,登録済みのクイズの中から適当に返してくれます.
ついでに,ブラウザ上でも使えるように,webページも作成しましょう.
/quiz4
... クイズ用のwebページ(html)を返します.
仕様が決まったので早速実装しましょう.
APIの定義
サーバで使えるapiを定義します!基本的には上であげた3つが使えれば良いのですが, htmlをつかう以上,html/css/js,それから画像ファイルも使えるようにしておきたいですね. また,ServantではHTMLは型として定義されていないので,自前で用意します. (他のライブラリを使えばできなくはないのですが,依存関係はできるだけ少なくしましょう)
ではさっそく,このファイル達を使うために型を定義します.
data HTML instance Accept HTML where contentType _ = "text" // "html" /: ("charset", "utf-8") instance MimeRender HTML BS.ByteString where mimeRender _ bs = bs data FileType = HTMLFile | CSSFile | JSFile | IMGFile deriving Eq
これを使って,APIを定義します.
type API = "quiz4" :> Get '[HTML] BS.ByteString :<|> "quiz4" :> "html" :> Raw :<|> "quiz4" :> "css" :> Raw :<|> "quiz4" :> "js" :> Raw :<|> "quiz4" :> "img" :> Raw :<|> "quiz4" :> "get" :> Get '[JSON] Quiz :<|> "quiz4" :> "make" :> ReqBody '[JSON] Quiz :> Post '[PlainText] T.Text
(日本語も使いたのでHTMLはByteStringにしましたが,T.Textに統一すれば良かった...)
とてもシンプルです.一行目と最後の二行は,仕様で決めたAPIそのものですね.
- topページ ...
/quiz4
を受け取ったなら,Htmlページを返す get API
.../quiz4
を受け取り./get
を受け取ったなら, JSON形式でQuiz型の値を返すmake API
.../quiz4
を受け取り./make
を受け取ったなら, JSON形式でQuiz型の値を受け取り,結果を平文で返す.- その他(cssの場合) ...
/css
を受け取ったなら,それに続くファイル名と一致するファイルをそのまま返す
APIを定義できたら,実装していきます
APIの実装
server関数に,上述のAPIを定義しましょう.APIは:<|>
で,定義したAPIの順番通りにつなげます.
quizServer :: BS.ByteString -> Server API quizServer toppage = getHtml toppage :<|> getStatics HTMLFile :<|> getStatics CSSFile :<|> getStatics JSFile :<|> getStatics IMGFile :<|> getQuiz -- getAPI :<|> postMake -- makeAPI
すばらしい.ほぼ定義通りに書くだけですね.それぞれの関数の型は次の通りで,上で定義したAPIとの対応がわかると思います.
getHtml :: BS.ByteString -> Handler BS.ByteString getStatics :: FileType -> Tagged Handler Application getQuiz :: Handler QUiz postMake :: Quiz -> Handler T.text
まずはtoppageを返すgetHtml
を実装します.引数で渡されたByteStrng表現htmlファイルを
Handlerモナドに包んで渡すだけですから
getHtml html = liftIO $ return html
で終わりです.
他のhtml/css/js/imgファイルも,リクエストが来たらそのままファイルの中身を返すだけですから
getStatics HTMLFile = serveDirectoryWebApp "html" getStatics CSSFile = serveDirectoryWebApp "css" getStatics JSFile = serveDirectoryWebApp "js" getStatics IMGFile = serveDirectoryWebApp "img"
で完了です.serveDirectoryWebApp
関数は,指定したフォルダの中にある該当ファイルを返します.
これにより,次のようなhtmlファイルをtoppageとしてquizServerに渡すと,
css,js,favicon画像を読み込んで作成したコンテンツを正しく表示できます.
<html> <head> <link rel="stylesheet" href="/quiz4/css/quiz4.css"> <link rel="shortcut icon" href="/quiz4/img/favicon.ico"> <script type="text/javascript" src="/quiz4/js/main.js"></script> <meta charset="UTF-8"> <title>クイズ4</title> </head> <body> <div class="buttonContainer"> <div class="indexButton" id="solveProblem"> 問題を解く </div> <div class="indexButton" id="makeProblem"> 作問する </div> </div> </body> </html>
<head href="~~">
が,API定義の通りに/quiz4/css
のようになっていますね.
実行ディレクトリにcss,js,htmlなどのフォルダを作成して適当にcssなどを作れば,次のようなページが表示されます.
では,肝心の二つのAPIの実装に入りましょう.
getQuiz : GETメソッド
GETメソッドなので関数は値を取らず,登録されているクイズの中から適当にどれか一つを選んで返します. 本来であればsqlデータベースを使うのですが,今回ははじめて作るwebアプリなので サーバーの構造をみやすくするために,テキストファイルに読み書きすることで実装しました.
getQuiz :: Handler Quiz getQuiz = do qs <- liftIO $ T.lines <$> TIO.readFile database now <- liftIO $ floor . utctDayTime <$> getCurrentTime :: Handler Int let i = mod now $ length qs return $ encodeQ $ qs !! i
時刻をもとにファイルからランダムに読み出した[T.Text]
型の値を
encodeQ
でQuiz型に変換して返します.
シンプルですね.
今回の仕様には定義していませんでしたが,
過去に出題した問題とできるだけ重複しないロジックにする場合は,
モナドを積んでいけば良さそうです.
postMake : POSTメソッド
POSTメソッドなので関数は定義にのっとりQuiz型の値をとり,
その結果を平文(T.Text
)で返します.
postMake :: Quiz -> Handler T.Text postMake q = do result <- liftIO $ registerDB q case result of Done -> return "success" AlreadyExist -> return "すでに同じ問題が存在します" ValueLack v -> return $ T.append v "は必須項目です" Fail -> return "fail. please report to developer."
registerDB
でファイルにクイズを書き込むアクションを実行し,
その結果でresultを束縛します.
この返り値RegisterResult
は次の値を取ります.
data RegisterResult = Done | AlreadyExist | ValueLack T.Text | Fail deriving Eq
Done
... クイズの登録に成功していますAlreadyExist
... 既に同じ問題が登録されている場合は,登録しません. apiを複数回叩いてしまうことを防止しています.ValueLack T.Text
... Quiz型のフィールドの中に抜けているものがある状態です. 解説文explanation
以外は必須項目なので,漏れがある場合は登録せず,漏れている 値のフィールド名を文字列で返します.Fail
... 登録に失敗しています.
JSONとQuiz型の変換
以上が書ければほぼ実装は終わりですが,最後に一つやり残しがあります. JSON型とQuiz型の相互変換です.api関数に書かれている
"quiz4" :> "get" :> Get '[JSON] Quiz
は,Quiz型の値を[JSON]形式で渡すことを意味しますが,
この処理はどこで行われるのでしょう.
これは,Quiz型をToJSON,FromJSON
という型クラスのインスタンスにすることで実現されます.
Quiz型のもつフィールド名とJSON内のフィールド名が異なる場合や,
少し処理を加えてから JSONと相互変換したい場合はごりごりinstanceにする処理を書かなければなりませんが,
フィールド名がQuiz型,JSON間で共通で何の処理もしない場合には,次の一行で解決です.
$(deriveJSON defaultOptions ''Quiz)
これだけで,JSONとQuiz型の FromJSON, ToJSON
のインスタンスを実装したことになります.
APIを使ってみる
ではAPIを叩いてみましょう.
クイズを取得
curlでは次のコマンドを叩くだけです.yourservernameには,localhostなどを入れてください. 何かしらのQuizを登録済みなら,その中の一個がJSONで返ってきます.
curl yourservername:8080/quiz4/get
jsでは,例えば次のようにかけます.
function getJsonData(url) { xhr = new XMLHttpRequest; xhr.open('GET', url, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = () => { console.log(xhr.responseText); var rec = JSON.parse(xhr.response); // rec.statementやrec.answerで各フィールドの値にアクセスできる } xhr.onerror = () => { console.log(xhr.status); } xhr.send(null); }
クイズを登録する
次のcurlでレスポンスが得られます.ここでは試しに,answerフィールドを空欄のままpostしました.
curl -X POST -H "Content-Type: application/json" -d '{"statement":"this is teset", "a":"a", "b":"b","c":"c","d":"d","answer":"","explanation":"this is test post"}' yourservername.com:8080/quiz4/make # ↓結果 解答は必須項目です
answerフィールドが抜けているので,解答は必須項目です
と返ってきました.
また,jsでは,例えば次のコードでpostできます.
function postJsonData(url, jsondata) { xhr = new XMLHttpRequest; xhr.open('post', url); ここでは//url = /quiz4/make xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onload = () => { console.log(xhr.status); console.log(jsondata); } xhr.onerror = () => { console.log(xhr.status); } xhr.send(jsondata); }
無事に四択クイズのwebアプリケーションを作ることができました. ユーザー認証やクイズジャンルを選べるようにするなど拡張性はまだまだありますが, とりあえず趣味で使う分には実用的なものができました.
おわりに
webアプリケーションなんもわからん!webAPIて何!?状態だったところから, たった二日(うち1日はjs側コードに苦しんだ)で形になるくらい, Servantは簡単でした.久々に感動したフレームワークです.webエンジニアデビュー成功,ということで.