wvogel日記

自分用の技術備忘録が多めです.

四択クイズwebアプリ with Servant

動機

マーク式の資格試験などに使える,クイズアプリケーションを作りたい. しかしyesodなどで一からweb作るのは面倒だし,そもそもyesodは大量のメモリを 消費するので手持ちのVPSでは走らない...
web APIとして軽量に,しかもさくっと作りたい!(そうしないと資格勉強時間が...)

ということで,はじめてのwebアプリ開発のツールに選んだのはServantです.
素晴らしいことに,webアプリ開発経験0の私ですら,Servant側で詰まったことは 一度もなかったので,(サーバーの仕組みよくわからん)(webアプリ難しそう) といった超初心者にこそお勧めしたいフレームワークです.
javascriptAPIを呼ぶところだけつまずきましたが, こちらも多くの場合ブラウザの"賢い"キャッシュ機能によるものでした.

そもそもServantって?

詳しい記事はたくさんあるのであちこち参照してみてください. webアプリ知識0なので他の言語・フレームワークでは どういう風に開発するのか全く知りませんが,開発を終えてみて理解したことは

  • Haskellによるwebフレームワーク.割と軽量.
  • いつも通り型を合わせていけば動く.間違いなく動く
  • 通信部で書くことが本当に少ないので,サーバーの仕組みを理解しやすい
  • 通信部と処理部を完全に分離して実装することができる
  • メンテも簡単!

webアプリ開発って難しそう〜〜と思っていましたが,本当に簡単に書けてしまいます.

はじめに

本記事のソースコードは全て https://github.com/wvogel00/quiz4/ に置いてあります. GHC拡張やimportモジュールはそちらを参照してください.

そもそもwebAPIって?

(私が勝手につまずいていただけなので読み飛ばしてもらって大丈夫です)

私はこれまで測定器やハードウェアまわりのAPIしか触ったことがありませんでした. 例えばYOKOGAWAのTIA装置TA720を使って, 通信機の基準信号を1日かけて測定する測定系(アプリケーション)を作るとしましょう.

image 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などを作れば,次のようなページが表示されます.

1

では,肝心の二つの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エンジニアデビュー成功,ということで.