Ruby の Web フレームワーク Hanami の特徴

こんにちは。 Ruby エンジニアの水野です。

みなさまは「 Ruby の Web フレームワーク」というと何を思い浮かべますか? 恐らくほとんどの方が Ruby on Rails を第一に思い浮かべると思います。

続いてジャンルが異なる質問になりますが、「春」といえば何を思い浮かべますか? さまざまな答えがあると思いますが、僕は花が好きですので「桜」を思い浮かべます。 そして「桜」といえば「花見」!

上記を理由に、名前に惹かれて好きになった Hanami の特徴を少しお話をしていきます。

Hanami とは

Hanami | The web, with simplicity

比較的新しめにリリースされた Ruby の Web フレームワークです。
DDD (ドメイン駆動設計) をベースに構成されているので、長期的にメンテナンスをすることに向いているフレームワークになります。

個人的に Hanami の Web ページのファーストビューが大好きですw

以降は、以下の Docker 環境内で試して僕が感じた Hanami の特徴を書いていきます!
楽をしたかったのでお試しで触る程度の予定ですので、敢えて Docker 環境内で完結させています。

ARG RUBY_VERSION
FROM ruby:$RUBY_VERSION

ENV LANG=C.UTF-8

RUN apt-get update -qq \
 && DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \
    libpq-dev \
    vim \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/* \
 && truncate -s 0 /var/log/*log

WORKDIR /app
RUN gem update --system \
 && rm /usr/local/lib/ruby/gems/*/specifications/default/bundler-*.gemspec \
 && gem install hanami \
 && hanami new . --database=mysql --template=slim \
 && bundle install
version: '3'
services:
  hanami:
    build:
      context: .
      dockerfile: ./Dockerfile
      args:
        RUBY_VERSION: '2.6.6'
    command: 'bundle exec hanami server --host 0.0.0.0 -p 2300'
    ports:
      - 2300:2300
    stdin_open: true
    tty: true
  mysql:
    image: mysql:5.7.30
    ports:
      - 3306:3306
    environment:
      MYSQL_DATABASE: 'app_development'
      MYSQL_USER: 'app_user'
      MYSQL_PASSWORD: 'app_password'
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'

コンテナ起動

適当なディレクトリを作って

mkdir hanami-test && cd hanami-test

ファイルを用意して

[~/workspace/hanami-test] > ls
Dockerfile      docker-compose.yml

コンテナを起動して

docker-compose up -d

http://localhost:2300 で接続すると・・・

画面が表示されたので準備完了です!
いざ Hanami の世界へ!

Hanami のコマンド

Rails ライクなコマンドになっていますので、 Rails を知っていれば容易に使えそうな印象です。

例えば画面に表示されたコマンドも Rails エンジニアの方でしたらなんとなくわかるのではないでしょうか?
もっとも、英語を読めば何が処理されるかわかりますが、コンテナの中に入ってコマンドを実行してみましょう。

docker-compose exec hanami bash
bundle exec hanami generate action web 'home#index' --url=/
root@a6ffd49554f8:/app# bundle exec hanami generate action web 'home#index' --url=/
      create  apps/web/controllers/home/index.rb
      create  apps/web/views/home/index.rb
      create  apps/web/templates/home/index.html.slim
      create  spec/web/controllers/home/index_spec.rb
      create  spec/web/views/home/index_spec.rb
      insert  apps/web/config/routes.rb

何やらたくさんファイルが作られましたが、 Action 関連を生成してくれます。
Rails と同様な Generator コマンド機能の認識で良さそうですね。

生成されたファイルを見ていきましょう。
テストに関しては省略します。

Action (Controller)

Hanami では Controller は Action 毎にファイルが必要です。

module Web
  module Controllers
    module Home
      class Index
        include Web::Action

        def call(params)
        end
      end
    end
  end
end

ネストが深くなってしまうのが気になるので、実際は以下のようにスコープ演算子で書くのかなと思います。

module Web::Controllers::Home
  class Index
    include Web::Action

    def call(params)
    end
  end
end

Hanami のコンセプトとして、 Modularity があり、継承を使わずにモジュールで mix-in を多用しています。
何が Controller で何が Action なのか、わかりやすくなっていますね!

View

Hanami では View は HTML テンプレートとクラスで構成されています。
以下の2つのファイルが該当します。

apps/web/views/home/index.rb
apps/web/templates/home/index.html.slim

今回は slim をテンプレートに指定していますが、もちろん erb や haml も使えます。

テンプレートには何も書かれていなかったので、クラスを見ていきましょう。

module Web
  module Views
    module Home
      class Index
        include Web::View
      end
    end
  end
end

Controller と同様ですね。
ネストが気になるので以下に変更します。

module Web::Views::Home
  class Index
    include Web::View
  end
end

HTML テンプレートとクラスは 1 対 1 に対応しているようなので、実際に書いて試してみましょう。
今回は試しませんが、 1 対 1 で対応しているため、他のクラスのメソッドを呼び出そうとするとエラーになるようです。

module Web::Views::Home
  class Index
    include Web::View
    
    def title
      "Hello World"
    end
  end
end
h1 = title

http://localhost:2300 で接続してみましょう。

表示されましたね!

このように、クラスに定義したメソッドを HTML テンプレートから呼び出すことができます。
Rails で例えると Helper や ActiveDecorator などに値しますが、デフォルトでスコープが制限されているのが非常にわかりやすいと思います。

Routing

Hanami は Rack アプリケーションなので、基本的に Rails と同様です。
もちろん異なる箇所もありますので、気になる方は下記をご覧になってみてください。
- Routing: Basic Usage | Hanami Guides

# Configure your routes here
# See: https://guides.hanamirb.org/routing/overview
#
# Example:
# get '/hello', to: ->(env) { [200, {}, ['Hello from Hanami!']] }
get '/', to: 'home#index'

最終行が Generator コマンドによって登録された Routing です。
前述の通り、 Rack アプリケーションなので #call メソッドを持つオブジェクトを渡すと実行してくれます。
正確には違いますので、詳細は下記をご覧になってみてください。

File: SPEC — Documentation for rack/rack (master)

A Rack application is a Ruby object (not a class) that responds to call. It takes exactly one argument, the environment and returns an Array of exactly three values: The status, the headers, and the body.

ひとつ上のコメントを外し、 http://localhost:2300/hello で接続してみましょう。
Hello from Hanami! と表示されるはずです。
Proc オブジェクトなので、 #call メソッドを持っています。

表示されましたね!

Model

まだ Model が登場していませんでしたね。
Rails と比べると全然違いますので、特に目立つ点だと思います。

Generator コマンドで Model を生成してみましょう。

bundle exec hanami generate model flower
root@a6ffd49554f8:/app# bundle exec hanami generate model flower
      create  lib/app/entities/flower.rb
      create  lib/app/repositories/flower_repository.rb
      create  db/migrations/20200611064756_create_flowers.rb
      create  spec/app/entities/flower_spec.rb
      create  spec/app/repositories/flower_repository_spec.rb

Hanami では RepositoryEntity で Model が 2 種類に分離されています。 Migration ファイルも作成されましたね。 見ていきましょう。

Migration

Hanami::Model.migration do
  change do
    create_table :flowers do
      primary_key :id

      column :created_at, DateTime, null: false
      column :updated_at, DateTime, null: false
    end
  end
end

構造は違えど、基本的に Rails と同様のようですね。 同じ感覚でカラムを追加できそうですので、お花たちに名前をつけてあげましょう。

Hanami::Model.migration do
  change do
    create_table :flowers do
      primary_key :id

      column :name, String, null: false, unique: true

      column :created_at, DateTime, null: false
      column :updated_at, DateTime, null: false
    end
  end
end

では Migration してみましょう! ですが、その前に使用するデータベースの設定をする必要があります。 環境変数 DATABASE_URL を変更しましょう。 ホスト名は Docker コンテナのサービス名を指定すれば名前解決してくれます。

# Define ENV variables for development environment
DATABASE_URL="mysql2://app_user:app_password@mysql:3306/app_development"

データベースはイメージの起動時に作成済みなので、下記コマンドで Migration します。

bundle exec hanami db migrate
root@a6ffd49554f8:/app# bundle exec hanami db migrate
[hanami] [INFO] (0.000433s) SET @@wait_timeout = 2147483
[hanami] [INFO] (0.000299s) SET SQL_AUTO_IS_NULL=0
[hanami] [ERROR] Mysql2::Error: Table 'app_development.schema_migrations' doesn't exist: SELECT NULL AS `nil` FROM `schema_migrations` LIMIT 1
[hanami] [INFO] (0.019519s) CREATE TABLE `schema_migrations` (`filename` varchar(255) PRIMARY KEY)
[hanami] [ERROR] Mysql2::Error: Table 'app_development.schema_info' doesn't exist: SELECT NULL AS `nil` FROM `schema_info` LIMIT 1
[hanami] [INFO] (0.005298s) SELECT `filename` FROM `schema_migrations` ORDER BY `filename`
[hanami] [INFO] Begin applying migration 20200611064756_create_flowers.rb, direction: up
[hanami] [INFO] (0.025800s) CREATE TABLE `flowers` (`id` integer PRIMARY KEY AUTO_INCREMENT, `name` varchar(255) NOT NULL UNIQUE, `created_at` datetime NOT NULL, `updated_at` datetime NOT NULL)
[hanami] [INFO] (0.001341s) INSERT INTO `schema_migrations` (`filename`) VALUES ('20200611064756_create_flowers.rb')
[hanami] [INFO] Finished applying migration 20200611064756_create_flowers.rb, direction: up, took 0.029871 seconds

これで flowers テーブルが作成されました!

mysql> show tables;
+---------------------------+
| Tables_in_app_development |
+---------------------------+
| flowers                   |
| schema_migrations         |
+---------------------------+
2 rows in set (0.01 sec)

Entity

class Flower < Hanami::Entity
end

Hanami は Entity で RDBMS などのテーブルやレコードを Ruby のオブジェクトとして扱えます。
イミュータブルで、インスタンス化するときにしか値を入力することができません。

bundle exec hanami console
irb(main):001:0> flower = Flower.new(name: "Sakura")
=> #<Flower:0x000055d1015a27d8 @attributes={:name=>"Sakura"}>
irb(main):002:0> flower.name
=> "Sakura"
irb(main):003:0> flower.name = "Ume"
Traceback (most recent call last):
       16: from /usr/local/bundle/gems/bundler-2.1.4/lib/bundler/cli.rb:30:in `dispatch'
       15: from /usr/local/bundle/gems/bundler-2.1.4/lib/bundler/vendor/thor/lib/thor.rb:399:in `dispatch'
       14: from /usr/local/bundle/gems/bundler-2.1.4/lib/bundler/vendor/thor/lib/thor/invocation.rb:127:in `invoke_command'
       13: from /usr/local/bundle/gems/bundler-2.1.4/lib/bundler/vendor/thor/lib/thor/command.rb:27:in `run'
       12: from /usr/local/bundle/gems/bundler-2.1.4/lib/bundler/cli.rb:476:in `exec'
       11: from /usr/local/bundle/gems/bundler-2.1.4/lib/bundler/cli/exec.rb:28:in `run'
       10: from /usr/local/bundle/gems/bundler-2.1.4/lib/bundler/cli/exec.rb:63:in `kernel_load'
        9: from /usr/local/bundle/gems/bundler-2.1.4/lib/bundler/cli/exec.rb:63:in `load'
        8: from /usr/local/bundle/bin/hanami:23:in `<top (required)>'
        7: from /usr/local/bundle/bin/hanami:23:in `load'
        6: from /usr/local/bundle/gems/hanami-1.3.3/bin/hanami:6:in `<top (required)>'
        5: from /usr/local/bundle/gems/hanami-cli-0.3.1/lib/hanami/cli.rb:57:in `call'
        4: from /usr/local/bundle/gems/hanami-1.3.3/lib/hanami/cli/commands/command.rb:85:in `call'
        3: from /usr/local/bundle/gems/hanami-1.3.3/lib/hanami/cli/commands/console.rb:51:in `call'
        2: from (irb):3
        1: from /usr/local/bundle/gems/hanami-model-1.3.2/lib/hanami/entity.rb:145:in `method_missing'
NoMethodError (undefined method `name=' for #<Flower:0x000055d1015a27d8 @attributes={:name=>"Sakura"}>)

Entity は基本はこれだけです。
あくまで入れ物として認識しておくのが良いでしょう。
ではどうやって値を代入するのでしょうか? 答えは Repository にあります。

※RDBMS 以外で使用する場合は、 Custom Schema の設定が必須になります。
Entities: Custom Schema | Hanami Guides

Repository

class FlowerRepository < Hanami::Repository
end

Hanami は Repository でデータベースのテーブルに対して CRUD 操作を行います。
テーブルのレコードを更新し Entity を返すなど、 RDBMS と Entity の仲介役を担っています。

実際にレコードを作成してみましょう。

bundle exec hanami console
irb(main):001:0> FlowerRepository.new.create(name: "Yaezakura")
[app] [INFO] [2020-06-11 08:29:27 +0000] (0.003513s) INSERT INTO `flowers` (`name`, `created_at`, `updated_at`) VALUES ('Yaezakura', '2020-06-11 08:29:27', '2020-06-11 08:29:27')
[app] [INFO] [2020-06-11 08:29:27 +0000] (0.001222s) SELECT `id`, `name`, `created_at`, `updated_at` FROM `flowers` WHERE (`id` IN (2)) ORDER BY `flowers`.`id`
=> #<Flower:0x000055cc10c53118 @attributes={:id=>1, :name=>"Yaezakura", :created_at=>2020-06-11 08:29:27 UTC, :updated_at=>2020-06-11 08:29:27 UTC}>

レコードを作成してから Entity が返却されていることがわかりますね。

もちろんレコードを取得することもできます。

irb(main):002:0> FlowerRepository.new.find(1)
[app] [INFO] [2020-06-11 08:36:53 +0000] (0.000934s) SELECT `id`, `name`, `created_at`, `updated_at` FROM `flowers` WHERE (`flowers`.`id` = 1) ORDER BY `flowers`.`id`
=> #<Flower:0x000055cc1113e150 @attributes={:id=>1, :name=>"Yaezakura", :created_at=>2020-06-11 08:29:27 UTC, :updated_at=>2020-06-11 08:29:27 UTC}>

次にメソッドを定義してみましょう。 Hanami では複雑なクエリはメソッド内で完結させることを推奨しています。

class FlowerRepository < Hanami::Repository
  def find_by_name(name)
    flowers.where(name: name).first
  end
end

実行してみます。

bundle exec hanami console
irb(main):001:0> flower_repository = FlowerRepository.new
=> #<FlowerRepository relations=[:flowers]>
irb(main):002:0> flower_repository.find_by_name("Yaezakura")
[app] [INFO] [2020-06-11 08:47:10 +0000] (0.002044s) SELECT `id`, `name`, `created_at`, `updated_at` FROM `flowers` WHERE (`name` = 'Yaezakura') ORDER BY `flowers`.`id`
=> #<Flower:0x000055f00786dda8 @attributes={:id=>1, :name=>"Yaezakura", :created_at=>2020-06-11 08:29:27 UTC, :updated_at=>2020-06-11 08:29:27 UTC}>

メソッドの定義で flowers という定義した覚えがないオブジェクトがでてきましたね。
わからないままは不安です。 何者なのか確認しましょう。

irb(main):003:0> flower_repository.flowers
=> #<ROM::Relation::Composite name=flowers dataset=#<Sequel::Mysql2::Dataset: "SELECT `id`, `name`, `created_at`, `updated_at` FROM `flowers` ORDER BY `flowers`.`id`">>

ROM::Relation::Composite のインスタンスでした。
Hanami は ROM (Ruby Object Mapper) でデータベースのテーブルにアクセスしているようですね。

最後に

InteractorValidations など、まだまだ言及したいことはありますが、メインとなる特徴のお話をさせていただきました。 興味が湧いた方はぜひ一度 Hanami を使ってみてください!