Haskell
framework
stack
tonatona
HaskellDay 2

Haskellの新世代フレームワーク Tonatona

Haskell を用いたアプリケーション開発は
「新しく作るのには時間がかかってしまうが、その代わり強い静的型のおかげで保守性が高い」
と言われることがよくあります。
もちろん「いや、新しく作る際にもむしろ型のおかげですばやく作れる」などの反論もありますが、こういったトレードオフがあることもまた事実です。
さらに、Haskellの闇の力に飲まれてしまった方が書く厨ニ病コードは、本人すらも1週間後には意味がわからなくなって保守性すら低くなる怖さも秘めています。

今回ご紹介する Tonatona は、Haskellを用いたアプリケーション開発にありがちなこういった問題を解決して、今までの常識を覆す「メタアプリケーションフレームワーク」です。

公式リポジトリ

最新の内容はTonatonaのリポジトリにあります。
トナカイのコスプレをしたさくらちゃんが目印です。

logo

どんな人のためのフレームワークか

Tonatona は現実世界で実用的なアプリケーションを開発する上で障壁となる問題を解決するために、株式会社ARoWでの知見をもとに作成されたフレームワークです。
それを実現するために、上記の「闇の力」に繋がりうるHaskellの学術的なおもしろさを一切排除してあります。
そういった難しい部分を全部フレームワークの内部に閉じ込め、Tonatonaを利用する開発者から闇の力を使う自由を奪っています。
将来的には Haskell を全く知らない開発者にも使えるようにすることを考えており、むしろTonatonaを使うと開発が平凡でつまらないものになるはずです。

以上の理由から、次のような方には Tonatona はまったくオススメできません。

  • すでに闇の技術を使いこなしていてまったく困っていない方
  • 「Haskell使いこなしている俺かっけー」と闇の技術を使いこなしてエクスタシーを感じることに価値を置く方
  • Haskell の学術的な側面を支えて Haskell の発展に貢献してくださることで活躍されている方
  • 「ルールに従うのではなく、俺がルールを作るのだ!」という価値観の方

これらの方は「読んで損した!」と思う前に、このページを閉じていただくことで人生の貴重な時間を節約できます。

なぜ メタアプリケーションフレームワークなのか

Tonatona を「メタアプリケーションフレームワーク」と呼ぶ理由は、他のアプリケーションフレームワークとともに使うことを想定しているからです。
たとえばウェブアプリケーションフレームワークである Servant と一緒に使うことで、Servant の手が届かない部分を変わりに Tonatona が行い、Servant の良さを120%引き出すことができます。

Servant は「美しい」フレームワークですが

  • 開発環境/本番環境 などに応じてログレベルを変更したり
  • 環境変数に応じてサーバーのポート番号を変えたり
  • DBアクセスのライブラリと連携したり

といった娑婆世界の汚辱にまみれた部分は開発者が自分でコードを書かないといけません。

Haskell のライブラリには高潔で神聖な世界のみを救うものも多いですが、
Tonatona はこういった泥まみれな世界にも顕現して衆生を救ってくれるのです。

まずは試してみよう!

だらだら説法を続けられても眠くなるだけです。
抽象的な話はここまでにして、さっそく Tonatona を使ってみましょう。

Tonatona を使ったプロジェクトを始めるのはとっても簡単です。
stackさえ使えるようにしておけば、あとは Tonatona用のテンプレートを使ってプロジェクトを初期化できます。

$ stack new sample-app https://raw.githubusercontent.com/tonatona-project/tonatona/master/tonatona.hsfiles

このコマンドを実行すると、sample-app という名前のディレクトリが作成されるはずです。
作成された sample-app ディレクトリの構成は以下のようになっています。

sample-app
├── app
│   └── Main.hs
├── LICENSE
├── package.yaml
├── README.md
├── Setup.hs
├── src
│   └── TonaApp
│       └── Main.hs
├── stack.yaml
└── test
    ├── DocTest.hs
    └── Spec.hs

開発時に実際に触るのは package.yamlsrc/TonaApp/Main.hs がメインです。
まずは src/TonaApp/Main.hs の中身を見てみましょう。

module TonaApp.Main where

import RIO

import Tonatona (HasConfig(..), HasParser(..))
import qualified Tonatona.Logger as TonaLogger



-- App


app :: RIO Config ()
app = do
  -- Tonatona.Logger plugin enables to use logger functions without any configurations.
  logInfo $ display ("This is a skeleton for tonatona project" :: Text)
  logDebug $ display ("This is a debug message" :: Text)



-- Config


data Config = Config
  { tonaLogger :: TonaLogger.Config
  -- , anotherPlugin :: TonaAnotherPlugin.Config
  -- , yetAnotherPlugin :: TonaYetAnotherPlugin.Config
  }


instance HasConfig Config TonaLogger.Config where
  config = tonaLogger


instance HasParser Config where
  parser = Config
      <$> parser
      -- <*> parser
      -- <*> parser

たったこれだけですが、実はこれだけで結構おもしろいアプリケーションになっています。
少しずつ謎を解き明かしていきましょう。

まず見て分かるのが、rioライブラリを読み込んでいることです。
tonatonaRIOPrelude の代替として使っています。

import RIO

でも、特に rio ライブラリが何なのかについて詳しく知る必要はありません。
rio を知らない人は、むしろ rio の README を見ると混乱してしまうと思うので、見ない方がいいでしょう。
Tonatona は単に「便利だから」という理由で rio ライブラリを使っているだけなので、あえて rio の流儀に従う必要はありません。
RIO モジュール のドキュメントを見てわかるとおり
「Prelude に定義されている関数の IO aRIO env a に置き換えて、その他罠がある関数を安全にしたもの」です。

次の import を見てみましょう。

import Tonatona (HasConfig(..), HasParser(..))
import qualified Tonatona.Logger as TonaLogger

Tonatona が、Tonatona のメインのモジュールです。
Tonatona.Logger が、Tonatona が用意している Logging 用の「プラグイン」です。
実は Tonatona は「プラグインアーキテクチャ」を採用しており、プラグインを追加するだけで 設定なしに その機能を使えてしまうのです。
このプラグインアーキテクチャの強力さは、読み進めていくとすぐにわかります。
その凄まじさに戦慄することでしょう。

Tonatona では 慣習 として、プラグインのインポートは qualified インポートにして、TonaXXXXXX という形式の別名を付けます。

次に、メインのコードです。

app :: RIO Config ()
app = do
  -- Tonatona.Logger plugin enables to use logger functions without any configurations.
  -- (訳: Tonatona.Logger プラグインのおかげで、何の設定も記述せずに Logging 関数が使えます!)
  logInfo $ display ("This is a skeleton for tonatona project" :: Text)
  logDebug $ display ("This is a debug message" :: Text)

RIO モジュールを使っているため、メインの処理を記述するコードも IO () ではなく RIO Config () という型を持っています。
でも、使い勝手としてはほとんど IO () と変わらず、RIO モジュールに定義されている logInfologDebug を使っています。

logDebug :: (MonadIO m, MonadReader env m, HasLogFunc env, HasCallStack) => Utf8Builder -> m ()
logInfo :: (MonadIO m, MonadReader env m, HasLogFunc env, HasCallStack) => Utf8Builder -> m ()

本来、rio ではロギング用の関数を使うためには以下のような「儀式」が必要です。
これをまったく意識せずにロギング用の関数を使えるというだけでも、Tonatonaの強力さが分かると思います。

以下のコードの意味を理解する必要はないです。「こんな生贄の儀式がいるのか」と本来の苦労を知ってもらうのが目的です。

main :: IO ()
main = do
    let isVerbose = False -- get from the command line instead
    logOptions' <- logOptionsHandle stderr isVerbose
    let logOptions = setLogUseTime True logOptions'
    withLogFunc logOptions $ lf -> do
      let app = App -- application specific environment
            { appLogFunc = lf
            , appOtherStuff = ...
            }
      runRIO app $ do
        logInfo "Starting app"
        myApp

class HasConfig env where
  configL :: Lens' env Config
instance HasConfig Config where
  configL = id

data Env = Env { envLogFunc :: !LogFunc, envConfig :: !Config }
class (HasLogFunc env, HasConfig env) => HasEnv env where
  envL :: Lens' env Env
instance HasLogFunc Env where
  logFuncL = lens envLogFunc (\x y -> x { envLogFunc = y })
instance HasConfig Env where
  configL = lens envConfig (\x y -> x { envConfig = y })
instance HasEnv Env where
  envL = id

-- And then, at some other part of the code
data SuperEnv = SuperEnv { seEnv :: !Env, seOtherStuff :: !OtherStuff }
instance HasLogFunc SuperEnv where
  logFuncL = envL.logFuncL
instance HasConfig SuperEnv where
  configL = envL.configL
instance HasEnv SuperEnv where
  envL = lens seEnv (\x y -> x { seEnv = y })

さて、Tonatonaはこの黒魔術のコードを書く代わりに、次のようなヤギにも分かるようなボイラープレートを記述するだけで、闇に飲み込まれる心配もなく安全に力を手に入れることができます。

data Config = Config
  { tonaLogger :: TonaLogger.Config
  -- , anotherPlugin :: TonaAnotherPlugin.Config
  -- , yetAnotherPlugin :: TonaYetAnotherPlugin.Config
  }


instance HasConfig Config TonaLogger.Config where
  config = tonaLogger


instance HasParser Config where
  parser = Config
      <$> parser
      -- <*> parser
      -- <*> parser

全部で3つのパートからなっており、まず最初の Config 型で「どのプラグインを使うか」を Tonatona に指定します。
次の HasConfig のインスタンス宣言で、「実際にどのプラグインが Config のどのフィールドにあるのか」を明示します。
最後に HasParser のインスタンス宣言で、実際に Tonatona が各プラグインの機能を使えるようにします。

コメントが暗示しているように、他のプラグインを追加する時も何も難しいことを考える必要はありません。

  1. Config 型にそのプラグイン用のフィールドを追加して
  2. そのプラグイン用のHasConfig のインスタンス宣言を追加して
  3. HasParser のインスタンス宣言に1行 <*> parser を追加する

たったこれだけです。
トナカイのコスプレをしたヤギにもできそうです。

Haskell好きな人はこういうものを「めんどうだ」と感じる方が多いですが、打たないといけない文字数が増えることがそんなに問題なのでしょうか。
エディタの機能で工夫したらこんなの大したことないはずです。
この文字数が増える負担と、これを解決するためだけに闇に足を突っ込んで「ああ〜〜〜!コンパイルが通らんのじゃぁあああ!!!」と発狂するのとを比較した際に、前者を迷わず選ぶのが Tonatona の方針です。

でも、きっとどこかのバージョンアップで derivePlugin みたいなおまじないが追加されて文字を打つ負担も減ると思います。
それまではエディタを使いこなしてどうにかしてください。

ここまででコードの説明は終了です。
コードをコンパイルしましょう。

$ stack install --pedantic

これで無事にサンプルアプリが動くはずです。

$ stack exec sample-app
2018-11-18 21:15:09.594168: [info] This is a skeleton for tonatona project
@(src/TonaApp/Main.hs:16:3)
2018-11-18 21:15:09.594783: [debug] This is a debug message
@(src/TonaApp/Main.hs:17:3)

おめでとうございます! ちゃんと動きました。

でも、実は Tonatona のすごさに驚くのはまだ早いんです。
-h オプションを付けて実行してみましょう。

(stack に渡すオプションではなく、sample-app に渡すオプションなので、-- をはさむ必要があります。)

$ stack exec sample-app -- -h
Application deployment mode to run
    Default: Development
    Type: DeployMode (Development|Production|Staging|Test)
    Command line option: --env
    Environment variable: ENV

Make the operation more talkative
    Default: False
    Type: Bool (False|True)
    Command line option: --verbose
    Environment variable: VERBOSE

Display this help and exit
    Default: False
    Type: Bool
    Command line option: -h
    Command line option: --help

わーお!
いつの間にこんな機能が?
なんと、自動でヘルプを表示する機能が実装されています。

しかも、どうやら一部の設定は環境変数でもコマンドラインオプションでも指定できるようです。
ためしに Env=Production という環境変数をセットしてみましょう。

$ ENV=Production stack exec sample-app
This is a skeleton for tonatona project

すごい! 本番環境用にデバグログが全部消えました。

本番環境で、試しにログを全部出したい時はどうしたらいいのでしょうか?
ヘルプテキストにある --verbose オプションを渡してみます。

$ ENV=Production stack exec sample-app2 -- --verbose
2018-12-02 18:05:10.444735: [info] This is a skeleton for tonatona project
@(src/TonaApp/Main.hs:16:3)
2018-12-02 18:05:10.445249: [debug] This is a debug message
@(src/TonaApp/Main.hs:17:3)

まじで? 魔法じゃん!
いいえ、これが Tonatona のプラグインアーキテクチャが持つ真の力なのです。

環境変数やコマンドラインオプションから設定を引っ張るようにしているから、コードに何の設定も書かないで済むんですね!
この機能を実現しているのは tonaparser ライブラリですが、そういう苦労を外には見せずに粛々と仕事をこなしています。

本当に洗練された設計は、難しい部分を全部内部に隠すので、一見平凡でつまらなく見えるものです。

プラグインを追加してみる

実際に Tonatona を使ってアプリケーション開発をする際には、他に必要なプラグインを追加していきます。
ここでは sqlite データベースにアクセスするためのプラグインである Tonatona.Persist.Sqlite プラグインを追加します。
Tonatona.Persist.Sqlite はその名が暗示する通り、persistent ライブラリを使ってDBアクセスを記述するのをサポートしてくれるプラグインです。

package.yaml に依存ライブラリを追加する

まず、package.yamldependenciesTonatona.Persist.Sqlite プラグインのための tonatona-persisntent-sqlite ライブラリと、実際にDBアクセスを記述するために persistent 関連の2つのライブラリを追加します。

dependencies:
  - base >= 4.7 && < 5
  # `persistent` and `persistent-template` are also needed to
  # actually use `tonatona-persistent-sqlite`.
  - persistent
  - persistent-template
  - rio
  - tonatona
  - tonatona-logger
  # new plugin to add
  - tonatona-persistent-sqlite

プラグインを読み込む

次に、src/TonaApp/Main.hsTonatona.Persist.Sqliteimport します。

import qualified Tonatona.Persist.Sqlite as TonaDb

この import 宣言で TonaDb.* という形式でこのモジュールで定義されている関数を使えるようになりました。
Config 型にフィールドを追加しましょう。

data Config = Config
  { tonaLogger :: TonaLogger.Config
  , tonaDb :: TonaDb.Config
  }

あとは機械的に HasConfig のインスタンス宣言をして、

instance HasConfig Config TonaDb.Config where
  config = tonaDb

HasParser のインスタンス宣言に1行 <*> parser を追加するだけです。

instance HasParser Config where
  parser = Config
      <$> parser
      <*> parser

これだけで、persistentgetdelete を使うための TonaDb.run が使えるようになります。

ライブラリ特有の準備

ここまでで準備は完了したので、実際にDBアクセスをするようにコードを書いていきましょう。
最初にDBテーブルの設計をします。
ブログ記事を想定したテーブルで、id, author_name, contents の3つのフィールドを持っています。

{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}

import Database.Persist.TH (mkMigrate, mkPersist, persistLowerCase, share, sqlSettings)


$(share
  [mkPersist sqlSettings, mkMigrate "migrateAll"]
  [persistLowerCase|
    BlogPost
      authorName Text
      contents   Text

      deriving Show
    |]
 )

これはあくまで persistent のための記述です。
Tonatona.Persist.Sqlite 自体が必要としているものではありません。

実際の処理を記述する

ここまでの準備でいきなり実際にDBへの操作を書けるようになっています。

  • DBがどこにあるかとか
  • コネクションプールをどうしようかとか

そういうコードは一切必要ありません!

import Database.Persist (insert_)

app :: RIO Config ()
app = do
  logInfo $ display ("This is a skeleton for tonatona project" :: Text)
  logDebug $ display ("Migrating DB..." :: Text)
  -- DBにテーブルを作成
  TonaDb.runMigrate migrateAll
  logDebug $ display ("Running DB query..." :: Text)

  -- `TonaDb.run` の中で `persistent` のDB操作関数が使えます
  TonaDb.run $ do
    -- `TonaDb.run` の中でも、`lift` を使うことで他のプラグインの機能をそのまま使えます。
    -- ここでは `Tonatona.Logger` の機能を使ってログ出力を行っています。
    lift $
      logInfo $ display $
        ("This log is called inside of `TonaDb.run`" :: Text)
    -- `persistent` に定義されている `insert_` 関数です。
    insert_ $ BlogPost "Mr. Foo Bar" "This is an example blog post"

  logInfo $ display ("Successfully inserted a blog post!" :: Text)

完成したコードの全体像

最終的にこのようなコードになりました。

{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}

module TonaApp.Main where

import RIO

import Database.Persist (insert_)
import Database.Persist.TH (mkMigrate, mkPersist, persistLowerCase, share, sqlSettings)
import Tonatona (HasConfig(..), HasParser(..))
import qualified Tonatona.Logger as TonaLogger
import qualified Tonatona.Persist.Sqlite as TonaDb


-- DB entity defs


$(share
  [mkPersist sqlSettings, mkMigrate "migrateAll"]
  [persistLowerCase|
    BlogPost
      authorName Text
      contents   Text

      deriving Show
    |]
 )



-- App


app :: RIO Config ()
app = do
  logInfo $ display ("This is a skeleton for tonatona project" :: Text)
  logDebug $ display ("Migrating DB..." :: Text)
  -- DBにテーブルを作成
  TonaDb.runMigrate migrateAll
  logDebug $ display ("Running DB query..." :: Text)

  -- `TonaDb.run` の中で `persistent` のDB操作関数が使えます
  TonaDb.run $ do
    -- `TonaDb.run` の中でも、`lift` を使うことで他のプラグインの機能をそのまま使えます。
    -- ここでは `Tonatona.Logger` の機能を使ってログ出力を行っています。
    lift $
      logInfo $ display $
        ("This log is called inside of `TonaDb.run`" :: Text)
    -- `persistent` に定義されている `insert_` 関数です。
    insert_ $ BlogPost "Mr. Foo Bar" "This is an example blog post"

  logInfo $ display ("Successfully inserted a blog post!" :: Text)



-- Config


data Config = Config
  { tonaLogger :: TonaLogger.Config
  , tonaDb :: TonaDb.Config
  }


instance HasConfig Config TonaLogger.Config where
  config = tonaLogger


instance HasConfig Config TonaDb.Config where
  config = tonaDb


instance HasParser Config where
  parser = Config
      <$> parser
      <*> parser

実行してみる

では、これをコンパイルしてヘルプを見てみましょう。

$ stack install --pedantic
$ stack exec sample-app -- -h
Application deployment mode to run
    Default: Development
    Type: DeployMode (Development|Production|Staging|Test)
    Command line option: --env
    Environment variable: ENV

Make the operation more talkative
    Default: False
    Type: Bool (False|True)
    Command line option: --verbose
    Environment variable: VERBOSE

Formatted string to connect postgreSQL
    Default: :memory:
    Type: String
    Command line option: --db-conn-string
    Environment variable: DB_CONN_STRING

Number of connections which connection pool uses
    Default: 10
    Type: Int
    Command line option: --db-conn-num
    Environment variable: DB_CONN_NUM

Display this help and exit
    Default: False
    Type: Bool
    Command line option: -h
    Command line option: --help

どのDBにつなぐかとか、コネクションプールのコネクション数をどうするかなど、環境変数やコマンドラインオプションで指定できるようになっています。

デフォルト値で実行すると以下のようにDBアクセスできていそうなことが確認できます。

$ stack exec sample-app
2018-12-02 18:45:01.605876: [info] This is a skeleton for tonatona project
@(src/TonaApp/Main.hs:36:3)
2018-12-02 18:45:01.606421: [debug] Migrating DB...
@(src/TonaApp/Main.hs:37:3)
Migrating: CREATE TABLE "blog_post"("id" INTEGER PRIMARY KEY,"author_name" VARCHAR NOT NULL,"contents" VARCHAR NOT NULL)
[Debug#SQL] CREATE TABLE "blog_post"("id" INTEGER PRIMARY KEY,"author_name" VARCHAR NOT NULL,"contents" VARCHAR NOT NULL); []
2018-12-02 18:45:01.608972: [debug] Running DB query...
@(src/TonaApp/Main.hs:40:3)
2018-12-02 18:45:01.609197: [info] This log is called inside of `TonaDb.run`
@(src/TonaApp/Main.hs:47:7)
[Debug#SQL] INSERT INTO "blog_post"("author_name","contents") VALUES(?,?); [PersistText "Mr. Foo Bar",PersistText "This is an example blog post"]
[Debug#SQL] SELECT "id" FROM "blog_post" WHERE _ROWID_=last_insert_rowid(); []
2018-12-02 18:45:01.609775: [info] Successfully inserted a blog post!
@(src/TonaApp/Main.hs:52:3)

プラグイン紹介

Tonatona のプラグインは現在以下のものが提供されています。
(Hackage や Stackage にはまだアップロードしていませんが、stack new でテンプレートから作成する場合にはすぐに使えるようになっています)
新しいプラグインの作成も歓迎しますので、「persisntent よりも HRRだろ!」って方はぜひご協力ください。

まとめ

トナカイのコスプレをしたさくらちゃんがかわいいメタアプリケーションフレームワークである Tonatona をご紹介しました。
プラグインアーキテクチャの威力と、考え抜かれてむしろ平凡に感じるような設計思想によって
「Tonatona を使えば新規開発も高速にでき、保守性も高い」と言われる未来の一端を感じていただけたなら幸いです。

さくらちゃんにご飯をあげる
さくらちゃんをもっと見る
他の記事を見る

tail-0.jpg