はじめに
HanamiというRubyフレームワークが話題にあがっているようです。
今年のRuby Kaigi 2017でもAnton Davydov氏が発表されるようです。
Hanami – New Ruby Web Framework
今回はそんなHanamiのチュートリアルをやってみたいと思います。
公式ドキュメントはこちらになります。
HANAMI
日本語の「花見」から来ているのでしょう、TOPページは桜ですかね?
紹介
GUIDEの部分を和訳しているだけです。
(和訳はもしかすると誤りがあるかもしれませんが。。。その場合はコメントをいただけると幸いです。)
Hanamiとは?
Hanamiは、多くのマイクロライブラリーで構成された、RubyのMVCフレームワークです。
シンプルで安定したAPIと最小限のDSLを持ち、あまりにも多くの責任と魔法のように複雑なクラスを越え、プレーンなオブジェクトの使用を優先します。
明確な責任を持つ単純なオブジェクトを使用することにより、より定型的なコードになります。 Hanamiは、基本的な実装を維持しながら、余分な箇所を軽減する方法を提供します。
Hanamiを選ぶ理由
Hanamiを選ぶ理由は3つあります。
軽量
花見のコードは比較的短いです。実装に関係なく、すべてのWebアプリケーションが必要とすることのみに関心があります。
Hanamiにはいくつかのオプションモジュールが付属しており、他のライブラリも簡単に組み込むことができます。
アーキテクチャとして
あなたが「Rails Way」に反していると感じたことがあるなら、あなたは花見を良いと思うでしょう。
Hanamiはコントローラの動作をクラスベースに保ち、独立してテストしやすくしています。
また、Hanamiはユースケースオブジェクト(別名:interactors
)にアプリケーション・ロジックを書くことをお勧めします。
ビューはテンプレートから分離されているため、内部のロジックを十分に包含してテストすることができます。
ちなみにHanamiはクリーンアーキテクチャに影響を受けているとのことです。
スレッドセーフ
スレッドの使用は、アプリケーションのパフォーマンスを向上させるのに最適です。
スレッドセーフなコードを書くのは難しいことではありませんし、Hanami(フレームワーク全体かその一部かに関わらず)は実行時スレッドセーフです。
チュートリアルのための準備
下記リンクのチュートリアルを実施していきます。
Getting Started
作成するものは、下記のような「本棚アプリ」になります。
「はじめに」にあたる部分のこの一言は良いですね。
But without change, there is no challenge and without challenge, there is no growth.
チュートリアルを進める上で必要な環境としては、
- Ruby 2.3以上
- SQLite 3以上
になります。
まずはGemのインストールです。
Gitihubリポジトリはこちらです。
1 | $gem install hanami |
プロジェクトを作成します。
デフォルトのDBはSQLiteになるようです。
1 | $hanami newbookshelf |
いろいろファイルが生成されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | bookshelf ├apps ├config ├db ├lib ├public ├spec ├.env.development ├.env.test ├.gitignore ├.hanamirc ├config.ru ├Gemfile ├Gemfile.lock └Rakefile |
Gemfile
を見てみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | source'https://rubygems.org' gem'rake' gem'hanami', '~> 1.0' gem'hanami-model','~> 1.0' gem'sqlite3' group:developmentdo # Code reloading # See: http://hanamirb.org/guides/projects/code-reloading gem'shotgun' end group:test,:developmentdo gem'dotenv','~> 2.0' end group:testdo gem'minitest' gem'capybara' end group:productiondo # gem 'puma' end |
テストフレームワークはminitest
なのに、spec
ディレクトリが生成されているんですね。
(これは注釈がありましたが、minitest
でもRSpec
でもどちらでもいけるそうです。)
これらのGemをインストールしてサーバーを起動します。
サーバーは2300番ポートで起動します。
1 | $bundle install |
1 2 3 4 | $bundle exec hanami server INFO WEBrick1.3.1 INFO ruby2.4.1(2017-03-22)[x86_64-darwin14] INFO WEBrick::HTTPServer#start: pid=39631 port=2300 |
これでhttp://localhost:2300
にアクセスすると、下記のページが表示されます。
実践
はじめてのテスト
テストをする前に、テスト環境のDBをセットアップしておきます。
1 | $HANAMI_ENV=test bundle exec hanami db prepare |
これでdb
ディレクトリにSQLiteのファイルができます。
HanamiはBDD
(Behavior Driven Development
)を推奨しています。
実際にチュートリアルを実施する際には、テストコードから書いていますが、本記事ではテストコードは割愛させていただきます。
途中まで載せていたのですが、文章が冗長になってしまったので、泣く泣く削除しました。
もしテストコードもご覧になる場合は、下記のGithubリンクにてお願いします。
https://github.com/naoki85/bookshelf/tree/master/spec
(一応日本語でコメントもいれています。)
まずはルーティングを定義します。
1 | rootto:'home#index' |
ここはRailsと変わりません。
Homeコントローラーのindexメソッドを作成。。。と思ったら、コントローラーの定義の仕方が違いました。
コントローラーはまず、apps/web/controllers
の下にhome
ディレクトリを作成して、その中にindex.rb
というファイルを作成します。
1 2 3 4 5 6 7 8 | moduleWeb::Controllers::Home classIndex includeWeb::Action defcall(params) end end end |
モジュールで名前空間を切って、Railsでいうアクションがクラスになっています。include Web::Action
は現状、決まり文句でよさそうです。def call(params)
はマジックメソッドみたいなもので、このメソッドがないと呼ばれません。params
にGETやPOSTなどで渡されたパラメータが格納されます。
次にビューですが、基本的にレンダリングするだけであれば、templates
以下に命名規則に従ってファイルを作成することで、そのテンプレートを表示してくれます。
1 | <h1>Bookshelf</h1> |
ここまでで、テストは通りますが、views
以下にビューコントローラーのようなファイルを作成しておきます。
今回は特にビューにロジックはないため、空のファイルですが、ヘルパー的なものを使用する場合やケースバイケースでテンプレートを変える場合などにお世話になりそうです。
1 2 3 4 5 | moduleWeb::Views::Home classIndex includeWeb::View end end |
こちらも名前空間を切って、決まり文句のinclude Web::View
を記載しておきます。
テストを実行して、通ればOKです。
新しいアクションを生成
今度は本の一覧を表示するアクションを追加していきます。
Hanami Generatorでアクションを生成
rails generate
のようなコマンドがHanamiにもあります。
指定したファイル群を生成してくれます。
今回はbooks#index
を生成します。
1 2 3 4 5 6 7 | $bundle exec hanami generate action web books#index create spec/web/controllers/books/index_spec.rb create apps/web/controllers/books/index.rb create apps/web/views/books/index.rb create apps/web/templates/books/index.html.erb create spec/web/views/books/index_spec.rb insert apps/web/config/routes.rb |
テストコードも含めて生成されます。routes.rb
にも自動で追記されています。
ただ、Railsのようにテンプレートの中身まで勝手に作ってくれないようなので、実際に描画されるHTMLは記載する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <h1>Bookshelf</h1> <h2>All books</h2> <div id="books"> <div class="book"> <h3>Patterns of Enterprise Application Architecture</h3> <p>by<strong>Martin Fowler</strong></p> </div> <div class="book"> <h3>Test Driven Development</h3> <p>by<strong>Kent Beck</strong></p> </div> </div> |
これでテストは通ると思います。
共通部分をLayoutにまとめる
templates/home/index.html.erb
とtemplates/books/index.html.erb
の両方に<h1>Bookshelf</h1>
が含まれています。
これは共通部分として、共通のテンプレートに記載します。
共通テンプレートはtemplates/application.html.erb
になります。
1 2 3 4 5 6 7 8 9 10 11 12 | <!DOCTYPE html> <html> <head> - <title>Web</title> + <title>Bookshelf</title> <%=favicon%> </head> <body> + <h1>Bookshelf</h1> <%=yield%> </body> </html> |
これで共通部分に移せたので、先ほどの2つのファイルからh1
タグを消しておきます。
モデルの作成
Hanamiのモデルにはentity
とrepository
の2種類があります。
厳密な区分は違うと思いますが、私の解釈としては、
entity
…SQLに依存しない状態でオブジェクトを管理する場合などrepository
…テーブルにレコードを作成、更新、削除する場合など(SQLを発行)
SQLを発行するモデルと、DBとは関係なく状態を管理できるモデルという認識です。
実際に使ってみたいと思います。hanami generate model
で、マイグレーションファイルを含め、モデルに関連するファイルを生成できます。
1 2 3 4 5 6 | $bundle exec hanami generate model book create lib/bookshelf/entities/book.rb create lib/bookshelf/repositories/book_repository.rb create db/migrations/20170911043801_create_books.rb create spec/bookshelf/entities/book_spec.rb create spec/bookshelf/repositories/book_repository_spec.rb |
apps
ではなく、libs
以下にモデルが作成されました。
ビジネスロジックはlibs
以下で管理し、apps
以下に複数のマイクロサービスを管理するという考えです。
これはHanamiの大きな特徴の一つです。
マイグレーションファイルは下記のように、title
とauthor
カラムを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 | Hanami::Model.migrationdo changedo create_table:booksdo primary_key:id column:title, String,null:false column:author,String,null:false column:created_at,DateTime,null:false column:updated_at,DateTime,null:false end end end |
マイグレーションを実行します。
1 2 | $bundle exec hanami db prepare $HANAMI_ENV=test bundle exec hanami db prepare |
色々メッセージが出ると思いますが、下記のような状態になるかと思います。
(確認にはDB Browser for SQLiteを使用しました。)
生成されたentity
、repository
のファイルはそれぞれ下記になります。lib
ディレクトリ以下に作成されており、ビジネスロジックは分けるという考えが伺えます。
1 2 | classBook<Hanami::Entity end |
1 2 | classBookRepository<Hanami::Repository end |
それぞれ、HanamiのEntityとRepositoryを継承しており、これだけで最低限の動作ができるようです。
(RailsのActiveRecordみたいな感じですね。)
ちょっとしたコードの確認はhanami console
でできます。
色々いじってみたので、まとめます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | $bundle exec hanami console >>book=Book.new(title:'title No.1') =>#<Book:0x007f89436fa468 @attributes={:title=>"title No.1"}> >>book.save # saveなどのメソッドはないので、エラーになります。 # テーブルからレコードを取得する場合はRepositoryのインスタンスからやります。 >>repository=BookRepository.new =>#<BookRepository relations=[:books]> # 全件取得(まだレコードはない) >>repository.all SELECT`id`,`title`,`author`,`created_at`,`updated_at`FROM`books`ORDER BY`books`.`id` =>[] # レコード作成 # 戻り値はBookオブジェクト >>book=repository.create(title:'TDD',author:'Kent Beck') INSERT INTO`books`(`title`,`author`,`created_at`,`updated_at`)VALUES('TDD','Kent Beck', '2017-09-11 06:20:12.065528','2017-09-11 06:20:12.065528') SELECT`id`,`title`,`author`,`created_at`,`updated_at`FROM`books`WHERE( `id`IN(2))ORDER BY`books`.`id` =>#<Book:0x007f8943694550 @attributes={:id=>2, :title=>"TDD", :author=>"Kent Beck", :created_at=>2017-09-1106:20:12UTC,:updated_at=>2017-09-1106:20:12UTC}> # Bookオブジェクトなので、値が取得できる >>book.id =>2 # この値を使って再取得 >>repository.find(book.id) SELECT`id`,`title`,`author`,`created_at`,`updated_at`FROM`books`WHERE(`books`.`id`=2) ORDER BY`books`.`id` =>#<Book:0x007f8943675da8 @attributes={:id=>2, :title=>"TDD", :author=>"Kent Beck", :created_at=>2017-09-1106:20:12UTC,:updated_at=>2017-09-1106:20:12UTC}> |
Indexの修正
本の一覧表示であるindex
アクションを修正してDBから引っ張ってくるようにします。
まずはコントローラーです。
1 2 3 4 5 6 7 8 9 10 11 | moduleWeb::Controllers::Books class Index include Web::Action + expose :books def call(params) + @books = BookRepository.new.all end end end |
@books = BookRepository.new.all
でbooks
テーブルからレコードを全件取得してインスタンス変数にセットします。
ただ、views
、およびtemplates
に渡すためにはexposeで指定する必要があります。
これでbooks
変数をviews
およびtemplates
にて使用することができるようになりました。templates/books/index.html.erb
を下記のように書き換えます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <h2>Allbooks</h2> <%ifbooks.any?%> <div id="books"> <%books.eachdo|book|%> <div class="book"> <h2><%=book.title%></h2> <p><%=book.author%></p> </div> <%end%> </div> <%else%> <pclass="placeholder">There are no books yet.</p> <%end%> |
これで、DBから取得した値の一覧画面ができました。
新規登録画面と登録処理
登録フォームと登録処理を作成していきます。
hanami generateでbooks#newを生成
また、hanami generate
コマンドで生成します。
1 2 3 4 5 6 7 | $bundle exec hanami generate action web books#new create spec/web/controllers/books/new_spec.rb create apps/web/controllers/books/new.rb create apps/web/views/books/new.rb create apps/web/templates/books/new.html.erb create spec/web/views/books/new_spec.rb insert apps/web/config/routes.rb |
先ほどと同様、ルーティングへの追記や必要なファイルは全て生成されています。
ただ、テンプレートは作成する必要があります。
今回は入力フォームが必要なので、HanamiのFormHelpersを使用します。
ここはRailsを初めとしたWebフレームワークに触ったことがあれば、理解しやすいかと思います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | <h2>Addbook</h2> <%= form_for:book,'/books'do divclass:'input'do label :title text_field:title end divclass:'input'do label :author text_field:author end divclass:'controls'do submit'Create Book' end end %> |
hanami generateでbooks#createを生成
実際に新規登録処理をするbooks#createを生成します。
1 2 3 4 5 6 7 | $bundle exec hanami generate action web books#create create spec/web/controllers/books/create_spec.rb create apps/web/controllers/books/create.rb create apps/web/views/books/create.rb create apps/web/templates/books/create.html.erb create spec/web/views/books/create_spec.rb insert apps/web/config/routes.rb |
ちなみにcreate
のルーティングはPOSTメソッドとして解釈されて追記されます。
1 | post'/books',to:'books#create' |
ただ、コントローラーは修正の必要があるので、追記します。
1 2 3 4 5 6 7 8 9 10 11 | moduleWeb::Controllers::Books classCreate includeWeb::Action defcall(params) BookRepository.new.create(params[:book]) redirect_to'/books' end end end |
かなり簡潔ですが、後ほどバリデーションを追加した際に、登録失敗した場合の処理も記載します。
パラメーターのバリデーション
バリデーションですが、モデルに書くものかと思いきや、Hanamiではコントローラー内のparams
で実施できるようです。
Parameters
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | moduleWeb::Controllers::Books classCreate includeWeb::Action paramsdo required(:book).schemado required(:title).filled(:str?) required(:author).filled(:str?) end end defcall(params) ifparams.valid? BookRepository.new.create(params[:book]) redirect_toroutes.books_path else self.status=422 end end end end |
require
で型を判定しています。で存在確認、
filled
実際に動かしてみると分かりますが、params
をvalid?
したタイミングで、下記のようなハッシュが含まれるようになります。
1 2 | # 無効なパラメーターだった場合 {valid?:false,error_messages:['Title must be filled','Author must be filled']} |
valid?
がfalse
だった場合は、HTTPステータスコードの422番をセットして返します。
普通であれば、再度new.html.erb
をレンダリングするよう指示するところですが、それはコントローラーではなく、ビュー側の領分になります。
1 2 3 4 5 6 | moduleWeb::Views::Books classCreate includeWeb::View template'books/new' end end |
これでもしパラメーターが無効であっても、new.html.erb
をレンダリングできます。
最後にエラーメッセージを表示する部分を追記します。
1 2 3 4 5 6 7 8 9 10 11 12 13 | <h2>Addbook</h2> <%unlessparams.valid?%> <divclass="errors"> <h3>Therewasaproblemwithyoursubmission</h3> <ul> <%params.error_messages.eachdo|message|%> <li><%=message%></li> <%end%> </ul> </div> <%end%> # 省略 |
ここまでで、下図のような画面になります。
ルーティングの修正
HanamiにもRESTなルーティングを生成できるresourcesが存在するため、そちらで修正しておきます。
この使い方はRailsとほとんど同じですね。
1 2 3 4 5 | -post '/books', to: 'books#create' -get '/books/new', to: 'books#new' -get '/books', to: 'books#index' rootto:'home#index' +resources :books, only: [:index, :new, :create] |
さいごに
チュートリアルが終わった後、CSSにMaterializeを使用してみてスタイリングしてみました。
ただ、Assets
まわりはよく分からなく、CDNでもドツボにはまってしまったので、フォントなどは使えていません。
チュートリアル終了〜上記の画面までの差分はこちらになります。
https://github.com/naoki85/bookshelf/commit/7a589c97560d9144bb1a0138241b949793fb8b06
本家のチュートリアルと合わせて、こちらの記事も一緒に読み進めながらやってみました。おかげさまで内容を理解しながら完了できました。本当に助かりました。