おもこん

おもこんは「思いつくままにコンピュターの話し」の省略形です

徒然Ruby(36)Ruby/GTK -- RubyとGUI

今回もRubyGUIについて書きます。 内容はGTK 3とGTK 4をRubyで使うライブラリRuby/GTKです。

GTKと「Rubyで動くGTK」とは

GTKオープンソースGUIライブラリです。 オリジナルはCで書かれており、Linuxで開発されました。 その後Windowsでも動くようになり、また言語もPythonPerlなどで使えるようになりました。

GTKの現在の安定版はGTK 4(バージョン4.8.2)です。 以前の版がGTK 3でその最新版は3.24.35です。 GTK 4がリリースされて2年以上経ちますので、これから使うとしたらGTK 4になると思います。 ですが、今回はGTK 3とGTK 4の両方を試してみました。

RubyGTKを動かすプロジジェクトはGitHub上で開発が進んでいます。

ruby-gnome/ruby-gnome

このGitHubの中でgtk3とgtk4の両方のgemが開発されています。 gemのバージョンは4.0.3となっています。 ドキュメントが少ないため、どの程度まで開発が進んでいるのかは良くわかりませんでした。

とにかく、試すしかないか、という感じです。

インストール

gemコマンドでインストールします。 gem名は「gtk3」と「gtk4」です。 どちらか一方をインストールすれば十分ですが、私は両方を試してみることにしましたので、2つともインストールします。

gtk4のインストール

$ gem install gtk4
Fetching red-colors-0.3.0.gem
... ... ...
Fetching gtk4-4.0.3.gem
... ... ...
... ... ...
13 gems installed

gtk3のインストール

$ gem install gtk3
Fetching gtk3-4.0.3.gem
... ... ...
... ... ...
2 gems installed

ドキュメント

Ruby/GTK3とRuby/GTK4をまとめてRuby/GTKと書くことにします。 Ruby/GTKGTKの機能をRubyで実現しようとするもので、GTKへの理解が前提となります。 そこで、GTKAPIリファランスのリンクを示しておきます。

GTKに馴染みのない方はこのリファランスの「Additional documentation」にある「Getting started with GTK」をまず読んでください。 ここがGTKのすべての出発点になります(それも難しいかもしれませんが)。 GTK 4については、GitHubレポジトリGtk4-tutorial、 またはそのHTML版もあります。

Ruby/GTK自体のドキュメントはありますが、完全ではありません。

例えばGTKのWindowオブジェクトにはそのデフォルトサイズを指定するgtk_window_set_default_sizeという関数があります。 これはRubyインスタンス・メソッドに相当するものです。 Ruby/GTK4にはこの記載はありませんが、set_default_sizeというRubyメソッドで使うことができます。

想像ですが、Ruby/GTKはこのメソッドをプログラムによる自動生成で作っているのではないでしょうか。 そうであれば、通常のメソッド定義の構文は使っていないことになります。 そして、ドキュメント自体もプログラム(おそらくRDoc)による自動生成ならば、メソッドをドキュメントに拾い出すことはできないでしょう。 この点については、ソースコードを確認できていないのであくまで推測です。

いずれにせよ、ドキュメントにないGTKのメソッドがRubyで使えるかどうかは、実際に試してみるしかありません。

Hello world

手始めはいつも「Hello world」です。 このプログラムではGTK 3とGTK 4を両方試せるようになっていて、引数に3を入れるとGTK 3にそれ以外ではGTK 4を使うようになっています。

$ ruby hello.rb 3 #=> GTK 3を使う
$ ruby hello.rb   #=> GTK 4を使う

先にコードを示して、その後説明します。

@gtk_version = (ARGV.size == 1 && ARGV[0] == "3") ? 3 : 4

require "gtk#{@gtk_version}"
print "Require GTK #{@gtk_version}\n"

application = Gtk::Application.new("com.github.toshiocp.hello", :default_flags)

application.signal_connect "activate" do |app|
  window = Gtk::ApplicationWindow.new(app)
  window.set_default_size 400,300
  window.title = "Hello"

  label = Gtk::Label.new("Hello World")
  if @gtk_version == 4
    window.child = label
    window.show
  else
    window.add(label)
    window.show_all
  end
end

application.run
  • はじめに引数を調べ、@gtk_versionに3または4を代入
  • require "gtk3"またはrequire "gtk4"を実行。これによりRuby/GTKが使えるようになる

GTK 3の使い方は徐々に変わってきて、gtk_applicationを使うのが良い方法になってきました。 GTK 4も同じ使う方をしますので、ここではそれにならってプログラムしています。

  • Gtk::Applicationオブジェクト(以下アプリケーションという)を生成する
  • アプリケーションにはIDをつける。 アプリケーションIDはURLを逆にするようなパターンで書き、世界で他に同一のものがないようにする。 例えばメールアドレスは世界にひとつしかないのでそれを使うことも可能である。 abc@example.comというメールアドレスを自分が持っているとする。 アプリケーションIDはcom.example.abc.helloとすれば良い。 (注:だれでも同じ文字列を使ってアプリケーションを作れるから、このIDが世界中でユニークだということを保証することはできない。 しかしGTKのシステムがアプリケーションIDがユニークであることを前提に作られているので、アプリ制作者がIDの付け方に十分注意を払う必要がある)。 上記のhello.rbではGitHubのIDを用いている
  • :default_flagsは「アプリケーションのデフォルト動作」ということ。 このフラグはGApplication ver2.74から使うようになった。 (GIO ドキュメント) 以前は:flags_noneを用いていた。 Cでプログラムする場合は「引数がファイル名である(:handles_open)」「引数が任意の文字列である(:handles_command_line)」などの定数もあるが、Rubyではあまり必要ないかもしれない(Rubyで引数処理できるから・・・正確ではないかも・・・)
  • アプリケーションが起動され、(:default_flagsで)アクティブになるとactivateシグナルが発せられる。 そのシグナルを受けて動作するプログラムを「ハンドラ」という。 シグナルとハンドラをつなぎ合わせるメソッドがsignal_connectメソッド。 そのメソッドのブロックにハンドラを記述する。
  • ハンドラでは、まずApplicationWindowオブジェクトを生成する。 このオブジェクトはアプリケーションと連携したウィンドウで、newメソッドには引数にアプリケーションを与える。 デフォルトサイズを400x300に、タイトル(タイトルバーに表示される文字列)を「Hello」に設定する
  • Labelオブジェクトを生成する。 ラベルオブジェクトはウィンドウの中で文字列を表示する。 その文字列をnewメソッドの引数に与える
  • GTK 4ではchildメソッドでラベルをウィンドウの子としてつなげる。 画面上ではウィンドウの中にラベルが配置されることになる。 このようにウィンドウ内のオブジェクト(ウィジェットという)の中に別のウィジェットが入り込むとき、外が親、中が子という親子関係が発生する。 この「ウィジェットの親子関係」は「クラスの親子関係」とは異なるものである。 GTK 3ではaddメソッドを使う(GTK 3ではこのような親子関係を作るときコンテナが必要なことがあり、GTK 4よりも複雑)。
  • GTK 4ではトップレベルのウィンドウをshowメソッドで表示するだけで良い。 というのは、トップレベルのウィンドウ以外のオブジェクトはデフォルトで「visible(表示)」にプロパティが設定されるからだ。 GTK 3ではデフォルトが「visible」ではないので、window.showを使うとウィンドウだけが表示され、ラベルが見えなくなってしまう。 window.show_allを使うとウィンドウとその子孫ウィジェットが表示できる。 なお、window.showに加えてlabel.showとすればウィンドウ・ラベルともに表示できるが、show_allを使うほうが簡単

Hello world

さて、「Hello world」を表示するだけにもかかわらず、説明がこんなに長くなってしまいました。 考えてみると、その多くの部分はGTKの説明です。 このことはRuby/GTKを使えるようになるには、GTKの理解が重要だということを示しています。 残念ながらGTK 4の日本語の解説資料はほとんどありません。 英語で最も頼りになるのはGTKのドキュメントです。 ですが、それも分かりやすいわけではありません。 GTKは学習コストが高いなあ、と思います。 ただ、それは非常に大きな首尾一貫したシステムだからで、GTKをマスターすればソフトウェアのスキルが格段に上がることは疑いないと思います。

電卓

簡単なGUIのプログラムである電卓を作ってみます。 lib_calc.rbはGlimmerと同じものを使います。 calc.rbだけをGTK対応に変更します。

まず、プログラムを示しましょう。

@gtk_version = (ARGV.size == 1 && ARGV[0] == "3") ? 3 : 4

require "gtk#{@gtk_version}"
print "Require GTK #{@gtk_version}\n"

require_relative "lib_calc.rb"

def get_answer a
  if a.instance_of?(Float) &&  a.to_i == a
    a.to_i.to_s
  else
    a.to_s
  end
end

application = Gtk::Application.new("com.github.toshiocp.calc", :default_flags)

application.signal_connect "activate" do |app|
  calc = Calc.new
  window = Gtk::ApplicationWindow.new(app)
  window.default_width = 400
  if @gtk_version == 4
    window.default_height = 120
  else
    window.default_height = 80
  end
  window.title = "Calc"

  vbox = Gtk::Box.new(:vertical, 5)
  window.child = vbox

  hbox = Gtk::Box.new(:horizontal, 5)
  label = Gtk::Label.new("")
  if @gtk_version == 4
    vbox.append(hbox)
    vbox.append(label)
  else
    vbox.pack_start(hbox)
    vbox.pack_start(label)
  end
    
  entry = Gtk::Entry.new
  entry_buffer = entry.buffer
  button_calc = Gtk::Button.new(label: "計算")
  button_clear = Gtk::Button.new(label: "クリア")
  button_quit = Gtk::Button.new(label: "終了")
  if @gtk_version == 4
    hbox.append(entry)
    hbox.append(button_calc)
    hbox.append(button_clear)
    hbox.append(button_quit)
  else
    hbox.pack_start(entry)
    hbox.pack_start(button_calc)
    hbox.pack_start(button_clear)
    hbox.pack_start(button_quit)
  end
    
  button_calc.signal_connect "clicked" do
    label.text = get_answer(calc.run(entry_buffer.text))
  end
  button_clear.signal_connect "clicked" do
    entry_buffer.text = ""
  end
  button_quit.signal_connect "clicked" do
    if @gtk_version == 4
      window.destroy
    else
      window.close
    end
  end

  if @gtk_version == 4
    window.show
  else
    window.show_all
  end
end

application.run

Calc

最初のあたりはGTK 3/4両方に対応するための処理、それからget_answerは以前と同じメソッドです。 GTKに関するプログラムはapplicationの定義以降です。 activateシグナルのハンドラがプログラムの大部分なのでそこを説明します。

  • ウィンドウのデフォルトサイズは幅と高さを別々に定義することができる。 GTK 3とGTK 4では高さの設定が異なるので、分けて定義する
  • ボックス・オブジェクトを使う。 ボックス・オブジェクトは縦または横に複数のオブジェクトを並べるためのコンテナ。 縦に並べるときは:vertical横に並べるときは:horizontalを生成時に引数に渡す。 引数の2番めはオブジェクト間のスペースをピクセル単位で指定する。 ここでは縦に並べるボックスを、1番めがボックス、2番めをラベルにして定義。 内側のボックスは横に並べるボックスで、その中にエントリーと3つのボタンを含める。 GTK 4ではappendメソッドを、GTK 3ではpack_startメソッドを使ってオブジェクトをボックスに追加していく
  • エントリとエントリ内のバッファは別オブジェクトになっていて、それぞれGtk::EntryクラスとGtk::EntryBufferクラスである。 このプログラムではGtk::EntryBufferオブジェクトをGtk::Entryクラスのbufferメソッドで取り出している。 エントリで編集された文字列はバッファの中に保存されている。
  • ボタンがクリックされたときにclickedシグナルが発生する。 このシグナルに対するハンドラを定義してそれらをsignal_connectメソッドで結びつける。
    • 計算ボタンのハンドラでは(1)entry_bufferの文字列を取り出し(2)calc.runで計算し(3)get_answerで文字列化し(4)ラベルのテキストに代入する
    • クリアボタンのハンドラでは、entry_bufferの文字列を空文字列にする
    • 終了ボタンのハンドラではトップレベルのウィンドウを閉じる。GTK 4ではdestroyGTK 3ではcloseメソッドを用いる

プログラムが長くなった原因はウィジェットを並べるコマンドを長々と書かなければならなかったからです。 これを解決するためにGTKにはウィジェットを別ファイル(UIファイル)にXMLで書くことができます。 次のセクションではこのことについて述べます。

ビルダーの使用

ウィジェット入れ子になった構造をXMLで表したファイルをUIファイルといい、拡張子をuiにします。 これを用いると本体のRubyプログラムを簡潔にすることができます。 電卓のUIファイルは次のとおりです。

<?xml version="1.0" encoding="UTF-8"?>
<interface>
  <object id="window" class="GtkWindow">
    <property name="title">Calc</property>
    <property name="default-width">400</property>
    <property name="default-height">200</property>

    <child>
      <object id="vbox" class="GtkBox">
        <property name="orientation">GTK_ORIENTATION_VERTICAL</property>
        <child>
          <object id="hbox" class="GtkBox">
            <property name="orientation">GTK_ORIENTATION_HORIZONTAL</property>
            <child>
              <object id="entry" class="GtkEntry">
              </object>
            </child>
            <child>
              <object id="button_calc" class="GtkButton">
                <property name="label">計算</property>
              </object>
            </child>
            <child>
              <object id="button_clear" class="GtkButton">
                <property name="label">クリア</property>
              </object>
            </child>
            <child>
              <object id="button_quit" class="GtkButton">
                <property name="label">終了</property>
              </object>
            </child>
          </object>
        </child>
        <child>
          <object id="label" class="GtkLabel">
          </object>
        </child>
      </object>
    </child>
  </object>
</interface>

最初にXMLの定義を書き、次の行からウィジェットの定義を書きます。 一番外のタグはinterfaceです。 その中にobjectタグでウィジェットを表し、childタグでその親子関係を表します。 propertyタグではオブジェクトのプロパティを定義します。

  • objectタグのアトリビュート
    • id ⇒ そのオブジェクトの名前。同じ名前を別のオブジェクトにつけてはいけない。この名前を使ってRubyプログラムでオブジェクトを取り出す
    • class ⇒ そのオブジェクトのGTK上のクラス「GtkWindow」や「GtkButton」のようにGtkというプレフィックスがつく
  • propertyタグはそのオブジェクトのプロパティを設定する
    • そのオブジェクトが持つプロパティを調べるにはGTKのドキュメントを参照する。例えばGtkButtonにはlabelプロパティがある。 GTK 4のドキュメントであれば「GtkButtonクラスのプロパティの説明」を参照する
    • nameアトリビュートにはそのプロパティ名を入れる。GTKのドキュメントでは「GtkButton:label」となっているが、コロンの後の「label」のみを指定する
    • プロパティには文字列、数字、真偽などがある。数字は100のように文字列でその数字を書けばよく、真偽はtrue/falseなどを書く(ye/noなどもOK)

XMLファイルを見ると、ウィジェットの親子関係が反映されていることが分かると思います。 注意するのは、ボックス内に並べるウィジェットひとつひとつに<child>タグが必要なことです。 まとめてひとつの<child>タグにすることはできません。

UIファイルを読み込んでオブジェクトをメソッドが組み立ててくれるので、Ruby内では記述の必要なオブジェクト(たとえばシグナルを設定するオブジェクト)だけをUIファイルから取り出すだけですみます。 ボックスのようなものは取り出す必要がありません。 そのおかげでプログラムはかなりすっきりします。

@gtk_version = (ARGV.size == 1 && ARGV[0] == "3") ? 3 : 4

require "gtk#{@gtk_version}"
print "Require GTK #{@gtk_version}\n"

require_relative "lib_calc.rb"

def get_answer a
  if a.instance_of?(Float) &&  a.to_i == a
    a.to_i.to_s
  else
    a.to_s
  end
end

application = Gtk::Application.new("com.github.toshiocp.calc", :default_flags)

application.signal_connect "activate" do |app|
  calc = Calc.new

  builder = Gtk::Builder.new(file: "calc.ui")
  window = builder["window"]
  entry = builder["entry"]
  entry_buffer = entry.buffer
  button_calc = builder["button_calc"]
  button_clear = builder["button_clear"]
  button_quit = builder["button_quit"]
  label = builder["label"]

  window.set_application(app)
  if @gtk_version == 3
    window.default_height = 80
  end

  button_calc.signal_connect "clicked" do
    label.text = get_answer(calc.run(entry_buffer.text))
  end
  button_clear.signal_connect "clicked" do
    entry_buffer.text = ""
  end
  button_quit.signal_connect "clicked" do
    if @gtk_version == 4
      window.destroy
    else
      window.close
    end
  end

  if @gtk_version == 4
    window.show
  else
    window.show_all
  end
end

application.run

UIファイルの取り込みにはGtk::Bulderクラスを使います。 Builderクラスのオブジェクトを生成するときにUIファイル内のウィジェットも生成されます。 そこからウィジェット(オブジェクト)を取得するには[]メソッドを使います。 ちょうどハッシュのキーを使って値を取り出すようにします。

UIファイルではGtkWindowオブジェクトを記述しています。 これは一般的なウィンドウ・オブジェクトです。 そのため、ウィンドウとアプリケーションを繋げなければなりません。 それをするのが、GtkWindowクラスのset_applicationメソッドです。

GtkWindowの代わりにGtkApplicationWindowを記述することもできますが、UIファイル内でGtkApplicationと結びつけることはできません。 そのため、set_applicationメソッドを用いることになります。

それ以外はUIファイルを使わないcalcのプログラムと同じです。

UIファイルはウィジェットが多く複雑なときにとても便利です。 本体のプログラムが冗長になるのを防いでくれます。

GTKでできること

Ruby/GTKがどこまでできるかはもっと使ってみないとわかりませんが、ここまでのところを見るとかなりGTKのつくりを反映していると思いました。 GObject IntrospectionというGTKのCライブラリなどを他の言語で使えるようにするバインディングソフトがあり、Ruby/GTKもそれを使っています。 ということは、ライブラリのRubyへの翻訳はプログラムを使って自動化されているということです。 おそらく、GTKでできることはRubyでもほとんどできるのかもしれません。

例えばGNOMEの標準エディタであるGEdit、あるいはお絵描きソフトのGimpGTKで書かれています。 ということは相当完成度の高いアプリを書くことができるということです。 それらのソフトはCで書かれていますが、Rubyでも同程度のものの作成が期待できます。

GTKのドキュメントを見ると数多くのウィジェットが用意されています。 GTKの全体を理解するのにはとても時間がかかりますが、得られるものも大きいはずです。

あとはRuby/GTKのドキュメントの整備が課題だと思います。 GTKもドキュメントが分かりにくいと考える人が多いですが、Ruby/GTKの場合は更に深刻で、ほとんどのメソッドの記述が無い状態です。

これはマンパワーの問題が大きいのだと思います。 大企業ではプロジェクトを推進するのに十分な人材を充てることができますが、オープンソースではそうでないケースが多いです。 そうなると優れたソフトウェアほど開発に注力しなければならず、ドキュメントがなかなか充実しません。 特に、易しい記述のガイドが少なくなりがちで、ソフトウェアを使う人の裾野が広がらないのです。

プロジェクトの開発においては、ソフトウェア自体の開発以外にドキュメントの整備やソフトウェア普及の活動などトータルな計画が必要だと思います。

徒然Ruby(35)Glimmer -- RubyとGUI

今回もRubyGUIのトピックです。 Glimmerを取り上げます。

Glimmerとは

GlimmerはRubyにおけるDSLDomain specific language, ドメイン固有言語)です。 すなわち、Rubyを用いてその中にプログラミング言語を作ったものです。

RubyDSLを比較的簡単に作ることのできる言語です。 例えばRakeなどは一種のDSLで、taskやfileなどの独自のコマンドを持っています。 これらのコマンドはRubyのメソッドやブロックを用いていることが多いです。

GlimmerはGUIに関する言語をRubyにおいて作ったものです。 これは言語ですから、GUIライブラリとは異なります。 GUIライブラリを土台にして、その上にライブラリを動かす言語を作っています。 GUIライブラリはJava/SWTJRuby使用)、Opal(Rails使用、ウェブブラウザ上のGUI)、libui、Tk、Gtk3(開発初期段階)などが可能です。 今回はlibui上のGlimmerを紹介します。

libuiは開発段階がmid-alpha(アルファ版の中段階)となっています。 まだ安定版は出ていませんので、このライブラリを採用するかどうかは迷うかもしれません。 もしも安定版を使いたい場合は「Glimmer dsl for SWT」を考えても良いかもしれません。 ただ、こちらはJRubyなので起動が遅い、メモリ使用量が多いなどのデメリットもあります。 長所、短所を見比べて選ぶことになると思います。

「Glimmer-dsl-libui」のGitHubページのREADME.mdが詳しいドキュメント(英語)になっています。 読むのが大変なくらい長いですが、詳しく丁寧なのでとても助かります。

ここでは「Glimmer-dsl-libui」をインストールし、使ってみた経験を書きたいと思います。 この記事はドキュメントではないので、詳細については上記のGitHubを参照してください。

インストール

Gemをインストールするだけです。 今回はGemfileを作ってBundlerでインストールしました。 Glimmerのプログラムを作る作業ディレクトリを作成し、そこにGemfileを作ります。

source "https://rubygems.org"

gem 'glimmer-dsl-libui', '~> 0.5.24'

そのディレクトリにカレントディレクトリを移動して、Bundlerでインストールします。

$ bundle install
Fetching gem metadata from https://rubygems.org/.........
Resolving dependencies...
Using bundler 2.3.23
Using matrix 0.4.2
Fetching os 1.1.4
Fetching libui 0.0.15
... ... ...
Installing libui 0.0.15
... ... ...
Fetching glimmer 2.7.3
Installing glimmer 2.7.3
Fetching glimmer-dsl-libui 0.5.24
Installing glimmer-dsl-libui 0.5.24
Bundle complete! 1 Gemfile dependency, 15 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
$

正しくインストールできていれば、次のコマンドでウィンドウが表示されます。

$ ruby -r glimmer-dsl-libui -e "require 'examples/meta_example'"

Glimmerのexample

Hello world

ものごとの始まりはいつも「Hello world」の表示です。 プログラムを見てみましょう。

require 'glimmer-dsl-libui'

include Glimmer

w = window('hello') {
  label('Hello world')
}

w.show

Glimmerを使うには、glimmer-dsl-libuiをrequireする必要があります。 また、Glimmerモジュールをインクルードしておきます。

  • winodwメソッドは引数にウィンドウのタイトル、幅、高さを指定する。 今回は幅と高さは省略している。 ブロックにウィンドウ内に配置するオブジェクト(Glimmerでは「コントロール」と呼ぶ)を配置する。
  • labelメソッドはウィンドウ上のラベル(文字列の表示をするオブジェクト)を定義する
  • windowメソッドの返すウィンドウ・オブジェクトをwに代入する。 そのオブジェクトに対してshowメソッドを呼び出すと、ウィンドウが表示される

表示された画面は次のようになりました。

Hello world

Glimmerの使い方

requireとincludeはhello.rbのサンプル同様に指定します。

ウィンドウとメニュー

メインウィンドウはwindowメソッドで定義します。

window(タイトル, 幅, 高さ, メニュー有無) {... ... ...}

メニューの有無はtrue/falseで指定します。 デフォルトはtrueです。 trueでもメニューを定義しなければメニューは現れません。

メニューを定義するにはmenuメソッド(メニューバーに現れる項目)、menu_itemメソッド(メニューバーをクリックしたときに現れるメニューの項目)を使います。

menu(項目名)

menu_item(項目名) {... ... ...}

menu_itemではメニューがクリックされたときの動作をブロックに書きます。 動作はリスナー(クリックを聞いているもの、クリックされると動作するもの)といいます。 リスナーはon_clickedメソッドで書きます。

window、menu、menu_itemなどのメソッドはコントロールを定義し、on_clickedはリスナーを定義しますが、両者でブロックの書き方を変えるのがGlimmerの流儀です。

  • コントロール ⇒ 波括弧で書く
  • リスナー ⇒ do〜endで書く

これは分かりやすくするための習慣で、異なる書き方をしてもエラーにはなりません。 ウィンドウとメニューの例menu.rbのコードを以下に示します。

require 'glimmer-dsl-libui'

include Glimmer

menu('File') {
  menu_item('Open') {
    on_clicked do
      file =  open_file
      @label.text = File.read(file)
    end
  }
  quit_menu_item
}

window('Menu', 800, 600) {
  @label = label('')
}.show

open_fileメソッドはファイル・オープン・ダイアログを表示し、選択されたファイルのパス名を返します。

quit_menu_itemは終了メニューです。 これだけ書いておけばメニューからクリックにより終了できます。

これに似たメニューアイテムにabout_menu_itemがあります。 このメソッドは「About」という名前のメニューアイテムを追加します。 on_clickedリスナーはブロックの中に書きます。

画面のスクリーンショットは、Openメニューからファイル「menu.rb」を読み込んだ後に、終了メニューをクリックする直前の様子です。

メニュー

ボックス

コントロールを縦または横に並べるコンテナとしてhorizontal_box(横に並べる)とvertical_box(縦に並べる)があります。 それぞれのメソッドのブロックの中にコントロールを記述します。

ホリゾンタル・ボックス内のコントロールに与えられる幅は等しくなります。 バーティカル・ボックス内のコントロールに与えられる高さは等しくなります。

ボックス内の幅、高さが等分にならないようにするには、コントロールのブロック内に

stretchy false

を入れておきます。 これにより次のコントロールが詰めて配置されます。

コントロール

windowあるいはボックスに配置できるコントロールには、以下のようなものがあります(全てではない)。

  • label
  • button
  • color_button ⇒ カラー選択のダイアログが開く
  • font_button ⇒ フォント選択のダイアログが開く
  • checkbox
  • radio_buttons
  • combobox ⇒ 複数の選択肢(リスト)からひとつを選ぶことができる
  • entry ⇒ 文字列の入力ができる
  • search_entry
  • non_wrapping_multiline_entry ⇒ 複数行のテキストを編集できる
  • msg_box ⇒ message_boxも同じ。ダイアログが開く
  • progress_bar ⇒ プログレス・バー(作業の進捗を示す棒)を表示

この中のボタンとエントリーを使うと、前回のShoesで作った電卓プログラムを作れます。

ボタンは次のような構文で使います。

button(ボタンに表示する文字列) { ... ... ...}
  • ブロックのところにはリスナーのon_clickedを入れることができる。 on_clickedメソッドはボタンがクリックされたときの動作をそのブロックに記述する
  • またbuttonメソッドは、ボタンのオブジェクトを生成して返す
  • textプロパティがある。 プロパティを設定する(.text=)、プロパティの設定を読む(.text)ことができる。 Rubyのattr_accessorで定義したメソッドと、ここでいうプロパティは同じ

エントリーは次のような構文で使います。

entry {... ... ...}
  • ブロックのところにはリスナーのon_changedを入れることができる。 on_changedメソッドはentryの内容が変更されたときの動作をそのブロックに記述する
  • またentryメソッドは、エントリーのオブジェクトを生成して返す
  • readonlyとtextプロパティがある。 プロパティを設定する(.readonly=または.text=)、プロパティの設定を読む(.readonlyまたは.text)ことができる。

なお、ブロックにはパラメータをひとつ付けることができ、そのパラメータは各メソッドの返すオブジェクトと同じものです。 例えば、

entry {|e| ... ... ...}

eはentryが返すエントリーオブジェクトと同じものです。

ファイルはcalc.rblib_calc.rbの2つです。 lib_calc.rbは前回の記事(Shoesの記事)で出てきたものと同じですので省略します。 以下にcalc.rbソースコードを示します。

require 'glimmer-dsl-libui'
require_relative 'lib_calc.rb'

include Glimmer

def get_answer a
  if a.instance_of?(Float) &&  a.to_i == a
    a.to_i.to_s
  else
    a.to_s
  end
end

window('calc', 400, 80) { |w|
  calc = Calc.new
  vertical_box {
    horizontal_box {
      @e = entry
      horizontal_box {
        button("計算") {
          on_clicked do
            @answer.text = "  "+get_answer(calc.run(@e.text))
          end
        }
        button("クリア") {
          on_clicked do
            @e.text = ""
          end
        }
        button("終了") {
          on_clicked do
            w.destroy
            LibUI.quit
            0
          end
        }
      }
    }    
    @answer = label("")
  }
}.show

windowメソッドの部分を説明しましょう。

  • Calcクラスのインスタンスを生成し、ローカル変数calcに代入する。
  • バーティカルボックスに2つの要素を入れる。 (1)ホリゾンルボックス(2)ラベル(初期値は空文字列、@answer変数にそのオブジェクトを代入しておく)
  • ホリゾンルボックスには2つの要素を入れる。 (1)エントリー、そのオブジェクトは@eに代入(2)ホリゾンルボックス
  • 内側のホリゾンルボックスにはボタンを3つ入れる。
  • 「計算」ボタンはクリックされたときに@answer(これはラベルオブジェクト)のテキストにエントリの文字列@e.textから計算した結果を代入する
  • 「クリア」ボタンはクリックされたときにエントリの文字列@e.textを空文字列にする
  • 「終了」ボタンはクリックされたときにウィンドウ・オブジェクトを閉じ(w.destroy)、libuiのメインループを終了させ(LibUI.quit)0を返す

実行すると次のような画面になります。 「(2+3)*4」を実行したところです。

Calc

プログラムの大筋はShoesと似ている感じがします。

表をウィンドウ上に作るためのメソッドが用意されています。 二重配列で表のデータを作り、その配列を渡すことでウィンドウ上に表が現れます。

まず、text_columnメソッドでタイトル行を定義し、表本体は二重配列をcell_rowsメソッドに引数として渡します。

以下に例を示します。 この例は政府統計から取ってきたもので、通信機器利用を世帯単位で調査したものです。

政府統計へのリンク

require 'glimmer-dsl-libui'

include Glimmer

tbl = [
  ["20-29","79.0","99.0","41.8","30.9","51.5","4.6","0.8"],
  ["30-39","74.9","99.0","51.5","44.1","47.3","3.5","0.9"],
  ["40-49","78.1","97.8","53.0","37.6","43.1","3.6","1.7"],
  ["50-59","80.2","96.4","46.0","32.4","27.8","3.4","2.4"],
  ["60-69","75.3","94.3","36.3","26.4","14.3","1.4","2.6"],
  ["70-79","68.7","86.7","26.5","20.4","9.1","1.9","5.5"],
  ["80-","67.5","84.5","25.6","22.0","8.8","2.1","5.7"] 
  ]
  
window('インターネット利用機器調査', 800, 600) {
  margined true
  vertical_box{
    label("政府統計 令和3年通信利用動向調査より") {
      stretchy false
    }
    label("※ 複数回答あり") {
      stretchy false
    }
    horizontal_box {
      table {
        text_column('世帯主年齢')
        text_column('PC')
        text_column('携帯スマホ')
        text_column('タブレット')
        text_column('テレビ')
        text_column('ゲーム機')
        text_column('その他')
        text_column('無回答')
      
        cell_rows tbl
      }
    }
  }
}.show

実行すると次のような画面が現れます。

政府統計の表

ここで用いたのは基本的なメソッドのみです。 他のメソッドなどについてはGlimmer-dsl-libuiのドキュメントを参考にして下さい。

蛇足ですが、この統計を見ると携帯・スマホが普及していることがわかります。 これから、ウェブサイトのスマホ対応は必須だということが分かりますね。 政府統計は公開されているので、個人や会社がコストをかけずに統計を手に入れることができ、便利です。 マーケティングのコストダウンにも繋がると思います。

図形の描画

図形描画のためのキャンバスにあたるコントロールがareaコントロールです。 areaの内部にpathコントロール、さらにpathの内部に正方形(square)長方形(rectangle)円(circle)円弧(arc)直線(line)ベジェ曲線(bezier)などを描くことができます。

また、静的な図形(一度描き、そのまま居座り続ける図形)だけでなく、動的な図形(書き直しのきく図形)も可能です。 動的な図形の場合は、その図形のために用いたメモリを描画後に解放し、メモリ効率を良くします。 ここでは、動的な図形は扱いませんが、難しくはないのでドキュメントを参考にしていただけば、すぐに理解できると思います。

図形描画の例を以下に示します。

require 'glimmer-dsl-libui'

include Glimmer
include Math

window('図形描画', 800, 600) {
  margined true
  
  vertical_box {
    area {
      path {
        arc(200, 300, 150, 90, 180, true)
        fill r: 200, g: 200, b: 255, a: 1.0
      }
      path {
        circle(200, 300, 150)
        rectangle(200,100,500,400)
        stroke r: 0, g: 0, b: 0
      }
      path {
        polygon(500,300-150, 500-150*cos(PI/6),300+150*sin(PI/6), 500+150*cos(PI/6), 300+150*sin(PI/6))
        fill r: 255, g: 200, b: 200, a: 1.0
      }
      path {
        polygon(500,300-150, 500-150*cos(PI/6),300+150*sin(PI/6), 500+150*cos(PI/6), 300+150*sin(PI/6))
        stroke r: 0, g: 0, b: 0
      }
    }
  }
}.show

areaはボックスの中に入れて使います。 pathはareaの中に入れ、更にその中にrectangleなどの図形を入れ、描き方(fillまたはstroke)を指定します。

上記のプログラムを実行すると次の画面が現れます。

図形描画

  • arc(中心, 半径, 描き始めの角度で右向きが0度で反時計回りに測る, 中心角, 円弧の角の進む方向で反時計回りがtrueで時計回りがfalse)
  • circle(中心, 半径)
  • rectangle(左上のx座標, 左上のy座標, 幅, 高さ) 座標はareaの左上隅が(0,0)で、上から下に向かう方向が正、左から右に向かう方向が正
  • polygon(各点のx座標, y座標, ... ...)
  • fill r:赤, g:緑, b:青, a:透明度 各色は0から255までの数(つまり8ビット)a(アルファ・チャンネル、不透明度)は0から1 (注:ドキュメントには明確には書いてない。が、おそらく合っていると思う)。図形の境界および内部を塗りつぶす
  • stroke r:赤, g:緑, b:青 各色は0から255まで。図形の境界線を描画

ここに書いていない図形のメソッドはドキュメントを参考にしてください。 また、アニメーションもできます。 例えばサンプルプログラムの「テトリス」を試してみてください。

データ結合

Glimmerは画面(View)とモデル(Model: データを保持しているもの)をプレゼンター(Presennter)が管理するMVPの方法をとることができます。 これはRailsなどのMVCと同じ考え方です。 具体的にはビューとモデルのデータに双方向の結合(バインディング)または片方向の結合を設定できます。 片方向の場合はモデルからビューへの方向のみ可能です。 それぞれ<=>または<=という演算子を用います。

まず、モデルを用意します。 モデルはRubyのオブジェクトで、ビューに対応させたいデータはattr_accessorで定義をしておきます。 例えば、インスタンス変数@dataをビューに対応させたいときは

class A
  attr_accessor :data
end

このようにします。 それにより、クラスAのインスタンスaとすると、a.data@dataを参照でき、a.data=@dataに代入することができます。

双方向のデータ結合をentryのテキストと行いたいときは

entry {
  text <=> [a, :data]
}

のように、右辺は配列で`[オブジェクト, インスタンス変数名のシンボル]とします。 左辺のtextはentryのプロパティ、すなわち入力された文字列です。

片方向のデータ結合は、例えば

label {
  text <= [a.data]
}

のようにします。

例としてentryとlabelがオブジェクトaのインスタンス変数@dataバインディングされたプログラムを紹介します。

require 'glimmer-dsl-libui'

include Glimmer

class A
  attr_accessor :data
  def initialize
    @data = ""
  end
end

window('バインディング', 800, 100) {
  margined true
  
  a = A.new
  vertical_box {
    entry {
      text <=> [a, :data]
    }
    label {
      text <= [a, :data]
    }
  }
}.show

同じデータがentryとlabelのテキストと結合されているので、エントリに文字列を打ち込むとラベルにもそれが反映されます。

データ結合

glimmer-dsl-libuiに付属の例

インストールしたglimmer-dsl-libuiのgemにはexampleフォルダに沢山の例が入っています。 インストール先は次のようにして調べられます。

$ gem Environment
RubyGems Environment:
  - RUBYGEMS VERSION: 3.3.7
  - RUBY VERSION: 3.1.2 (2022-04-12 patchlevel 20) [x86_64-linux]
  - INSTALLATION DIRECTORY: /(ユーザディレクトリ)/.rbenv/versions/3.1.2/lib/ruby/gems/3.1.0
... ... ...
... ... ...

INSTALLATION DIRECTORYと書いてあるのがgemのインストール先です。

この例を調べることにより、Glimmerの書き方が理解できます。 実行してみて面白いと思った例を以下にあげてみます。

  • area_based_custom_controls.rb
  • class_based_custom_controls.rb
  • control_gallery.rb
  • custom_draw_text.rb
  • dynamic_area.rb
  • editable_table.rb ⇒ ダブルクリックで編集できる
  • form_table.rb
  • histogram.rb
  • meta_example.rb
  • shape_coloring.rb
  • tetris.rb ⇒ 以前大流行したテトリス
  • timer.rb

実行してみて面白いだけでなく、プログラムを見ることによってGlimmerの書き方を習得することができます。

まとめ

Glimmer-dsl-libuiはShoes4と比べると起動が速く、精神的なストレスがありません。 まだ安定版がないので、使うかどうかを迷うかもしれませんが、開発版の段階でもある程度は実用になると思います。

Glimmerのベースになっているlibuiというライブラリ(こちらも開発段階)はGtk3ベースと書いてありました。 Gtk4になって2年くらいになるので、ぜひともGtk4に対応してもらいたいです。 Gtk3とGtk4ではいろいろな違いがあり、Gtk4はかなり進歩していますから。

なお、Glimmer-dsl-libuiは2022年10月15日の「福岡 mruby Kaigi」で特別賞を受賞しています。 今後の開発に期待したいと思います。

徒然Ruby(34)Shoes -- RubyとGUI

Rubyはグラフィックについて弱い印象があります。 しかし、グラフィックはデバイスに関することなので、言語そのものには直接の関係はないはずで、あるとすればライブラリです。 今後グラフィック関係のgemが開発されることに期待しましょう。

そのような状況の中で、現時点でグラフィックやグラフィック・ユーザ・インターフェース(GUI)のRubyをめぐる状況を調べていました。 今回はShoesというライブラリを取り上げます。

Shoesとは?

2022/11/15の時点で、Shoesには安定版のバージョン3.3と開発版のバージョン4がありますが、安定/開発だけではない違いがあります。 バージョン3はCを使っていましたが、バージョン4はJRubyのgemとなっています。つまり、Javaベースです。 また、その開発は現時点では活発とはいえません。 Shoes3の最後のコミットが2020年1月9日、Shoes4が2019年4月5日です。

今回はShoes4を試してみました。 PC環境はUBUNTU22.10です。 解説記事というよりは、使用記です。

インストール

Javaのインストール

Java(open-jdk)がインストールされているかを確認します。

$ java --version
openjdk 11.0.17 2022-10-18
OpenJDK Runtime Environment (build 11.0.17+8-post-Ubuntu-1ubuntu2)
OpenJDK 64-Bit Server VM (build 11.0.17+8-post-Ubuntu-1ubuntu2, mixed mode, sharing)

このようにコマンドラインからjava --versionでバージョン表示がされれば、すでにインストールはできています。 なお、これはUBUNTUでのデフォルトのバージョンです(OpenJDK-11JDK)。 もっと新しいバージョンもあります(最も高いバージョンは2022/11/15の時点でOpenJDK-20-JDK)ので、それを使いたい場合はaptでインストールします。

先程のコマンドでバージョンが表示されなければJavaをaptでインストールしてください。

JRubyのインストール

私の場合はrbenvを使ってRubyをインストールしているので、JRubyにもrbenvを使います。 インストール可能なバージョンを確認します。

$ rbenv install -l
2.6.10
2.7.6
3.0.4
3.1.2
jruby-9.3.4.0
mruby-3.0.0
rbx-5.0
truffleruby-22.1.0
truffleruby+graalvm-22.1.0

Only latest stable releases for each Ruby implementation are shown.
Use 'rbenv install --list-all / -L' to show all local versions.

JRuby-9.3.4.0があるので、それをインストールします。

$ rbenv install jruby-9.3.4.0

この段階で私のUBUNTUには、「Ruby 3.1.2」「JRuby 9.3.4.0」の2つのRubyがインストールされました。 この使い分けには「rbenv local」コマンドを使います。 あるディレクトリでJRubyを使いたい場合は、そのディレクトリに移動して

$ rbenv local jruby-9.3.4.0

とします。 このディレクトリでrubyを起動するとJRubyが呼ばれます。 なお、このときディレクトリ内にRubyバージョンを書いた隠しファイル「.ruby-version」が置かれ、rbenvはそれを手がかりにRubyを起動するのです。

Shoes4のインストール

JRubyを指定したディレクトリ(上記の rbenv local jruby-9.3.4.0 したディレクトリ)でshoesのgemをインストールします。

$ gem install shoes --pre
Fetching shoes-4.0.0.rc1.gem
Fetching shoes-core-4.0.0.rc1.gem
Fetching shoes-package-4.0.0.rc1.gem
Fetching furoshiki-0.6.1.gem
Fetching shoes-swt-4.0.0.rc1.gem
Successfully installed shoes-core-4.0.0.rc1
Successfully installed furoshiki-0.6.1
Successfully installed shoes-package-4.0.0.rc1
Successfully installed shoes-swt-4.0.0.rc1
Building native extensions. This could take a while...
Successfully installed shoes-4.0.0.rc1
Parsing documentation for shoes-core-4.0.0.rc1
Installing ri documentation for shoes-core-4.0.0.rc1
Parsing documentation for furoshiki-0.6.1
Installing ri documentation for furoshiki-0.6.1
Parsing documentation for shoes-package-4.0.0.rc1
Installing ri documentation for shoes-package-4.0.0.rc1
Parsing documentation for shoes-swt-4.0.0.rc1
Installing ri documentation for shoes-swt-4.0.0.rc1
Parsing documentation for shoes-4.0.0.rc1
Installing ri documentation for shoes-4.0.0.rc1
Done installing documentation for shoes-core, furoshiki, shoes-package, shoes-swt, shoes after 5 seconds
5 gems installed

間違って、Ruby 3.1.2 が起動するディレクトリでgem install shoesとすると、Shoes3のgemがインストールされるので注意してください。 また、このgemだけではShoes3は動かないようです。

Hello world

手始めはいつも「Hello world」の表示です。 次のプログラムをShoes4で動かします。

Shoes.app title: "Hello" do
  stack do
    para "Hello world"
  end
end
  • クラスShoesの特異メソッドappを呼び出すとグラフィック画面に表示するウィンドウを作成する
  • 引数のtitleはアプリケーション名(上部の「アクティビティ」や日付のあるバーに表示される)とウィンドウのタイトルになる
  • ブロックの中にウィンドウのパーツを書く(パーツをShoesではエレメントという)
  • stackは上下にエレメントを並べるコンテナ(このプログラムでは無くても良い)
  • paraは段落(paragraph)のことで、文字列を表示する

このプログラムをhello.rbのファイル名で保存し(Jrubyが動くディレクトリに保存し---以下Jrubyを前提とする)コマンドラインからshoesを起動すると次のような画面が現れる。

$ shoes hello.rb

hello

Shoes.app内のself

Shoesの使い方を説明します。

ウィンドウを作成するにはShoes.appの特異メソッドのブロックにエレメントを作成するメソッドを書きます。 このとき、ブロックのselfはShoes::APPクラスのオブジェクトに変更されます。 なお、Rubyの原則ではメソッドのブロックのselfはメソッド外側のselfと同じです。 この特異メソッドでは原則と異なる扱いになるように設定されているということです。

ブロック内の関数形式のメソッドはselfをレシーバとするので、Shoes.appのブロック内の関数形式のメソッドはShoes::APPクラスのインスタンスメソッドになります。 hello.rbで使ったparaというメソッドもShoes::APPインスタンスメソッドです。

hello.rbではstackメソッドも使いました。 stackメソッドのブロックではselfの変更はしません。 ほとんどのShoes4のメソッドはそのブロックでselfの変更をしませんが、マニュアルによるとwindowメソッドもselfの変更をするそうです。

エレメント

ウィンドウ内にエレメントを置くメソッドには次のようなものがあります。

  • para ⇒ 段落(テキストと考えて良い)
  • button ⇒ ボタン
  • edit_line ⇒ 一行入力の枠
  • oval ⇒ 円や楕円。その他にも図形を描画するメソッドlineやrectなどがある
  • list_box ⇒ リストボックス

他にも沢山エレメントがあるので、Shoesのマニュアルを参照してください。

これらのメソッドの返り値はそれぞれのオブジェクトを返します。 例えば、paraメソッドはShoes::Paraクラスのオブジェクトを返します。 このオブジェクトに対してメソッドを使うことができます。

@para = para "こんにちは" #=> 「こんにちは」をウィンドウ内に段落として表示
@para.text = "さようなら" #=> その段落の文字列を「さようなら」に変更する

どのようなメソッドがあるかを調べるにはAPIドキュメントを見れば良いのですが、なかなか探しにくいかもしれません。 Shoes::ParaクラスがAPIドキュメントでは書かれていませんが、これはShoes::TextBlockのサブクラスです。 このことは、ソースファイルを見るか、あるいはShoes::Para.ancestorsShoes::Para.superclassといったメソッドを実行して調べることで分かります。 Shoes::TextBlockのメソッドにはtexttext=があるので、これらはShoes::Paraでも使えることが分かります。

buttonメソッドには文字列の引数を与え、表示されたボタンのラベル(ボタンに書かれる文字列)を指定できます。 また、buttonメソッドが返すShoes::Buttonクラスのオブジェクトにはclickメソッドがあり、クリックされたときの動作を記述できます。

@button = button "ボタン"
@button.click do
  (ボタンがクリックされたときの動作を記述)
end

クリック時の動作はbuttonメソッドにブロックを付けてそこに記述することもできます。

スロット

スロットはエレメントを並べるためのコンテナで、フローとスタックがあります。

  • フロー(flow): エレメントを横に並べる
  • スタック(stack):エレメントを縦に並べる

エレメントはflowまたはstackメソッドのブロックに記述します。

電卓プログラム

簡単な電卓プログラムを書きました。 2つのファイルcalc.rblib_calc.rbから成ります。 calc.rbがShoesを使ってウィンドウを表示し、lib_calc.rbが文字列を構文解析して計算をします。

このプログラムを実行すると次のような画面が現れます。

$ shoes calc.rb

Shoes calc 初期状態

また、計算を実行すると次のようになります。

Shoes calc 計算式の入力と実行

  • 入力枠に数式を書く
  • 四則以外に累乗(**)、三角関数、指数関数、対数関数が可能
  • 前回計算した値を文字vで参照できる
  • 変数を使うことができる。a=10+2のようにイコールで代入する
  • 「計算」ボタンをクリックすると計算を実行する
  • 「クリア」ボタンをクリックすると入力枠内の文字列を消去する
  • 「終了」ボタンをクリックするとウィンドウを閉じる
  • タブを押すことによって入力枠やボタンをフォーカスが移動するので、ボタンにフォーカスをあててエンターキーを押すことでクリックの代わりにすることができる

以下にcalc.rbのプログラムを示します。

require_relative 'lib_calc.rb'

def get_answer a
  if a.instance_of?(Float) &&  a.to_i == a
    a.to_i.to_s
  else
    a.to_s
  end
end

Shoes.app title: "calc", width: 400, height: 80 do
  @calc = Calc.new
  flow do
    @edit_line = edit_line "", margin_left: 10
    @do_calc = button "計算", margin_left: 10
    @clear = button "クリア", margin_left: 3
    @close = button "終了", margin_left: 10
  end
  stack do
    @answer = para "", margin_left: 10
  end

  @do_calc.click do
    @answer.text = get_answer(@calc.run(@edit_line.text))
  end
  @clear.click {@edit_line.text = ""}
  @close.click {close}
end
  • Calcクラスはlib_calc.rbで定義されている
  • Calcのインスタンスメソッドrunは引数に文字列を与えるとその計算をして答え(Floatオブジェクト)を返す。 エラーが発生したときは答えの代わりにエラーメッセージを返す
  • get_answerメソッドは答えが整数のとき、Integerクラスに変えてから文字列にしている。 このことにより、例えば「12.0」でなく「12」という文字列にする
  • Shoes.appメソッドの中は2つのスロット(フローとスタック)を設定している
  • フローには入力枠、「計算」ボタン、「クリア」ボタン、「終了」ボタンを入れている。 それぞれ左マージンを10ピクセルまたは3ピクセル与え、エレメント間のスペースを作っている
  • スタックには答えを表示するための段落を設けている
  • @do_calcは「計算」ボタンのオブジェクト。 clickメソッドで、入力枠の文字列から@calc.runで計算し、get_answerで文字列化して@answerの段落エレメントの文字列に代入している
  • @clearは「クリア」ボタンのオブジェクトで、クリックされたときに@edit_lineオブジェクト(入力枠)の文字列を空文字列にする
  • @closeは「終了」ボタンのオブジェクトで、クリックされたときにcloseメソッドを呼び出す。 closeメソッドはウィンドウを閉じる。

Shoesのプログラムはこのように簡単です。 clickメソッドは、ボタンクリックのイベントに対するハンドラを定義しています。 この段階ではイベント処理をしているのではなく、イベント処理のハンドラのセットをしているだけです。

開発が活発でないのは残念ですが、電卓のような簡単なプログラムであれば開発には十分です。 ちょっと気になるのはJRubyの起動に時間がかかることです。

最後にlib_calc.rbのソースを示しますが、長いので説明は省略します。 なお、プログラムのコードはGitHubのBlog-about-Rubyレポジトリにあります。 ディレクトリは_example/shoes/です。

class Calc
  include Math

  def initialize
    @table = {}
    @value = 0.0
  end

  # calculate s
  # error => return error message
  # success => return the result as a string
  def run(s)
    a = parse(s)
    if a.instance_of? Float
      @value = a # keep the result of the calcukation.
      a = a.to_i if a.to_i == a
    end
    a.to_s
  end

  # error => return nil
  # success => return array like:
  # [[:id, "var"], [:=, nil], [:num, 12.34], [:+, nil], ... ... ...]
  def lex(s)
    result = []
    while true
      break if s == ""
      case s[0]
      when /[[:alpha:]]/
        m = /\A([[:alpha:]]+)(.*)\Z/m.match(s)
        name = m[1]; s = m[2]
        if name =~ /sin|cos|tan|asin|acos|atan|exp|log|sqrt|PI|E|v/
          result << [$&.to_sym, nil]
        else
          result << [:id, name]
        end
      when /[[:digit:]]/
        m = /\A([[:digit:]]+(\.[[:digit:]]*)?)(.*)\Z/m.match(s)
        result << [:num, m[1].to_f]
        s = m[3]
      when /[+\-*\/()=]/
        if s =~ /^\*\*/
          result << [s[0,2].to_sym,nil]
          s = s[2..-1]
        else
          result << [s[0].to_sym, nil]
          s = s[1..-1]
        end
      when /\s/
        s = s[1..-1] 
      else
        @error_message = "Unexpected character."
        result = nil
        s = "" # remove the rest of the string.
      end
    end
    result
  end

  # BNF
  # program: statement;
  # statement: ID '=' expression
  #   | expression
  #   ;
  # expression: expression '+' factor1
  #   | expression '-' factor1
  #   | factor0
  #   ;
  # factor0: factor1
  #   | '-' factor1
  #   ;
  # factor1: factor1 '*' power
  #   | factor1 '/' power
  #   | power
  #   ;
  # power: primary ** power
  #   | primary
  #   ;
  # primary: NUM | 'PI' | 'E' | '(' expression ')' | function '(' expression ')' | 'v';
  # function: 'sin' | 'cos' | 'tan' | 'asin' | 'acos' | 'atan' | 'exp' | 'log' ;

  # parser
  # error => return error message
  # success => return the result of the calculation (Float)
  def parse(s)
    tokens = lex(s)
    return @error_message unless tokens # lex error
    tokens.reverse!
    a = statement(tokens) # error
    return "syntax error." unless tokens == []
    a ? a : @error_message
  end

  private

  # error => return false and the error message is assigned to @error_message
  # success => return the result of the calculation (Float)
  def statement(tokens)
    token = tokens.pop.to_a
    case token[0]
    when :id
      a = token[1]
      b = tokens.pop.to_a
      if  b[0] == :'='
        return false unless c = expression(tokens)
        install(a, c)
        c
      else
        tokens.push(b) if b[0]
        tokens.push(token)
        expression(tokens)
      end
    when nil # token is now empty.
      syntax_error
      false
    else
      tokens.push(token)
      expression(tokens)
    end
  end

  def expression(tokens)
    return false unless (a = factor0(tokens))
    while true
      token = tokens.pop.to_a
      case token[0]
      when :'+'
        b = factor1(tokens)
        unless b
          break false
        end
        a = a+b
      when :'-'
        b = factor1(tokens)
        unless b
          break false
        end
        a = a-b
      when nil
        return a
      else
        tokens.push(token)
        break a
      end
    end
  end

  def factor0(tokens)
    token = tokens.pop.to_a
    case token[0]
    when :'-'
      b = factor1(tokens)
      b ? -b : false
    when nil
      syntax_error
      false
    else
      tokens.push(token)
      factor1(tokens)
    end
  end

  def factor1(tokens)
    return false unless (a = power(tokens))
    while true
      token = tokens.pop.to_a
      case token[0]
      when :'*'
        b = power(tokens)
        unless b
          break false
        end
        a = a*b
      when :'/'
        b = power(tokens)
        unless b
          break false
        end
        if b == 0
          @error_message = "Division by 0.\n"
          break false
        end
        a = a/b
      when nil
        break a
      else
        tokens.push(token)
        break a
      end
    end
  end

  def power(tokens)
    return false unless (a = primary(tokens))
    token = tokens.pop.to_a
    case token[0]
    when :'**'
      b = power(tokens)
      if b
        a**b
      else
        false
      end
    when nil
      a
    else
      tokens.push(token)
      a
    end
  end

  def primary(tokens)
    token = tokens.pop.to_a
    case token[0]
    when :id
      a = lookup(token[1])
      @error_message = "Variable #{token[1]} not defined.\n" unless a
      a ? a : false
    when :num
      token[1]
    when :PI
      PI
    when :E
      E
    when :'('
      b = expression(tokens)
      return false unless b
      unless tokens.pop.to_a[0] == :')'
        syntax_error
        return false
      end
      b
    when :sin, :cos, :tan, :asin, :acos, :atan, :exp, :log, :sqrt
      f = token[0]
      unless tokens.pop.to_a[0] == :'('
        syntax_error
        return false
      end
      b = expression(tokens)
      return false unless b
      unless tokens.pop.to_a[0] == :')'
        syntax_error
        return false
      end
      method(f).call(b)
    when :v
      @value
    when nil
      syntax_error
      false
    else
      syntax_error
      false
    end
  end

  def install(name, value)
    @table[name] = value
  end
  def lookup(name)
    @table[name]
  end

  def syntax_error
    @error_message = "syntax error."
  end
end

徒然Ruby(33)Rails7 システムテスト

Rails7におけるシステムテストについて書きます。

テンプレートの作成

コマンドラインからシステムテストのテンプレートを作成します。

$ bin/rails generate system_test words
    invoke test_unit
    create test/system/words_test.rb
$

/test/system/words_test.rbシステムテストを記述するファイルです。 ファイルを開くとWordsTestクラスの定義があります。 テストはその中に書いていきます。

require "application_system_test_case"

class WordsTest < ApplicationSystemTestCase
... ... ...
... ... ...
end

アサーション

クラスの親子関係は次のようになります。

WordsTest < ApplicationSystemTestCase < ActionDispatch::SystemTestCase < ActiveSupport::TestCase

このことから、アサーションについて次のことがわかります。

  • ActiveSupport::TestCaseがインクルードしたアサーションシステムテストでも使うことができる(以前の3つのテストと同様)
    • assert_changes
    • assert_difference
    • assert_no_changes
    • assert_no_difference
    • assert_not
    • assert_nothing_raised
  • 機能テストや結合テストではActionDispatch::IntegrationTestスーパークラスになっていたが、システムテストではスーパークラスではない。 ActionDispatch::IntegrationTestがインクルードしていたActionDispatch::Assertions::ResponseAssertionsActionDispatch::Assertions::RoutingAssertionsは、 システムテストではインクルードされないので、以下のアサーションは使えない。
    • assert_response
    • assert_redirect_to
    • assert_generates
    • assert_recognizes
    • assert_routing
  • ActionDispatch::SystemTestCaseCapybara::Minitest::Assertionsをインクルードしているので、Capybaraのアサーションを使うことができる。 このアサーションRailsAPIリファランスには説明がない。 CapybaraのAPIリファランスにその説明がある。 よく使いそうなアサーション(個人の独断による)を以下に示す。
    • assert_selector(セレクタ名, オプション): セレクタが送られてきたHTMLページ(以下ページと略記)にあるかどうかをチェック。 オプションでよく使うのは「text: 文字列または正規表現」「count: 整数」(そのセレクタの出現回数のチェック)「visible: (true|falseなど)」(可視かどうか)など。
    • assert_text(文字列, オプション): 文字列がページにあるかどうかをチェック。 オプションには「count: 整数」などが可。 デフォルトでは文字列は「可視文字列」、つまり改行やコントロールコードなどは含まない
    • assert_button(文字列): 引数がボタンのidまたは表示文字列であるようなボタンがあるかどうかのチェック
    • assert_field(文字列, オプション): 入力フィールドで引数の文字列にそのラベルまたは名前またはidが一致するものがあるかどうかをチェック。 オプションには「type: "textarea"」などの入力タイプの指定ができる
    • assert_link(文字列, オプション): リンクで、そのidまたはテキストが引数の文字列に一致するものがあるかどうかをチェック。 オプションにはリンク先「href: 文字列または正規表現」を指定できる
    • assert_table(文字列, オプション): 引数の文字列に一致するidまたはキャプションを持つ表があるかどうかをチェック。 オプションでは表の内容のチェックができるが、詳しくはAPIリファランスを参照してほしい
    • assert_title(文字列): 文字列に一致するタイトルを持っているかどうかをチェック

良く使われるメソッド

結合テストで使えたgetメソッドなどはシステムテストでは使えません。 代わりにCapybaraのメソッドを使います。 良く使われると思われるメソッドを以下に簡単に説明します。 その詳細や、他のメソッドについてはCapybaraのドキュメントを参照してください。

  • visit(URLまたはパス): URLへのGETメソッドでのアクセスをシミュレート
  • fill_in(フィールド名, with: 文字列): フォーム・フィールドに文字列をセットする
  • click_link(テキスト): リンクのクリックをシミュレート。テキストはそのaタグのコンテンツ(開始タグと終了タグで挟まれた部分)
  • click_button(テキスト): ボタンのクリックをシミュレート。テキストはそのボタンに表示される文字列
  • click_on(テキスト): リンクまたはボタンのクリックをシミュレート。click_linkclick_buttonの両方をまとめたメソッド
  • accept_confirm ブロック: ブロック(多くはリンクやボタンのクリック)を実行したときに、確認ダイアログが現れる場合、確認を受け入れる(OKをクリックなど)
  • dismiss_confirm ブロック: ブロック(多くはリンクやボタンのクリック)を実行したときに、確認ダイアログが現れる場合、確認を拒否する(NOやCancelをクリックなど)

最後の2つは、例えば削除に対する「are you sure?」などの確認ダイアログが出る場合に必要なメソッドです。 click_onだけではエラーになります。

ここまでで説明したメソッドは、ユーザがクリックやキーボード入力するのをシミュレートします。 テストでは、ユーザの行動をシミュレートしますので、現実の運用に近い形でのテストになります。

システムテストの例

ここでは、ルートにアクセスしてから、検索、追加、変更、削除を一通り行うことをシミュレートしてテストします。 プログラムリストにコメントしてありますが、次の順に推移することを想定しています。

  • ルートにアクセス
  • 正規表現「.」で検索
  • 単語を追加
  • その単語を変更
  • その単語を削除
  • index画面に戻る
require "application_system_test_case"

class WordsTest < ApplicationSystemTestCase
  test "flow from the root through every action" do
    # ルートにアクセス=>indexアクションへ
    visit root_url
    assert_selector "h1", text: "単語帳"

    # searchアクションへ
    fill_in "search", with: "."
    click_button "検索"

    assert_selector "h1", text: "単語検索結果"
    ["tall","高い","house",""].each do |s|
      assert_text s
    end

    # newアクションへ
    click_link "追加"

    fill_in "word[en]", with: "stop"
    fill_in "word[jp]", with: "止まる"
    fill_in "word[note]", with: "The machine stopped.\n機械が止まった。\n"
    wc = Word.count

    # createアクションへ
    click_button "作成"

    # showアクションへリダイレクト
    assert_text "単語を保存しました"
    assert_equal 1, Word.count-wc
    ["stop","止まる","The machine stopped.","機械が止まった。"].each do |s|
      assert_text s
    end

    # editアクションへ
    click_link "変更"

    fill_in "word[jp]", with: "止める"
    fill_in "word[note]", with: "I stopped speaking.\n私は話すのをやめた。\n"

    # updateアクションへ
    click_button "変更"

    # showアクションへリダイレクト
    ["stop","止める","I stopped speaking.","私は話すのをやめた。"].each do |s|
      assert_text s
    end
    wc = Word.count

    # deleteアクションへ
    accept_confirm do
      click_link "削除"
    end

    # indexアクションに戻る
    assert_text "単語を削除しました"
    assert_equal -1, Word.count-wc
    assert_selector "h1", text: "単語帳"
  end
end

プログラムを読んでもらえば、ユーザがクリックしたりキーボード入力するのをそのままシミュレートしていることが分かると思います。 いくつかポイントになる点を書きます。

assert_textでは可視文字列のみをチェックする(デフォルト動作)ので、改行をチェックすることはできません。 2行にわたる文字列は、2回に分けて1行ずつチェックします。

createアクションでデータベースの単語数が増えるのをassert_equalでチェックしました。 assert_differenceでも可能ですが、そのブロック内にデータベースに書き込むタイミングを含まなければならないことに注意が必要です。 次のプログラムではフェイルする可能性が高いです。

assert_difference("Word.count") do
  click_button "作成"
end
assert_text "単語を保存しました"

これは、ボタンクリック直後ではまだデータベースの書き込みが終わっていないため、Word.countがブロックの前後で変化していないためです。 これは次のように書くことで解決できます。

assert_difference("Word.count") do
  click_button "作成"
  assert_text "単語を保存しました"
end

assert_textが画面が変わるまで待ってからチェックをしてくれるので、ブロック実行中にデータベース書き込みは行われています。 Capybaraでは、このようにアサーションが画面遷移を待つようになっているので、それに注意が必要です。 assert_differenceを使うことは可能ですが、どちらかといえばassert_equalを使うほうが分かりやすいように思います。

削除メニューをクリックすると確認ダイアログが現れます。 Capybaraではこのようにダイアログが現れる場合はaccept_confermのブロックにリンクやボタンのクリックを書かなければなりません。 そうでないとエラーになります。

もし、ダイアログでCancelやNoボタンを押すことをシミュレートしたい場合はdismiss_confirmを使います。

テストの実行

テストの実行にはtest:systemを引数にbin/railsを実行します。

$ bin/rails test:system
yarn install v1.22.19
[1/4] Resolving packages...
success Already up-to-date.
Done in 0.11s.
yarn run v1.22.19
$ esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets

  app/assets/builds/application.js      394.1kb
  app/assets/builds/application.js.map  721.7kb

Done in 0.20s.
yarn install v1.22.19
[1/4] Resolving packages...
success Already up-to-date.
Done in 0.10s.
yarn run v1.22.19
$ sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules
Done in 1.86s.
Running 1 tests in a single process (parallelization threshold is 50)
Run options: --seed 20243

# Running:

DEBUGGER: Attaching after process 8081 fork to child process 8111
Capybara starting Puma...
* Version 5.6.5 , codename: Birdie's Version
* Min threads: 0, max threads: 4
* Listening on http://127.0.0.1:38767
.

Finished in 3.190794s, 0.3134 runs/s, 5.9546 assertions/s.
1 runs, 19 assertions, 0 failures, 0 errors, 0 skips

多くのメッセージが出ていますが、その多くはテストの準備作業です。 テスト自体は下から14行目の「Running 1 tests in a single process (parallelization threshold is 50)」以下になります。 下から4行目のドットがテストの成功を示しています。 さらに、その詳細は、最終行のテスト1、アサーション19、フェイルとエラーは0だったことが分かります。

ヘッドレスブラウザ

システムテストではブラウザ画面が表示されます。 これは画面を見ることができて良いともいえますが、逆に煩わしいともいえます。 画面表示の部分を無くしたヘッドレスブラウザを使うことでこの問題を解決できます。

設定についてはRails Guideを参照してください。

まとめ

この記事の例から分かると思いますが、システムテストでは実際の運用に近い形でのテストができます。 本当にシミュレーションという感じです。 演劇でいえば舞台稽古、コンサートでいえばリハーサルのようなものです。

したがって、Railsウェブアプリケーションは最終的にはシステムテストを書かなければならないと思います。 そうであれば、結合テストは省略される可能性もあるでしょう。 これはそれぞれのプロジェクトでの考え方になると思います。

今回でRailsの記事が7本になりました。 ここでRailsは終わりになります。 次回以降は別のトピックを取り上げたいと思います。

徒然Ruby(32)Rails7 テスト

前回作ったWordbook(リソースフル)のテストを書いてみます。 RailsのテストはminitestをRails用に拡張したものです。

テストの種類

Railsでは、4つのテストが用意されています。

今回の記事ではシステムテストを除いた3つのテストについて書きます。 システムテストは次回の記事で扱います。

日本語訳は書籍によって異なるかもしれません。 例えば「結合テスト」を「統合テスト」と訳すこともあります。 この記事の訳は「Railsガイド]に基づいています。

また、これらの用語は「一般論としてのテスト」にも使われますが、その使い方には幅があります。 Railsの場合は

を指します。

モデルのテスト

モデルのテストでは

  • データベースから読み出しが正しくできているか
  • データベースに保存できているか
  • バリデーションが正しく機能しているか

などをテストします。

これらは、コントローラやビューから切り離して単体でテストすることができます。 その意味では「単体テスト」です。 また、データベースまわりのテストが主になるので、そのためのいくつかの仕組みが用意されています。

フィクスチャ

フィクスチャはいわゆるサンプルデータです。 Yaml形式でデータを記述しておくと、Railsがテスト前にデータベースに保存してくれます。 /test/fixtures/words.ymlに2つほどサンプルデータを書いてみました。

# Word model fixtures

tall:
  en: tall
  jp: 高い
  note: |
    He is tall.
    彼は背が高い。

house:
  en: house
  jp:note: |
    I stayed in my house.
    私は家にいました。

Yamlはコロン(:)でキーと値のペアを表します。 Rubyのデータ構造でいえばハッシュにあたります。

{tall: {en: "tall", jp: "高い", note: "He is tall.\n彼は背が高い。\n"}}
{house: {en: "house", jp: "", note: "I stayed in my house.\n私は家にいました。\n"}

Yamlの縦棒(|)は次の行からのデータが改行も含めてのデータであることを示します。 :tall:houseはフィクスチャを指定するときの名前になります。 データベースに登録されるデータはenからnoteまでの部分です。

フィクスチャを変数に代入するには、そのモデル名の複数形を小文字で書いたメソッドを用います。

word = words(:tall)

これで変数wordにフィクスチャtallのモデルが代入されます。

モデルのテスト

モデルのテストは/test/models/word_test.rbに書きます。

require "test_helper"

class WordTest < ActiveSupport::TestCase
  test "fixture tall should be read" do
    word = Word.find_by(en: "tall")
    assert_equal "高い", word.jp
    assert_equal "He is tall.\n彼は背が高い。\n", word.note
  end

  test "A valid data should be saved uniquely" do
    w1 = Word.new(en: "room", jp: "部屋", note: "")
    w2 = Word.new(en: "room", jp: "空き", note: "")
    assert w1.save
    refute w2.save
  end

  test "three invalid data should not be saved" do
    w1 = Word.new(en: "", jp: "空き", note: "")
    w2 = Word.new(en: "@@@", jp: "アットマーク", note: "")
    w3 = Word.new(en: "page", jp: "", note: "Turn to page four.\n4ページを開きなさい。")
    [w1, w2, w3].each do |word|
      assert word.invalid?
      refute word.save
    end
  end
end

テストはWordTestクラスの定義の中に書きます。 クラスWordTestActiveSupport::TestCaseのサブクラスです。 さらに、ActiveSupport::TestCaseMinitest::Testのサブクラスです。 したがって、WordTestクラス内では、それらの祖先クラスのすべてのメソッドを使うことができます。

アサーションの詳細は上記のリンクやRails Guideを参照して下さい。 また、minitestについては本ブログまたは「徒然Ruby」(GitHub Pages)にも記事があります(GitHub Pagesの方が新しい版)。

カテゴリー(categories)の「minitest」をご覧ください。

クラス内の各テストを記述するのにtestメソッドを使っています。 これは「"test_"+引数」を名前にしたメソッドを定義します。 つまり、次の2つは同じことになります。

test "show hello" do
  print "Hello world.\n"
end

def test_show_hello
  print "Hello world.\n"
end

testメソッドの引数にはそのテストの内容や目的を書きます。 それによって、defステートメントよりも「テストらしくみえる」「テストの内容を示すことができる」ということが可能です。

2つめのテストを見てみましょう。 ここでは、assertrefuteの2つのメソッドが使われています。 両者ともminitestのメソッドです。

  • assert ⇒ 引数が真ならばテストをパス、偽ならばフェイル
  • refute ⇒ 引数が偽ならばテストをパス、真ならばフェイル

テストがフェイルした場合は、そこでテストは打ち切られ、フェイルのメッセージが画面に出力されます。

test "A valid data should be saved uniquely" do
  w1 = Word.new(en: "room", jp: "部屋", note: "")
  w2 = Word.new(en: "room", jp: "空き", note: "")
  assert w1.save
  refute w2.save
end

w1とw2は英単語が同じで、オブジェクトとしては異なるWordオブジェクトです。 w1をデータベースにsaveメソッドで保存するのは成功しますので、w1.saveは真を返します。 assert w1.saveは成功するわけです。

次にw2を保存しようとすると、バリデーションに引っかかります。 英単語はユニーク、すなわちデータベース中にひとつしか許されませんので、w2は保存できません。 このときsaveメソッドは偽を返します。 refuteは引数が偽のときにパスするので、このテストも通過するはずです。

次に、1番めのテストを見てみましょう。 ここでは、あらかじめデータベースに保存されているフィクスチャの呼び出しをチェックしています。 assert_equalは2つの引数をとり、それらが==であるときにテストをパスします。 テストでは英単語がtallであるデータを読み出して、日本語訳(jp)と備考(note)がフィクスチャと一致するかをテストしています。

3つめのテストはバリデーションのテストです。

  • 英単語(en)は空ではいけない
  • 英単語(en)はアルファベットのみの文字列でなければいけない
  • 日本語訳(jp)は空ではいけない

これらに違反するWordモデルを作成して、invalid?メソッドでインバリッドであるかをテストし、さらにsaveが失敗することをテストします。

このように、モデルのテストは主に

  • 読み書きが正しくできるか
  • バリデーションが正常に機能するか

を見ます。

より複雑なデータベースでは、テーブル間のリレーションが機能しているかどうかもテストします。 ここではそのような複雑なテストについては省略します。

テストの実行

テストの実行はコマンドラインから./bin/rails test (テストプログラム)の形で実行します。

$ ./bin/rails test test/models/word_test.rb
Running 3 tests in a single process (parallelization threshold is 50)
Run options: --seed 543

# Running:

...

Finished in 0.145926s, 20.5584 runs/s, 68.5281 assertions/s.
3 runs, 10 assertions, 0 failures, 0 errors, 0 skips

途中のドットはテストが成功したことを表します。 テストが3つあったのでドットも3つ表示されます。 もしテストがフェイルすればFが、テストプログラムに誤りがあればE(エラー)がドットの代わりに表示されます。

最後の行にテスト結果が出ています。

  • 3 runs ⇒ 3つのテストを実行
  • 10 assertions ⇒ 10個のアサーション(個別のテスト)を実行
  • 0 failures ⇒ 0個のフェイル
  • 0 errors ⇒ 0個のエラー

機能テスト

機能テスト(functional test)はコントローラの動作をテストします。 また、ルーティングとビューはコントローラと一体ですので、それらのテストもここで行うことができます。 テストは/test/controllers/words_contraller_test.rbに記述します。

ルーティングとコントローラのテストとしては

  • HTTPリクエストに対して正しいコントローラとアクションが呼び出されているか
  • コントローラが正しくHTTPレスポンスを返す、またはリダイレクトしているか
  • その他

が考えられます。

また、ビューのテストとしては、正しいHTMLタグが必要数だけ出力されているか、が考えられます。

テスト全体を以下に示します。

require "test_helper"

class WordsControllerTest < ActionDispatch::IntegrationTest
  test "should get index" do
    get words_url
    assert_response :success
  end

  test "should get show" do
    get word_url(words(:tall))
    assert_response :success
    assert_select "nav a", {text: /変更|削除/, count: 2}
    assert_select "nav a.disabled", {text: /変更|削除/, count: 0}

    id = words(:tall).id+words(:house).id
    get word_url(id)
    assert_response :success
    assert_select "nav a.disabled", {text: /変更|削除/, count: 2}
    assert_equal "データベースにはid=#{id}のデータは登録されていません", flash.now[:alert]
  end

  test "should get new" do
    get new_word_url
    assert_response :success
  end

  test "should create word" do
    assert_difference("Word.count") do
      post words_url, params: { word: { en: "stop", jp: "止まる", note: "" } }
    end
    assert_redirected_to word_path(Word.last)
  end

  test "should get edit" do
    get edit_word_url(words(:tall))
    assert_response :success
  end

  test "should update word" do
    word = words(:tall)
    patch word_url(word), params: {word: {en: "tall", jp: "背が高い", note: ""}}
    assert_redirected_to word_path(word)
  end

  test "should delete word" do
    word = words(:tall)
    assert_difference("Word.count", -1) do
      delete word_url(words(:tall))
    end
    assert_redirected_to words_path
  end

  test "should get search" do
    get words_search_url, params: {search: "."}
    assert_response :success
  end
end

テストを記述するクラスはWordsControllerTestで、ActionDispatch::IntegrationTestのサブクラスです。 なお、このActionDispatch::IntegrationTestは機能テストのクラスのスーパークラスであるだけでなく、結合テストのクラスのスーパークラスにもなっています。 したがって、両テストで同じメソッドを使うことができます。 クラスの親子関係は次のようになります。

WordsControllerTest < ActionDispatch::IntegrationTest < ActiveSupport::TestCase < Minitest::Test

機能テストでは、アサーション単体テストよりも増えています。

アサーションの詳細は上記のリンクまたはRails Guideを参照してください。

HTTPメソッドをシミュレートするメソッド

機能テストではHTTPメソッドをシミュレートするメソッドを使うことができます。 それらの名前はHTTPメソッドと同じです。 例えばgetはHTTPのGETメソッドをシミュレートします。

get パス名(URL), オプション

の形で用います。 オプションではパラメータなどを渡すことができます。 詳細はAPIリファランスを参照してください。

indexアクションのテストを見てみましょう

test "should get index" do
  get words_url
  assert_response :success
end

getメソッドでwords_urlすなわちhttp://www.example.com/wordsにアクセスします。 なお、words_urlRailsのヘルパーメソッドで、words_pathのような働きをします。 words_path絶対パス/wordsを返すのに対し、words_urlは絶対URLを返します。 他のルーティングprefixに対しても同様のXXX_urlメソッド(XXXはPrefix)を使うことができます。 なお、テストでは仮のホストとしてhttp://www.example.comが用いられています。

このアドレスにGETメソッドでアクセスするとindexアクションにルーティングされます。

assert_responseはHTTPレスポンスのステータスコードをチェックします。 :successは200番台のコードにマッチします。

このテストではindexアクションにアクセスすると200番台のステータスコードでデータが送られてくることを確認しました。 もちろん、もっと詳しくチェックすることも可能です。 例えば、HTMLで送られてくるタグや文字列をチェックすることもできます。 しかし、あまり細かくテストしても意味はありませんから、この程度でも十分だと思います。

getの他に、post、patch、deleteなどのメソッドがあり、それぞれPOST、PATCH、DELETEのHTTPメソッドのリクエストをシミュレートします。 postの例としてcreateアクションのテストを見てみましょう。

test "should create word" do
  assert_difference("Word.count") do
    post words_url, params: { word: { en: "stop", jp: "止まる", note: "" } }
  end
  assert_redirected_to word_path(Word.last)
end

createアクションへは、あらかじめnewアクションで送られたフォームにデータを書き入れたものをPOSTメソッドで送ります。 そのデータはparamsキーを持つハッシュの形で送ります。

word: { en: "stop", jp: "止まる", note: "" }

これは、word[en]が「stop」、word[jp]が「止まる」、word[note]が空文字列のパラメータを表します。 words_urlhttp://www.example.com/wordsになり、ここにPOSTでアクセスするとcreateアクションにルーティングされます。

ここでは、assert_differenceメソッドでテストしています。 このメソッドは「ブロック実行後の引数の値」から「ブロック実行前の引数の値」を引いた数をチェックします。 デフォルトでは差が1です。 createアクションが実行されると、データベースのWordレコードが1つ増えますから、レコード数「Word.count」の差が1になります。 無事データが保存されればこのテストは通過します。

また、createアクションが成功するとリダイレクトが起こります。 最後に追加したWordモデルのshowアクションにリダイレクトされるので、そのアドレスword_path(Word.last)へのリダイレクトかどうかをチェックしています。 なお、アドレスにword_url(Word.last)を用いても構いません。

削除の場合も同様にassert_differenceを使い、差が-1になるかどうかをチェックします。

ビューのテスト

ビューをテストするにはassert_selectメソッドを使います。 このメソッドはRails guideに書かれていますが、APIリファランスには記述がありません。 このメソッドはRails::Dom::TestingというGemのメソッドです。

これらの情報を参考にしてください。

assert_select CSSセレクタ [, テスト]

という形で使います。

第2引数の「テスト」を省略すると、そのセレクタがHTMLデータに存在すればテストは通過します。 「テスト」がある場合は、そのテスト結果によりパス、フェイルが決まります。 「テスト」は、ハッシュで与えるのが基本的です。

その他にもありますが、詳細はドキュメントを参照してください。 ここではshowアクションのテストを見てみます。

test "should get show" do
  get word_url(words(:tall))
  assert_response :success
  assert_select "nav a", {text: /変更|削除/, count: 2}
  assert_select "nav a.disabled", {text: /変更|削除/, count: 0}

  id = words(:tall).id+words(:house).id
  get word_url(id)
  assert_response :success
  assert_select "nav a.disabled", {text: /変更|削除/, count: 2}
  assert_equal "データベースにはid=#{id}のデータは登録されていません", flash.now[:alert]
end

フィクスチャのtallをパラメータにしてGETアクセスし、showアクションにルーティングさせます。 レスポンスは正常になるはずですので、assert_responseでチェックします。 その場合「変更」と「削除」メニューが「disabledでなく」表示されるので、2つのassert_selectでテストします。

  • まず、ナビゲーションバーに「変更」または「削除」へのリンク(aタグ)が2個あることをテスト
  • 次に、その「変更」または「削除」へのリンクでdisabledのものは0個であることをテスト

次に「データベースに存在しないid」でshowアクションにアクセスした場合をチェックします。 初期状態ではtallとhouseのフィクスチャのみがデータベースにあるので、それらのidの和であれば、データベースにそのidは存在しないはずです。 そのidをパラメータにしてアクセスすると、レスポンスはsuccessですが、データは表示されません。 また、ナビゲーションバーの「変更」と「削除」リンクはdisabledになっています。 assert_selectでそれをチェックします。 また、フラッシュが表示されるはずなので、assert_equalでフラッシュの内容をチェックします。

今回はshowメソッドのみで「変更」「削除」のenabled/disabledのチェックをしましたが、他のアクションでは常にdisabledのはずなので、それをテストに加えるのも良いと思います。

ビューのテストはあまりしつこくやっても意味は無いのですが、このように画面によってdisabledになるようなリンクをテストするのは有意義です。

結合テスト

結合テストでは複数のアクションがアクセスの流れの中で正しく呼び出されるかをテストします。 機能テストとの違いは、ひとつのアクションのテストか、複数かの違いになります。

まず、雛形を作ります。

$ ./bin/rails generate integration_test word_flows
      invoke  test_unit
      create    test/integration/word_flows_test.rb

/test/integration/word_flows_test.rbにテストを記述します。 ここでは3つの流れについてテストをします。

  • 単語を作成: new ⇒ create ⇒ show
  • 単語を変更: edit ⇒ update ⇒ show
  • 単語を削除: delete ⇒ index

結合テストではリダイレクトのレスポンスが送られたときに、そのリダイレクト先にアクセスするfollow_redirect!メソッドを使います。

require "test_helper"

class WordFlowsTest < ActionDispatch::IntegrationTest
  test "flow from new to show through create" do
    # access to the new action
    get new_word_url
    assert_response :success
    # access to the create action
    post words_url, params: { word: { en: "stop", jp: "止まる", note: "" } }
    assert_response :redirect
    follow_redirect!
    # redirect to the show action
    assert_response :success
    assert_equal "単語を保存しました", flash[:success]
    assert_select "ul.list-group" do
      assert_select "li", "stop"
      assert_select "li", "止まる"
      assert_select "li", ""
    end
  end

  test "flow from edit to show through update" do
    # access to the edit action
    word = words(:tall)
    get edit_word_url(word)
    assert_response :success
    # access to the update action
    patch word_url(word), params: { word: { en: "tall", jp: "背の高い", note: "How tall is she?\n彼女はどのくらい背がありますか?" } }
    assert_response :redirect
    follow_redirect!
    # redirect to the show action
    assert_response :success
    assert_select "ul.list-group" do
      assert_select "li", "tall"
      assert_select "li", "背の高い"
      assert_select "li", "How tall is she?\n彼女はどのくらい背がありますか?"
    end
  end

  test "flow from delete to index" do
    # access to the delete action
    word = words(:house)
    delete word_url(word)
    assert_response :redirect
    follow_redirect!
    # redirect to the index action
    assert_response :success
    assert_select "h1", "単語帳"
  end
end

最初のテストだけ説明すれば十分だと思います。 2番めと3番めについてはソースコードを追ってみてください。

最初のテスト部分を再掲します。

test "flow from new to show through create" do
  # access to the new action
  get new_word_url
  assert_response :success
  # access to the create action
  post words_url, params: { word: { en: "stop", jp: "止まる", note: "" } }
  assert_response :redirect
  follow_redirect!
  # redirect to the show action
  assert_response :success
  assert_equal "単語を保存しました", flash[:success]
  assert_select "ul.list-group" do
    assert_select "li", "stop"
    assert_select "li", "止まる"
    assert_select "li", ""
  end
end
  • getメソッドを使い、newアクションにアクセスする
  • 応答はsuccess。入力フォームが表示されるがそのチェックは省略
  • 単語stopをパラメータにセットしてpostメソッドでcreateアクションにアクセス
  • 応答はredirect
  • follow_redirect!メソッドでリダイレクト先(showアクションになるはず)にアクセスする
  • 応答はsuccess
  • 「単語を保存しました」のフラッシュが表示されるはずなのでassert_equalでチェック
  • 保存された単語が順序なしリストで表示されるはずなので、それをチェック。 まず、assert_selectul.list-groupセレクタをキャッチする。 そのブロックでは、キャッチされたセレクタ内のみを対象としてassert_selectが働く。 リストの項目として「stop」「止まる」「」(空文字列)があるはずなのでチェック。 なお、assert_selectの第2引数が文字列のときは、そのセレクタ(HTMLタグ)で囲まれたコンテンツの文字列との一致をチェックする

このようにして、複数のアクションにまたがるフローをチェックできます。

結合テストではクライアントのリクエストをシミュレートしてそれに対する応答をチェックしました。 しかし、現実にはブラウザで表示された画面の中でボタンやリンクのクリックなどが行われます。 そこまでのシミュレートは結合テストではできません。 そのチェックはシステムテストならば可能です。 次回はシステムテストについての記事を掲載する予定です。

徒然Ruby(31)Rails7 慣例にそったプログラミング

今回はRailsの慣例に沿った形でWordbookを作り直します。

Convention Over Configuration

Railsには「Convention Over Configuration」(設定より規約が優先)という哲学があります。 「規約」とは「プログラミングの約束事」ですが、「プログラミングの慣例」あるいは「習慣」と言う方が合っているかもしれません。 というのは、この「規約」というのは「絶対に守らなければいけないルール」ではなく、「Railsが勧めるより良いプログラミングの方法」のことなのです。

そのひとつに、コントローラのアクションは7つにまとめることができる、というのがあります。

  • index: 最初の画面。モデル全体のリストを表示させることが多い
  • show: ひとつのモデルのデータを表示。クライアントはindexから項目をクリックしてshowに遷移することが多い
  • new: モデルの新規作成の画面
  • create: new画面から送られたデータにより、モデルを新規作成するアクション
  • edit: 既存のモデルのデータを変更するための画面。クライアントはshowからアクセスすることが多い
  • update: edit画面から送られたデータにより、モデルを更新するアクション
  • destroy: 既存のモデルを消去するアクション。クライアントはshowから遷移することが多い

Railsでは、「RESTful」(レストフル)なウェブアクセスでは、アクションをこの7つにまとめることができる、としています。 RESTfulは形容詞で「RESTな」ということです。 では、RESTとは何かというと、それは「Representational State Transfer」を短くしたもので、ウェブ・インターフェースのアーキテクチャスタイルです。 これでは説明にならないと思うので、具体的に説明すると次の4つを持つスタイルです

  • ステートレス(「状態」を持たない)。 例えば、ログインするようなサイトは「ログイン状態」という状態を持っているので、ステートレスではありません。 ログイン無しのサイトは、ほぼ「ステートレス」と考えて良いと思います
  • 上手く定義された操作のセット。 例えばHTTPプロトコルでは、GET、POST、PATCH、PUT、DELETEなどのメソッドがあり、これを使って上手く通信ができます
  • リソースの一意的識別。 これはHTTPではURIで識別しています
  • 文書の情報に状態遷移を含めることができる。 言葉は難しいですが、要するに「リンクを貼ることができる」ということです

これから、RESTfulというのは私達がブラウザでウェブにアクセスしているスタイルのことを概念的にまとめたものだと分かると思います。 ウィキペディアに情報がありますので、参考にしてください。

RailsではRESTfulなアクセスは上記の7つのアクションにまとめることができるから

  • プログラマーは、このパターンを主に使うようにすると良い
  • Railsは「このパターンによるプログラミング」が楽になるような様々な工夫をしている

ということなのです。

上記の7つのアクションの構成は、前回作ったwordbookの構成に似ていますが、名前が違っていました。

Railsの慣例によるアクション名 前回wordbookのアクション名
new append
edit change
destroy exec_delete
delete
search

deleteとsearchが前のwordbookにありましたが、これはRailsの慣例アクション名にはありません。

今回はこの慣例に合うようにWordbookを作り直します。 ただ、次の点は慣例と異なります。

  • indexですべての単語のリストを表示することはしない(登録単語が多くなるとリストが長くなりすぎるため)。 そこでは使い方の説明を表示することにする
  • searchアクションを作り、正規表現での検索をサポートする

モデル

それでは、WordBookの新版を作りましょう。 ディレクトリ名を「word_book_rails_resources」とします。 ディレクトリ、コントローラ、モデルを作り、データベースのマイグレーションをします。

$ rails new word_book_rails_resources -c bootstrap
... ... ...
$ cd word_book_rails_resources
$ ./bin/rails generate controller Words index show new create edit update delete search
... ... ...
$ ./bin/rails generate model Word en:string jp:string note:text
... ... ...
$ ./bin/rails db:migrate
... ... ...

モデルのフィールドには「英単語」「日本語訳」に加えて「備考」(note)を作りました。 フィールドタイプのstringは短い文字列(通常一行)で、textは複数行に渡るような文字列です。 備考には例文や解説を書くことを想定しています。

モデルのバリデーションは英単語と日本語訳に設定します。 備考については空欄でも構わないとします。

class Word < ApplicationRecord
  validates :en, presence: true, format: {with: /[a-zA-Z]+/}, uniqueness: true
  validates :jp, presence: true
end

ルーティング

/config/routes.rbを編集します。

Rails.application.routes.draw do
  root "words#index"
  get "words/search"
  resources :words
end

rootとsearchは個別のルーティングを書いておきます。 Railsの7つのアクションは「resources」メソッドでルーティングを定義します。 これはRailsが用意した「便利な工夫」のひとつで、これだけで7つのルーティングが記述できます。 このとき、それぞれのルーティングがどうなっているかは、./bin/rails routesコマンドで確認できます。

$ ./bin/rails routes
      Prefix Verb   URI Pattern                  Controller#Action
        root GET    /                            words#index
words_search GET    /words/search(.:format)      words#search
       words GET    /words(.:format)             words#index
             POST   /words(.:format)             words#create
    new_word GET    /words/new(.:format)         words#new
   edit_word GET    /words/:id/edit(.:format)    words#edit
        word GET    /words/:id(.:format)         words#show
             PATCH  /words/:id(.:format)         words#update
             PUT    /words/:id(.:format)         words#update
             DELETE /words/:id(.:format)         words#destroy
... ... ...

左の列「Prefix」に、「_path」をつけるとパスを返すメソッドが得られます。

root_path #=> /
words_search_path #=> /words/search
words_path #=> /words

このような形で他アクションへのメソッドも得られます。 createアクションはアドレスがindexアクションと同じなのでwords_pathでPOSTメソッドを使えばアクセスできます。 同様にupdateアクションはword_pathをPATCHまたはPUTメソッドで、destroyアクションはword_pathをDELETEメソッドでアクセスすることによりアクセスできます。

7つのルーティングがresourcesメソッド1つで書けるところが、負担軽減になっています。 このようなルーティングを「リソースフル・ルーティング」と呼んでいます。

なお、「リソース」というのはウェブサーバで扱っているデータのことで、例えばWordインスタンスの表すデータはリソースです。

レイアウト、ナビゲーションバー、フラッシュ

レイアウト

レイアウト/app/views/layouts/application.html.erbは前回のWordbookと同様ですが、画面幅は少し広めにしました。

<!DOCTYPE html>
<html>
  <head>
    <title>WordBookRailsResources</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
  </head>

  <body>
    <div class="container">
      <div class="col-lg-10 col-xl-8 mx-auto">
        <%= render "navbar" %>
        <%= render "flash" %>
        <%= yield %>
      </div>
    </div>
  </body>
</html>
ナビゲーションバー

ナビゲーションバー/app/views/words/_navbar.html.erbも前回とほぼ同様ですが次の2点が異なります。

  • 「変更」と「削除」はshowアクションからの画面でのみ有効にし、他のアクションからの画面では無効(disable)にする
  • 「検索」の窓もナビゲーションバーに付ける
<nav class="navbar navbar-expand-lg navbar-light" style="background-color: #e3f2fd;">
  <div class="container-fluid">
    <a class="navbar-brand" href="/">Wordbook</a>
    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarSupportedContent">
      <ul class="navbar-nav me-auto mb-2 mb-lg-0">
        <li class="nav-item">
          <%= link_to "追加", new_word_path, style: "text-decoration: none;", class: "nav-link" %>
        </li>
        <li class="nav-item">
          <% if action_name == "show" && @word %>
            <%= link_to "変更", edit_word_path(@word), style: "text-decoration: none;", class: "nav-link" %>
          <% else %>
            <a class="nav-link disabled" href="#">変更</a>
          <% end %>
        </li>
        <li class="nav-item">
          <% if action_name == "show" && @word %>
            <%= link_to "削除", @word, style: "text-decoration: none;", class: "nav-link", data: {turbo_method: :delete, turbo_confirm: "Are you sure?"} %>
          <% else %>
            <a class="nav-link disabled" href="#">削除</a>
          <% end %>
        </li>
      </ul>
      <%= form_with  url: words_search_path, method: :get, class: "d-flex" do |form| %>
        <%= form.search_field :search, placeholder: "検索", class: "form-control me-2" %>
        <%= form.submit "検索", class: "btn btn-outline-success text-nowrap" %>
      <% end %>
    </div>
  </div>
</nav>

13、20行目に<% if action_name == "show" %>があります。 action_nameRailsのメソッドで、そのときのアクション名を文字列で返します。 したがって、if節はアクションがshowのときだけ実行され、それ以外はelse節(リンクdisable)になります。

また、削除リンクはdata: {turbo_method: :delete, turbo_confirm: "Are you sure?"}により、HTTPメソッドがdeleteになり、「Are you sure?」の確認ダイアログが表示されます。 前回のwordbookではフォームの送信ボタンが削除のきっかけになっていましたが、今回はナビゲーションバーの「削除」メニューがきっかけになっています。 また、@wordだけでリンク先を表すことができ、そのパスはword_path(@word)と同じになります。

下から7〜4行目が検索部分で、form_withによるフォーム送信が埋め込まれています。 「検索」ボタンを押すとsearchアクションに飛ぶようにリンク先がwords_search_pathメソッドで与えられています。

フラッシュ

フラッシュは前回と同じです。

<% flash.each do |name, msg| %>
  <% if name == "success" %>
    <%= content_tag :div, msg, class: "text-success" %>
  <% elsif name == "alert" %>
    <%= content_tag :div, msg, class: "text-danger" %>
  <% end %>
<% end %>

indexアクション

indexアクションは初期画面です。 Wordbookの使い方を表示します。

コントローラでは特にやることはありません。

def index
end

ビューapp/views/words/index.html.erbは次のようになります。

<h1 class="text-center">単語帳</h1>
<h5 class="my-2">単語帳の使い方</h5>
<ul class="list-group my-2">
  <li class="list-group-item">Wordbook: 初期画面に戻ります</li>
  <li class="list-group-item">追加: 単語を追加します</li>
  <li class="list-group-item">検索: 単語を検索しマッチした単語を表示します</li>
</ul>
<p>
検索にRubyの正規表現を使うことができます。
各単語に対して「英単語」「日本語訳」「備考」を書き、保存することができます。
「備考」は複数行のテキストが可能なので、例文や解説などを書くのに適しています。
</p>
<p>
単語の表示、変更、削除は次のようにしてください。
なお「単語の表示」または「表示画面」とは、ひとつの単語についてその日本語訳と備考を表示すること、またはその画面です。
検索結果の画面では備考は表示されません。
</p>
<ul class="list-group my-2">
  <li class="list-group-item">表示: 検索後の一覧において、各項目の左にあるボタンをクリック</li>
  <li class="list-group-item">変更: 表示画面から変更メニューをクリック</li>
  <li class="list-group-item">削除: 表示画面から削除メニューをクリック</li>
</ul>

searchアクション

ナビゲーションバーの入力枠に正規表現を入れ、検索ボタンをクリックすることにより、/words/searchにgetメソッドでアクセスします。 これはsearchアクションにルーティングされます。

def search
  @search_word = params[:search]
  if @search_word == ""
    flash.now[:alert] = "検索ワードは入力必須です"
    render html: "", status: :unprocessable_entity
  end
  begin
    pattern = Regexp.compile(@search_word)
  rescue RegexpError
    flash.now[:alert] = "正規表現に構文エラーがあります"
    render :index, status: :unprocessable_entity
  else
    @words = Word.all.select{|word| word[:en] =~ pattern}.sort{|w1,w2| w1[:en] <=> w2[:en]}
  end
end

検索ワードはsearchという名前で送られるので、params[:search]で取り出すことができます。 searchアクションの内容は、前回のWordbookのlistアクションとほぼ同じです。

ビュー/app/views/words/search.html.erbは次のようになります。

<h1 class="my-2">単語検索結果</h1>
<p>検索ワード: <%= @search_word %></p>
<% if @words %>
  <table class="table table-bordered">
    <thead>
      <tr>
        <th scope="col">英語</th>
        <th scope="col">日本語</th>
      </tr>
    </thead>
    <tbody>
    <% @words.each do |word| %>
      <tr>
        <td><%= link_to word[:en], word %></td>
        <td><%= word[:jp] %></td>
      </tr>
    <% end %>
    </tbody>
  </table>
<% end %>

ここでは、英語と日本語訳のみを表にして表示します。 英単語はshowアクションへのリンクになっています。 wordがshowアクションへのリンクです。 このリンクには該当の単語のid(データベースの通し番号)が挿入されます。

showアクション

showアクションはid番号を用いてデータベースからWordモデルを取り出します。

def show
  begin
    @word = Word.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    @word = nil
    flash.now[:alert] = "データベースにはid=#{params[:id]}のデータは登録されていません"
  end
end    

search画面のリンクからshowアクションに遷移した場合はエラーになることはありません。 クライアント側が適当なid番号で「words/id番号」をgetでアクセスした場合にはエラーの発生する可能性があります。 そのためrescueを使ったエラー処理を入れておきました。

ビュー/app/views/words/show.html.erbは次のようになります。

<% if @word %>
  <ul class="list-group my-2">
    <li class="list-group-item"><%= @word.en %></li>
    <li class="list-group-item"><%= @word.jp %></li>
    <li class="list-group-item"><%= @word.note %></li>
  </ul>
<% end %>

showでは英単語、日本語訳に加え備考(@word.note)も表示します。

newとcreateアクション

newアクション

ナビゲーションバーの「追加」をクリックすると/words/newにgetメソッドでアクセスします。 そして、newアクションにルーティングされます。

newコントローラはWordモデルを生成して@wordに代入します。

def new
  @word = Word.new
  @submit = "作成"
end

ビュー/app/views/words/new.html.erbは次のようになります。

<h1 class="my-2">単語登録</h1>
<%= render "form" %>
formパーシャル

画面の組み立てはformパーシャルが行います。

<%= form_with model: @word do |form| %>
  <div>
    <%= form.label :en, "英単語", class: "form-label" %>
    <%= form.text_field :en, value: @word.en, class: "form-control" %>
    <% @word.errors.full_messages_for(:en).each do |message| %>
      <div class="text-danger"><%= "DB: 英単語は空欄にできません" if message == "En can't be blank" %></div>
      <div class="text-danger"><%= "DB: 英文字を入力してください" if message == "En is invalid" %></div>
      <div class="text-danger"><%= "DB: すでに同名の単語が登録されています" if message == "En has already been taken" %></div>
    <% end %>
  </div>

  <div>
    <%= form.label :jp, "日本語訳", class: "form-label" %>
    <%= form.text_field :jp, value: @word.jp, class: "form-control" %>
    <% @word.errors.full_messages_for(:jp).each do |message| %>
      <div class="text-danger"><%= "DB: 日本語訳は空欄にできません" if message == "Jp can't be blank" %></div>
    <% end %>
  </div>

  <div>
    <%= form.label :note, "備考", class: "form-label" %>
    <%= form.text_area :note, value: @word.note, class: "form-control" %>
    <% @word.errors.full_messages_for(:jp).each do |message| %>
      <div class="text-danger"><%= message %></div>
    <% end %>
  </div>

  <div class="my-2">
    <%= form.submit @submit,  class: "btn btn-primary" %>
  </div>
<% end %>

ルーティングがリソースフルである場合は、form_withにモデルを設定すればurlは省略できます。 送信ボタンを押すとcreateアクションにアクセスします。 「備考」の入力枠はform.text_areaで作ります。 複数行のテキストを想定した入力フォームです。

createアクション

createアクションはコントローラのみでビューはありません。

def create
  @word = Word.new(word_params)
  if @word.save
    flash[:success] = "単語を保存しました"
    redirect_to @word, status: :see_other
  else
    flash.now[:alert] = "単語を保存できませんでした"
    render :new, status: :unprocessable_entity
  end
end

private

def word_params
  params.require(:word).permit(:en, :jp, :note)
end

プライベートメソッドword_paramsはクライアントからのデータを調べ、必要なものだけを取り出します。 この仕組みはparamsrequirepermitの3つのメソッドで構成されています。

  • params: ActionController::Parametersオブジェクトを返す。 このオブジェクトはクライアントから送られてきたデータを、キーと値の形式(ハッシュに似た形式)で保持している
  • require: パラメータにキーが与えられたとき、その値があればその値を持つActionController::Parametersオブジェクトを返す。 そうでなければエラーになる。
  • permit: パラメータにはキーのリストをとる。 与えられたキー以外のデータは捨てられ、指定されたキーと値のみのActionController::Parametersオブジェクトを返す

この仕組みはストロング・パラメータと呼ばれ、仮に不正なデータが送られたとしてもそれを除去することができます。 createメソッドでWord.new(word_params)としても安全です。 これを使わず`Word.new(params)とするとサーバ側で予期していないデータを保存しようとする可能性があり、エラーになったり、何らかの問題を引き起こす可能性があります。

Wordbookではストロングパラメータを使わなくてもほとんど危険はありませんが、ユーザ登録の仕組みを持つサーバでは脆弱性が発生することがあります。 例えば、ユーザモデルUserが3つのフィールドname、email、adminを持っていて、最後のadminは管理者権限を表し、true=adminnil|false=not adminだとします。 newアクションのビューではnameとemailのみをデータ送信するようになっているとします。 通常なら@user=User.new(params)でnameとemailのみ代入され、admin=nilとなるのですが、 悪意のあるユーザがPOSTデータを改ざんしname、emailに加えadmin=trueを送ってきたとすると、新規ユーザは管理者になってしまいます。 ストロングパラメータがパラメータをnameとemailに限定すれば、このような危険は無くなるというわけです。

createアクションでは、単語登録できればshowにリダイレクト、失敗したら再度newの画面を表示します。

editとupdateアクション

editアクション

editアクションはshowアクションから遷移される前提です。 showアクションの画面ではナビゲーションバーの「変更」リンクが有効になります。 /words/:id/editにアクセスがあるとeditアクションにルーティングされ、:idのところの数字がパラメータとしてparams[:id]にセットされます。

def edit
  begin
    @word = Word.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    @word = nil
    flash[:alert] = "データベースにはid=#{params[:id]}のデータは登録されていません"
    redirect_to words_path, status: :see_other
  end
  @submit = "変更"
end

show画面から遷移すればWord.find(params[:id])がエラーになることはありませんが、ユーザがURLを作ってアクセスすると、そうとも限りません。 そこで、rescueを使ったエラーの捕捉をしています。

ビューは次のようになります。

<h1 class="my-2">単語の変更</h1>
<%= render "form" %>

newと共有しているformパーシャルを使います。 editの場合は送られてきたデータによってフォーム入力枠に値が入った状態から編集することができます。 編集が終わるとupdateアクションに遷移します。 newではcreateアクションへの遷移でした。 どこでcreateとupdateを区別しているかと言うと、@wordのidフィールドがnilか数字かの違いです。 この判断はform_withメソッドがしてくれるので、プログラマーが気にする必要はありません。

updateアクション

updateアクションはコントローラのみでビューはありません。

def update
  @word = Word.find(params[:id])
  if @word.update(word_params)
    redirect_to @word, status: :see_other
  else
    flash.now[:alert] = "単語「#{@word.en}」は変更できませんでした"
    render :edit, status: :unprocessable_entity
  end
end

アップデートができたらshowにリダイレクト、失敗したらedit画面を再表示します。 @wordだけで/words/:idにリダイレクトできます。 word_path(@word)より短い@wordだけでも同じリンク先を生成してくれるのは、リソースフル・ルーティングのさらなる恩恵です。

destroyアクション

show画面ではナビゲーションバーの「削除」リンクが使えるようになります。 それをクリックするとdestroyアクションに遷移します。 destroyアクションはコントローラだけでビューはありません。

def destroy
  @word = Word.find(params[:id])
  if @word == nil
    flash[:alert] = "単語#{@en}は未登録のため削除できません"
  else
    begin
      @word.destroy
    rescue
      flash[:alert] = "単語#{@en}を削除できませんでした"
    else
      flash[:success] = "単語を削除しました"
    end
    redirect_to words_path, status: :see_other
  end
end

削除ができてもできなくてもINDEX画面にリダイレクトします。 クライアント側はフラッシュ・メッセージによって削除できたかどうかを知ることができます。

起動

コマンドラインから起動します。

$ ./bin/rails server

ブラウザを起動しhttp://localhost:3000にアクセスするとWordbookの初期画面が現れます。

railsを終了するには、コマンドラインからCTRL-Cを入力します。

まとめ

前回のWordbookと比べ、大きな差はないものの、リソースフル・ルーティングを使ったおかげで楽をした部分がありました。 また、Railsの慣例に従っているので、メンテナが変わっても分かりやすいはずです。 そのような保守管理の意味でも慣例を守るのは重要なことです。

Wordbookはシンプルで分かりやすいと思います。 実際のウェブの開発はもっと複雑でデータベース・テーブルも複数になり、それらが関係しあっていることもあります。 そのようなテーブル間の関係をRailsはサポートしているのですが、今回はそこまでは踏み込みませんでした。 詳しいことは「Rails Guide」を参照してください。

また、ソースコードGitHub_example/word_book_rails_resourcesにありますので、 必要な方はダウンロードまたはクローンしてお使いください。

徒然Rubyのウェブサイト(GitHub pages)を作りました

「徒然なるままにRuby」のウェブサイトを作りました。

  • おもこんに連載した「徒然なるままにRuby(1)〜(30)」を収録しました
  • 収録にあたり、見直しをして、一部記載を修正しました
  • おもこんに残っている「徒然Ruby」も一部修正しましたが、依然として古い記述も残っています
  • レポジトリにはプログラムのソースコードも含まれています(_exampleディレクトリ)

今後はウェブサイトの方をご覧いただければと思います。

「徒然Ruby」シリーズはもう少し書こうと思っていますが、今後は「おもこん」と「ウェブサイト」の両方に同時投稿していきます。 今後もよろしくお願いいたします。