Ruby 3.2 - 無くなったメソッド

Ruby 3.2 アドベントカレンダーの5日目の記事です。

qiita.com


無くなったメソッド

Dir.exists? / File.exists?

Ruby 2.1 で Dir.exists?File.exists? は deprecated になった。 Dir.exist?File.exist? を使えと。

Warning[:deprecated] = true
Dir.exists?(".")
#=> warning: Dir.exists? is deprecated; use Dir.exist? instead
File.exists?(".")
#=> warning: File.exists? is deprecated; use File.exist? instead

とうとう Ruby 3.2 で無くなった。deprecated になってから無くなるまで長いな。

Dir.exists?(".")
#=> undefined method `exists?' for Dir:Class (NoMethodError)
File.exists?(".")
#=> undefined method `exists?' for File:Class (NoMethodError)

Object#=~

Ruby 2.6 で Object#=~ が deprecated になった。

Warning[:deprecated] = true
123 =~ /./  #=> nil
#=> warning: deprecated Object#=~ is called on Integer; it always returns nil

Ruby 3.2 で無くなった。

123 =~ /./  #=> nil
#=> undefined method `=~' for 123:Integer (NoMethodError)

まあ String と Regexp にあればいいよね。

[追記] nil (NilClass) にも残されてた。gets =~ /./ みたいなのがエラーにならないで欲しいということっぽい。

Object#taint / Object#untaint / Object#tainted? / Object#trust / Object#untrust / Object#untrusted?

Ruby 2.7 から taint 系のメソッドが deprecated になった。

Warning[:deprecated] = true
"hoge".taint       #=> warning: Object#taint is deprecated and will be removed in Ruby 3.2
"hoge".untaint     #=> warning: Object#untaint is deprecated and will be removed in Ruby 3.2
"hoge".tainted?    #=> warning: Object#tainted? is deprecated and will be removed in Ruby 3.2
"hoge".trust       #=> warning: Object#trust is deprecated and will be removed in Ruby 3.2
"hoge".untrust     #=> warning: Object#untrust is deprecated and will be removed in Ruby 3.2
"hoge".untrusted?  #=> warning: Object#untrusted? is deprecated and will be removed in Ruby 3.2

Ruby 3.2 で無くなった。

"hoge".taint       #=> undefined method `taint' for "hoge":String (NoMethodError)
"hoge".untaint     #=> undefined method `untaint' for "hoge":String (NoMethodError)
"hoge".tainted?    #=> undefined method `tainted?' for "hoge":String (NoMethodError)
"hoge".trust       #=> undefined method `trust' for "hoge":String (NoMethodError)
"hoge".untrust     #=> undefined method `untrust' for "hoge":String (NoMethodError)
"hoge".untrusted?  #=> undefined method `untrusted?' for "hoge":String (NoMethodError)

Ruby 3.2 - 無くなったクラス/定数

Ruby 3.2 アドベントカレンダーの4日目の記事です。

qiita.com


無くなったクラスや定数

Fixnum / Bignum

Ruby 2.4 から Fixnum と Bignum は deprecated になって Integer の別名になった。

Warning[:deprecated] = true
Fixnum  #=> Integer
#=> warning: constant ::Fixnum is deprecated
Bignum  #=> Integer
#=> warning: constant ::Bignum is deprecated

Ruby 3.2 でとうとう無くなった。

Fixnum
#=> uninitialized constant Fixnum (NameError)
Bignum
#=> uninitialized constant Bignum (NameError)

無くなるまで長かったな…。

Random::DEFAULT

Ruby 2.7 までは Random::DEFAULT はデフォルトの乱数生成器(?よくわかってない)の Random オブジェクトを返していたんだけど、スレッドセーフじゃないとかで Ruby 3.0 で Random::DEFAULT は deprecated になって Random を返すようになった。

Warning[:deprecated] = true
Random::DEFAULT  #=> Random
#=> warning: constant Random::DEFAULT is deprecated

Ruby 3.2 で無くなった。

Random::DEFAULT
#=> uninitialized constant Random::DEFAULT (NameError)

Struct::Group / Struct::Passwd

require 'etc' すると何故か Struct::GroupStruct::Passwd が作られてたんだけど、実体は Etc::GroupEtc::Passwd だった。

Ruby 3.0 からは deprecated になってた。

Warning[:deprecated] = true
require 'etc'
Struct::Group   #=> Etc::Group
#=> warning: constant Struct::Group is deprecated
Struct::Passwd  #=> Etc::Passwd
#=> warning: constant Struct::Passwd is deprecated

Ruby 3.2 で無くなった。

require 'etc'
Struct::Group   #=> Etc::Group
#=> uninitialized constant Struct::Group (NameError)
Struct::Passwd  #=> Etc::Passwd
#=> uninitialized constant Struct::Passwd (NameError)

ていうかなんで Struct 配下に存在してたんだろ。

文字化け復元ページを作ったよ

これは SmartHR アドベントカレンダーの4日目の記事です。

qiita.com

といっても内容は会社とは関係ありません。


文字化け復元ページを作った。

tmtms.net

使い方

最初に「繧ゅ§縺ー縺代r縺オ縺上£繧薙☆繧九h」という文字化けした文字列が入力されてる。

「復元」ボタンを押すと、それが復元されて「もじばけをふくげんするよ」という文字列が表示される。

さらに「文字化け」ボタンを押すと、文字化けされた文字列が表示される。 これが元の「繧ゅ§縺ー縺代r縺オ縺上£繧薙☆繧九h」と同じなので、正常に復元されたことがわかる。

一番上の文字列を消して「豎滓虻蟾昴さ繝翫Φ縺ョ豁」菴薙・蟾・阯、譁ー荳・」を入れて「復元」を押してみる。 「江戸川コナンの正体\xE3?工藤新\xE4\xB8?」となる。 文字化け文字列中の「」の部分は情報が落ちてしまった部分。情報が落ちてしまったので完全には復元できない。

まあ、それ以外の文字列から「江戸川コナンの正体は工藤新一」と想像できるので、そのように入力して「文字化け」を押してみると、「豎滓虻蟾昴さ繝翫Φ縺ョ豁」菴薙・蟾・阯、譁ー荳・」となり元の文字列と同じだから、「江戸川コナンの正体は工藤新一」だった可能性が高いと判断できる。

このように情報が落ちてしまってる場合は前後の文脈からある程度推測するしかない。 途中の「\xE3?」は文字コード的にはたとえば「」でも成立するし、末尾の「\xE4\xB8?」は「」でも成立するのでもしかしたら「江戸川コナンの正体ね工藤新三」の可能性もある。

文字化けと復元方法

このページは、UTF-8 で書かれた文字列を CP932(Windows-31J)で表示しようとして文字化けしてしまった文字列に対応している。

それを復元しようとしたら、該当文字を CP932 に変換し、そのバイト列を UTF-8 として表示すればいい。 「・」は情報が落ちてしまった文字なので、そのまま扱うとおかしなことになるので、たとえば「?」に変換しておく。

Ruby で書くならこんな感じ。

str.tr('', '?')             # 「・」を「?」に変換
  .encode('cp932', undef: :replace, invalid: :replace, replace: '?')
                              # CP932 に変換(変換できない文字は「?」にする)
  .force_encoding('utf-8')    # バイト列を強制的に UTF-8 とする

文字化けさせるには、こんな感じで。

str.force_encoding('cp932')    # バイト列を強制的に CP932 とする
  .encode('utf-8', undef: :replace, invalid: :replace, replace: '')
                               # UTF-8 に変換(変換できない文字は「・」にする)

しくみ

前からこういうページを作ろうかと考えてたんだけど、このためにわざわざサーバーサイドを作るのはちょっとなーと思って、かといって JavaScript でこれを実現するのはムリだと思ったので諦めてた。

Ruby が WebAssembly で動くようになったので、サーバーサイドを作らなくても実現できるようになったんで作ってみた。

https://github.com/ruby/ruby.wasm/ にある通り、

<script src="https://cdn.jsdelivr.net/npm/ruby-head-wasm-wasi@0.5.0/dist/browser.script.iife.js"></script>
<script type="text/ruby">
  ここが Ruby スクリプト
</script>

ってするだけで Ruby がブラウザ上で動く。https://tmtms.net/mojibake.html のソースもそうなってる。

標準入出力等はブラウザのコンソールになる。 DOM 操作は js ライブラリ経由で JavaScript を操作する必要がある。 メソッド名がスネークケースじゃなくて JavaScript 風のキャメルケースなのでちょっと気持ち悪いけど、普通に DOM 操作できる。

あと、スクリプトエンコーディングが UTF-8 じゃなくて ASCII-8BIT になってる。今回文字化けページを作るのにちょっとハマった。

ブラウザ上で動くプログラムを JavaScript ではなく Ruby でそのまま書けるのはかなり体験が良い。もっと書いていこう。

Ruby 3.2 - 代入式の評価順 / パターンマッチ

Ruby 3.2 アドベントカレンダーの3日目の記事です。

qiita.com


代入式の評価順

Bug #15928: Constant declaration does not conform to JIS 3017:2013 - Ruby master - Ruby Issue Tracking System

定数設定時、定数の定義元オブジェクトと代入する式の評価順が変更された。

def foo
  p :foo
  Object
end

def bar
  p :bar
end

foo::Hoge = bar

上のスクリプトを実行すると次の出力になる。

Ruby 3.1:

:bar
:foo

右辺が先に評価されてる。これが 3.2 では変更になり、

Ruby 3.2:

:foo
:bar

代入式では必ず左辺が先に評価されるということになったっぽい。

パターンマッチ

Find pattern が正式機能になった

Feature #18585: Promote find pattern to official feature - Ruby master - Ruby Issue Tracking System

パターンマッチの Find pattern というやつが正式機能になった。

a = ['a', 1, 'b', 2, 'c', 3]
a in [*, Integer => x, String => y, *]
p [x, y]  #=> [1, "b"]

Ruby 3.1 だと

warning: Find pattern is experimental, and the behavior may change in future versions of Ruby!

という警告が出ていたが、3.2 だと出なくなった。

* で任意のオブジェクトを指定できるってことなのかな。パターンマッチ全然わからん。

ruby-mysql と ruby-mysql2

これはMySQLアドベントカレンダーとRubyアドベントカレンダーの3日目の記事です。

qiita.com

qiita.com


ruby-mysql

誰も使わないだろうけど、ruby-mysql 4.0 をリリースした。

ruby-mysql | RubyGems.org | コミュニティのGemホスティングサービス

ruby-mysql は Ruby で書かれた MySQL 用のクライアントライブラリ。

3.0 に対する大きな変更点

Mysql#query の結果の値オブジェクトのクラス

今まではプリペアドステートメント(Mysql#prepare)ではない通常のクエリ(Mysql#query)の結果の値はすべて String で返していた。 プリペアドステートメントの場合は、MySQL の型に応じたオブジェクトを返していた。 4.0 で通常のクエリでもプリペアドステートメントと同様のオブジェクトで結果を返すようにした。 あと DECIMAL と DATE の型も変更した。

MySQL type Ruby class
NULL NilClass
INT Integer
DECIMAL BigDecimal (3.0 までは String)
FLOAT, DOUBLE Float
DATE Date (3.0 までは Time)
DATETIME, TIMESTAMP Time
TIME Float (秒単位)
YEAR Integer
CHAR, VARCHAR String
BIT String
TEXT, BLOB, JSON String

3.0:

pp my.query('select 123,123.45,now(),cast(now() as date)').fetch.map{[_1, _1.class]}
#=> [["123", String],
#    ["123.45", String],
#    ["2022-11-15 00:17:11", String],
#    ["2022-11-15", String]]

4.0:

pp my.query('select 123,123.45,now(),cast(now() as date)').fetch.map{[_1, _1.class]}
#=> [[123, Integer],
#    [0.12345e3, BigDecimal],
#    [2022-11-15 00:17:17 +0900, Time],
#    [#<Date: 2022-11-15 ((2459899j,0s,0n),+0s,2299161j)>, Date]]

今までと同じように String で返すには、Mysql.new とか Mysql.connect とか Mysql#query とか Mysql#each とか Mysql#fetch とかに cast: false を指定する。

Mysql.new(cast: false).connect.query('select 123').fetch   #=> ["123"]
Mysql.new.connect(cast: false).query('select 123').fetch   #=> ["123"]
Mysql.connect(cast: false).query('select 123').fetch       #=> ["123"]
Mysql.connect.query('select 123', cast: false).fetch       #=> ["123"]
Mysql.connect.query('select 123').each(cast: false).first  #=> ["123"]
Mysql.connect.query('select 123').fetch(cast: false)       #=> ["123"]

または、Mysql.default_options を変更すると、それ以降そのプロセスで生成された Mysql オブジェクトの振る舞いが変更される。

m1 = Mysql.connect
Mysql.default_options[:cast] = false
m2 = Mysql.connect
m1.query('select 123').fetch  #=> [123]
m2.query('select 123').fetch  #=> ["123"]

Mysql::Result#each が毎回先頭から繰り返す

3.0 までは each は前回の続きから結果を返すが、4.0 では最初から繰り返す。

3.0:

res = my.query('select 1 union select 2 union select 3')
res.entries  #=> [["1"], ["2"], ["3"]]
res.entries  #=> []

res = my.query('select 1 union select 2 union select 3')
res.each.first  #=> ["1"]
res.each.first  #=> ["2"]
res.each.first  #=> ["3"]
res.each.first  #=> nil

4.0:

res = my.query('select 1 union select 2 union select 3')
res.entries  #=> [[1], [2], [3]]
res.entries  #=> [[1], [2], [3]]

res = my.query('select 1 union select 2 union select 3')
res.each.first  #=> [1]
res.each.first  #=> [1]
res.each.first  #=> [1]

3.0 と同じ振る舞いにしたいことはないと思うけど、もししたい場合は、こんな感じで:

require 'mysql'
class Mysql::Result
  def each(**opts)
    while (r = fetch(**opts))
      yield r
    end
  end
end
my = Mysql.connect
res = my.query('select 1 union select 2 union select 3')
pp res.entries  #=> [[1], [2], [3]]
pp res.entries  #=> []

その他

RSpec

テストコードは test-unit で書いてたんだけど、RSpec を使うようにした。慣れてるので。

GitHub から GitLab に移行

https://github.com/tmtm/ruby-mysql から https://gitlab.com/tmtms/ruby-mysql に移行した。なんとなく。

ruby-mysql2

mysql2 は C ライブラリの libmysqlclient を使ってるんだけど、その代わりに ruby-mysql を使うと面白いかと思って、mysql2 をベースに ruby-mysql2 を作ってみた。

gitlab.com

mysql2 のテストコードはかなり充実してて、ruby-mysql の開発にも役立った。

テストコードはだいたい通ったので、普通に mysql2 の代わりとして使えると思う。

mysql2 との非互換は、これくらい。

  • my.cnf 等を読まない
  • caching_sha2_password, mysql_native_password, sha256_password 以外の認証方式はサポートしない
  • Mysql2::EM がない

試しに ActiveRecord で使ってみようと思ったんだけど、mysql2_adapter.rb 中で gem "mysql2" と書かれてたので、ダメだった。

Sequel では普通に使えた。

まあでも、特に ruby-mysql2 を使う理由はないな。普通に mysql2 を使えばいいんだし。mysql2 に比べたらかなり遅いし。

また誰の役に立たないものを作ってしまった…。

Ruby 3.2 - キーワードパラメータその3

Ruby 3.2 アドベントカレンダーの2日目の記事です。

qiita.com


キーワードパラメータその3

Bug #18625: ruby2_keywords does not unmark the hash if the receiving method has a *rest parameter - Ruby master - Ruby Issue Tracking System

Ruby 2.7 ではこういうスクリプトが動いたんだけど、

def hoge(*args)
  fuga(*args)
end

def fuga(x: nil)
  pp x  #=> 123
end

hoge(x: 123)

Ruby 3.0 でキーワードパラメータの仕様が変わってエラーになるようになった。

x.rb:5:in `fuga': wrong number of arguments (given 1, expected 0) (ArgumentError)

メソッドに ruby2_keywords をつけると Ruby 3.0 でもエラーにならなくなる。

ruby2_keywords def hoge(*args)
  fuga(*args)
end

def fuga(x: nil)
  pp x  #=> 123
end

hoge(x: 123)

キーワードパラメータを受け付けるメソッド(ここでは fuga)までの間にもうひとつメソッド(ここでは hoge2)を挟んでも動いた。

ruby2_keywords def hoge(*args)
  hoge2(*args)
end

def hoge2(*args)
  fuga(*args)
end

def fuga(x: nil)
  pp x  #=> 123
end

hoge(x: 123)

けど、どうやらこれはバグだったらしい。Ruby 3.2 ではエラーになるようになった。

x.rb:9:in `fuga': wrong number of arguments (given 1, expected 0) (ArgumentError)

hoge2 にも ruby2_keywords を指定すると動く。

ruby2_keywords def hoge(*args)
  hoge2(*args)
end

ruby2_keywords def hoge2(*args)
  fuga(*args)
end

def fuga(x: nil)
  pp x  #=> 123
end

hoge(x: 123)

しかし自分はこの辺は全然わかってないな…。別にわかる必要もない気はするけど。

Ruby 3.2 - キーワードパラメータその1、その2

Ruby 3.2 アドベントカレンダーの1日目の記事です。

qiita.com

Ruby は毎年クリスマスにバージョンが上がります。 今年も順調にいけば 12/25 に Ruby 3.2 がリリースされるはずです。 3.2 がリリースされるまで毎日少しずつ変更点を見ていきながらリリースを待ちましょう。

ネタ元は Ruby 3.2 preview3 の NEWS.md


キーワードパラメータその1

Feature #18351: Support anonymous rest and keyword rest argument forwarding - Ruby master - Ruby Issue Tracking System

*** で受け取ったパラメータを引数として使う場合に変数名をつける必要がなくなった。

def hoge(a, *, **)
  a  #=> 123
  fuga(*, **)
end

def fuga(*args, **opts)
  pp args, opts
  #=> [456]
  #   {:x=>"abc", :y=>"def"}
end

hoge(123, 456, x: 'abc', y: 'def')

でもこれキーワードパラメータがあるとエラーになってしまう。

def hoge(a, *, x: nil, **)
  fuga(*, **)   #=> no anonymous keyword rest parameter
end

これはどうやらバグだったらしい。3.2 リリースまでには直りそう。 Bug #19132: ** を引数に指定すると no anonymous keyword rest parameter になる - Ruby master - Ruby Issue Tracking System

キーワードパラメータその2

Bug #18633: proc { |a, **kw| a } autosplats and treats empty kwargs specially - Ruby master - Ruby Issue Tracking System

ブロックパラメータがキーワードパラメータを含む場合に、配列を渡した場合の扱いが変わった。 3.1 までは配列の中の要素が展開されてパラメータに格納されたが、3.2 では配列のまま。

Ruby 3.1:

proc{|a| p a}.call([1, 2])         #=> [1, 2]
proc{|a,| p a}.call([1, 2])        #=> 1
proc{|a, k:nil| p a}.call([1, 2])  #=> 1
proc{|a, **k| p a}.call([1, 2])    #=> 1

Ruby 3.2:

proc{|a| p a}.call([1, 2])         #=> [1, 2]
proc{|a,| p a}.call([1, 2])        #=> 1
proc{|a, k:nil| p a}.call([1, 2])  #=> [1, 2]
proc{|a, **k| p a}.call([1, 2])    #=> [1, 2]

Ubuntu 22.10 に rbenv を使わずに Ruby をインストールする

Ruby 3.1.3 / 3.0.5 / 2.7.7 が出たので普段使いの Ubuntu 22.10 にインストールした。 rbenv とかを使えばいいんだけど、自分は普段から rbenv は使ってないので。

Ruby 3.1.3

特別なことは何もしていない。

% tar xf ruby-3.1.3.tar.xz
% cd ruby-3.1.3
% ./configure --prefix=$HOME/ruby31 --with-ext=openssl,zlib,psych,dbm,gdbm,+
% make -j
% make install

Ruby 3.0.5

OpenSSL 3.0 に対応してないので openssl ライブラリはコンパイルできない。

% tar xf ruby-3.0.5.tar.xz
% cd ruby-3.0.5
% ./configure --prefix=$HOME/ruby30 --with-ext=openssl,zlib,psych,dbm,gdbm,+
% make -j
...
*** Following extensions are not compiled:
openssl:
        Could not be configured. It will not be installed.
        /tmp/ruby-3.0.5/ext/openssl/extconf.rb:113: OpenSSL >= 1.0.1, < 3.0.0 or LibreSSL >= 2.5.0 is required
        Check ext/openssl/mkmf.log for more details.
*** Fix the problems, then remove these directories and try again if you want.
make[1]: *** [exts.mk:2234: note] エラー 1
make[1]: ディレクトリ '/tmp/ruby-3.0.5' から出ます
make: *** [uncommon.mk:301: build-ext] エラー 2

一旦 --with-ext から openssl を外してインストールする。

% ./configure --prefix=$HOME/ruby30 --with-ext=zlib,psych,dbm,gdbm,+
% make -j
% make install

最新の openssl gem は OpenSSL 3.0 に対応しているので、それをインストールする。 ただし https://rubygems.org に接続するのに openssl ライブラリが必要なので gem install openssl ではインストールできない。

% ~/ruby30/bin/gem install openssl
ERROR:  While executing gem ... (Gem::Exception)
    OpenSSL is not available. Install OpenSSL and rebuild Ruby (preferred) or use non-HTTPS sources

なので wget や curl で openssl gem をダウンロードする。

% wget -q https://rubygems.org/downloads/openssl-3.0.1.gem
% curl -s https://rubygems.org/downloads/openssl-3.0.1.gem --output openssl-3.0.1.gem

その後ファイルを指定して gem install すればOK。

% ~/ruby30/bin/gem install openssl-3.0.1.gem
Building native extensions. This could take a while...
Successfully installed openssl-3.0.1
Parsing documentation for openssl-3.0.1
Installing ri documentation for openssl-3.0.1
Done installing documentation for openssl after 0 seconds
1 gem installed

Ruby 2.7.7

Ruby 3.0 と同じように openssl をビルドしないようにしてインストールしてみると make install でエラーになる。

% tar xf ruby-2.7.7.tar.xz
% cd ruby-2.7.7
% ./configure --prefix=$HOME/ruby27 --with-ext=zlib,psych,dbm,gdbm,+
% make -j
% make install
...
/tmp/ruby-2.7.7/lib/rubygems/core_ext/kernel_require.rb:83:in `require': cannot load such file -- openssl (LoadError)
make: *** [uncommon.mk:373: do-install-all] エラー 1

ちょっと強引かもしれないけど、ビルド前に ext/openssl を openssl gem に置き換えたらできた。 ext/digest が openssl に依存してるみたいなので一緒に置き換えておく。

% curl -s https://rubygems.org/downloads/openssl-3.0.1.gem --output openssl-3.0.1.gem
% curl -s https://rubygems.org/downloads/digest-3.1.0.gem --output digest-3.1.0.gem
% gem unpack openssl-3.0.1.gem
% gem unpack digest-3.1.0.gem
% tar xf ruby-2.7.7.tar.xz
% cd ruby-2.7.7
% rm -rf ext/openssl ext/digest
% rsync -a ../openssl-3.0.1/ext/openssl ./ext/
% rsync -a ../openssl-3.0.1/lib ./ext/openssl/
% rsync -a ../digest-3.1.0/ext/digest ./ext/
% rsync -a ../digest-3.1.0/lib ./ext/digest/
% ./configure --prefix=$HOME/ruby27 --with-ext=openssl,zlib,psych,dbm,gdbm,+
% make -j
% make install

ちなみに rbenv は openssl-1.1.1s をインストールしてそれを使ってビルドしてるっぽい。 まあ普通は rbenv を使うのがいいと思う。

RubyKaigi 2022 に行ってきた

RubyKaigi が3年ぶりにオフラインで開催されたので行ってきた。

希望者は出張扱いで行けるので前泊後泊で4泊5日の出張だった。

勤務先がスポンサーになってる RubyKaigi は初。ブースも出してたんでときどきブース番もした。 フルリモートで家に閉じこもってて入社してから一度も会社に出社したことなかったんで、会社の人とリアルで会うのは初めてだった。

1日目

初日、津駅前からシャトルバスが出てたんだけど、ホテルから会場まで 2km くらいだったし、雨も小降りだったので歩いて行ったら途中から結構な降りになってきてた。普通にシャトルバスに乗ればよかった。

Ruby meets WebAssembly

ブラウザで Ruby が動く。すごい。 ファイルシステム、ネットワーク、DOM 操作とかは直接はできなくて、JavaScript 経由でする必要があるっぽいけど、ライブラリ等が用意されてるみたいだからある程度はできそう。 すごい。

Making *MaNy* threads on Ruby

Ruby を M:N スレッドにして大量のスレッドを作るという話。

Types teaches success, what will we do?

型(RBSファイル)を書こうという話。

Tools for Providing rich user experience in debugger

Chrome の DevTools が Ruby のデバッガになったり、VS Code で Ruby のデバッグができたり。 VS Code で Ruby オブジェクトをビジュアルに表示できるのは面白かった。 使いこなせたらとても便利そう。

TRICK 2022

特殊能力者の異次元の遊び。

その他

ホテルに帰る前に Cookpad さんのブースで Cookpad Code Puzzle for RubyKaigi 2022 の URL を入手したのでホテルで夜中までやって全問解いた。

ここで20問全部解いたのは正解だった。解けてなかったら、2日目以降会場でろくに発表聞かずにパズルをやってたと思う。

2日目

この日も歩いて会場まで行こうと思ってたんだけど時間的に間に合わなくてシャトルバスに乗った。バスだとあっという間に着いた。快適。文明の利器すごい。

Matz Keynote

ブログ書けって言ってたんでやっと書いたよ。

Do Pure Ruby Dream of Encrypted Binary Protocol?

通信プロトコルの実装は難しいけど楽しいよねー。 pack / unpack がややこしいみたいな話がされてたと思うけど、そうかな…。あんまり感じたことなかった。 プロトコル専用のライブラリとかがあれば楽なのかなぁ。 構造体だったら実は Fiddle でクラス定義するだけでもよかったり?

ブース番

Packet analysis with mruby on Wireshark - dRuby as example

Wireshark 用に mruby で dRuby プロトコルを扱える拡張を作ったという話。

Ruby Committers vs The World

いつもだったら席に座って話してたところだけど、今回はマイクスタンドのところまで出てきて立って話してたんでラップバトルみたいな構図で面白かった。

3日目

error_highlight: user-friendly error diagnostics

Ruby 3.1 で NoMethodError 時に ^^^ でエラー箇所が出るようになったけど、それの実装の話と、3.2 からエラー箇所をプログラムから取得できるようにしたという話。

ログファイルにエラーを1行で出力するときに ^^^ だと何文字目なのかわかりにくくなっちゃうので、便利そう。

RBS generation framework using Rack architecture

凄そうなんだけど口調のせいか内容があまり入ってこなかった…。

Let's collect type info during Ruby running and automaticall

rbs ファイルを rb 実行時に自動生成する試み。 すでにテストコードもある rb に対して最初に rbs を作るとっかかりとしては良さそう。

Why is building the Ruby environment hard?

いろんな環境で Ruby をビルドするツラミの話。

macOS は結構つらいんだな。そういやこの前も OS アップデートしたら Ruby がビルドできなくなったという話もあったし。

やっぱりデスクトップ Linux 使うのが一番いいな。

The Better RuboCop World to enjoy Ruby

rubocop がつらいという話。わかる。

でもつらいのは rubocop のデフォルトの Cop のルールがつらいというだけなので、自由を重視するのなら Style/* を一旦全部無効にしてみるのがいいんじゃないかと思った。

エラーレベルと参考レベルをわけるという案が提示されてたけど、それだと結局「参考レベル」のが放置されて時間がたつと破綻しそうな気がする。

String Meets Encoding

CSV パースをエンコーディングまわりで速くしたという話。よかった。

おやつ休憩

Stories from developing YJIT

Ruby の話じゃなくて CPU やアセンブラの話だったような気がする。 英語だったから何もわからんかった。 日本語だったらわかったのかというとそれも怪しいけど。

松本

おわり


しかし長距離の移動は疲れる。特急しなのの3時間が特に。酔うから車内で何もできないし。少し前まで月に何回も出張してたのになぁ。ほとんど出歩かない生活になってしまったせいか。

前の会社と違って、出張時の交通費の精算に特急券の領収書が必要というのがめんどくさい。ときどきあやうく領収書ボタンを押し忘れそうになった。あぶない。領収書必要なくなればいいのになぁ…。

最終日翌日は伊勢神宮観光でもしようと思ってたんだけど脚が痛くなってしまったので断念。日頃の運動不足が祟ったか。

津駅の改札内のコンビニで土産に「赤福餅」と「なが餅」と「松阪牛にしぐれ煮」を買って帰った。

Fiddle で Ruby から C ライブラリを使う

rkremap を作ってるとき、最初は FFI を使ったんだけど、そういや Fiddle だと Ruby 標準だからそっちの方がいいかな…と思って Fiddle で作り直した。

ということで忘れないうちに Fiddle についてまとめておく。

Fiddle

Fiddle を使うと Ruby から C のライブラリ関数を呼び出すことができる。

C ライブラリを使いたいんだけど Ruby のライブラリが用意されてない場合とかに Fiddle を使えば C を書くこともなくコンパイルもせずにサクッと使うことができる。当然 C の知識は必要だけども。

たとえば libc の atoi を呼ぶにはこんな感じ:

require 'fiddle/import'
module C
  extend Fiddle::Importer
  dlload 'libc.so.6'
  extern 'int atoi(const char *nptr)'
end
p C.atoi("123")  #=> 123

簡単!

dlload でライブラリを指定して、extern で関数の引数や戻り値の型を指定する。C の extern 文をそのまま書けるので便利。これだけでモジュールの関数として呼び出すことができる。

C の atoi() の引数の型はポインタなんだけど、Ruby の文字列オブジェクトを引数として渡すと、Fiddle が自動的にその文字列のメモリ上のポインタを引数として渡してくれる。

C の関数は文字列の最後に NUL(\0)があることを期待するものが多いけど、Ruby は今のところ文字列の内部表現は末尾に NUL があることになってるので問題なく処理できる。

こんな風に文字列の途中を抜き出した場合でも大丈夫:

p C.atoi("123456"[2,3])  #=> 345

誤って数値オブジェクトを渡したりすると、サクッと Segmentation fault で落ちたりする。Ruby プログラムじゃないみたいで面白い。

...
p C.atoi(123)
% ruby example.rb
example.rb:6: [BUG] Segmentation fault at 0x000000000000007b
ruby 3.1.0p0 (2021-12-25 revision fb4df44d16) [x86_64-linux]

-- Control frame information -----------------------------------------------
c:0004 p:---- s:0018 e:000017 CFUNC  :call
c:0003 p:0016 s:0013 e:000012 METHOD example.rb:6
c:0002 p:0028 s:0007 e:000005 EVAL   example.rb:7 [FINISH]
c:0001 p:0000 s:0003 E:001270 (none) [FINISH]

-- Ruby level backtrace information ----------------------------------------
example.rb:7:in `<main>'
example.rb:6:in `atoi'
example.rb:6:in `call'

-- Machine register context ------------------------------------------------
...

まあ他人に使ってもらうライブラリを作る場合は、ちゃんと型チェックをするラッパーモジュールを書いたほうがいいような気がする。

定数やマクロや環境依存の型

C のヘッダファイルで #define で定義されてる定数とかマクロは Fiddle は知ることができないので、それは Ruby コードで適当に定義しないといけない。めんどくさいけど仕方ない。

もっとやっかいなのは型が環境に依存している場合なんだけど、まあでもライブラリのファイル名をバージョンまで含めて指定しないといけなかったりして、Fiddle を使ったプログラムは環境に依存しがちだし、どこまで頑張る必要があるのかって感じ。

構造体とポインタ

C は構造体を作ってそのポインタを関数に渡すとか、引数に指定したポインタに値を返してもらうとかは普通にある。

Fiddle で構造体を扱うにはこんな感じ:

require 'fiddle/import'
module C
  extend Fiddle::Importer
  dlload 'libc.so.6'
  typealias 'time_t', 'long int'
  typealias 'suseconds_t', 'long int'
  Timeval = struct(['time_t tv_sec', 'suseconds_t tv_usec'])
  extern 'int gettimeofday(struct timeval *tv, struct timezone *tz)'
end
timeval = C::Timeval.malloc(Fiddle::RUBY_FREE)
C.gettimeofday(timeval, nil)
p timeval.tv_sec   #=> 1970-01-01 00:00:00 UTC からの経過秒数
p timeval.tv_usec  #=> マイクロ秒

typealias は C の typedef みたいな感じ。structextern で C の標準以外の型を書きたい場合は書いておく。

struct で C の構造体に対応した Ruby のクラスを作る。 extern 内で struct timeval *tvstruct timezone *tz と、宣言してない構造体の名前を書いてるけどエラーにならないのは、ポインタだからみたい。ポインタの型は何でもいいらしい。

C::Timeval.malloc で構造体のメモリを確保して引数に渡すと、文字列の場合と同じく Fiddle が自動的にポインタに変換してくる。

malloc 時に Fiddle::RUBY_FREE を指定しておけば、Ruby の GC 時に獲得メモリを自動的に解放してくれる。勝手に解放してほしくない場合は引数を指定しなければいい。

GC を待たずにメモリを解放したい場合は、malloc をブロック付きで実行すればブロック終了時に解放される。

C::Timeval.malloc(Fiddle::RUBY_FREE) do |timeval|
  C.gettimeofday(timeval, nil)
  ...
end

引数なしで malloc したメモリは Fiddle.free(obj.to_ptr) で解放できる。

構造体のメンバーの値は普通にメンバー名のメソッドで取り出せる。便利!

文字列や構造体でないポインタ

文字列や構造体ではなく int のような型のポインタを扱うには Fiddle::Pointer を使う。

手頃な C の関数が見つからなかったので自作。 これは引数で指定されたポインタが指す int の値を2倍するだけの関数:

void hoge(int *n)
{
  *n = *n * 2;
}

これを次のようにして共有ライブラリを作っておいて:

gcc -shared -fPIC -o hoge.so hoge.c

こんな風に使う:

require 'fiddle/import'
module Hoge
  extend Fiddle::Importer
  dlload './hoge.so'
  extern 'void hoge(int *n)'
end
n = 123
buf = [n].pack('i')         # C の int のバイト列のバッファを作って
ptr = Fiddle::Pointer[buf]  # ポインタ化する
Hoge.hoge(ptr)
p buf.unpack1('i')          # バッファ内のバイト列を int とみなして数値化する
#=> 246

ポインタが指すバッファやその中の表現は自力でなんとかする必要がある。ちょっと面倒。

C での表現とはちょっとずれちゃうけど、struct を使ったほうが簡単かもしれない:

require 'fiddle/import'
module Hoge
  extend Fiddle::Importer
  dlload './hoge.so'
  extern 'void hoge(int *n)'
  Int = struct(['int n'])
end
int = Hoge::Int.malloc(Fiddle::RUBY_FREE)
int.n = 123
Hoge.hoge(int)
p int.n    #=> 246

数値の変換もやってくれて便利。 Fiddle はポインタはただのアドレスで、int のポインタか構造体のポインタかなんて気にしてないのでこういうことができる。

戻り値がポインタ

ポインタを返す関数を呼ぶと、Fiddle::Pointer のオブジェクトが返る。

require 'fiddle/import'
module C
  extend Fiddle::Importer
  dlload 'libc.so.6'
  extern 'char *strdup(const char *s)'
end
ptr = C.strdup('hoge')  #=> #<Fiddle::Pointer>
p ptr.size  #=> 0
p ptr.to_s  #=> "hoge"
Fiddle.free ptr

strdup() は NUL 終端文字列を返すけど、Fiddle はそんなこと知らないので、ポインタが指す先のサイズは不明ってことで 0 になってる。

Fiddle::Pointer#to_s を使うと NUL までのデータを文字列オブジェクトとして返してくれる。 NUL 終端されてないバッファのポインタに対して to_s すると確保されたメモリ外のデータまで読もうとしてたぶん落ちるので注意。

strdup()free() しないといけないので、ちゃんと Fiddle.free を呼んでおくこと。

MySQL

libmysqlclient を使って MySQL にアクセスしてみるとこんな感じ。

require 'fiddle/import'
module Mysql
  extend Fiddle::Importer
  dlload 'libmysqlclient.so.21'
  typealias 'MYSQL_ROW', 'char **'
  extern 'MYSQL * mysql_init(MYSQL *mysql)'
  extern 'MYSQL *mysql_real_connect(MYSQL *mysql, const char *host, const char *user, const char *passwd, const char *db, unsigned int port, const char *unix_socket, unsigned long clientflag)'
  extern 'int mysql_query(MYSQL *mysql, const char *q)'
  extern 'MYSQL_RES *mysql_store_result(MYSQL *mysql)'
  extern 'MYSQL_ROW mysql_fetch_row(MYSQL_RES *result)'
  extern 'void mysql_free_result(MYSQL_RES *result)'
end

m = Mysql.mysql_init(nil)
Mysql.mysql_real_connect(m, 'localhost', 'hoge', 'hogehoge', 'test', 0, nil, 0)
Mysql.mysql_query(m, 'select 123, 456')
res = Mysql.mysql_store_result(m)
rowp = Mysql.mysql_fetch_row(res)
MysqlRow = Fiddle::Importer.struct(['void *col1', 'void *col2'])
row = MysqlRow.new(rowp)
p row.col1.to_s        #=> "123"
p row.col2.to_s        #=> "456"
Mysql.mysql_free_result(res)

普通に C でプログラムするのと同じような感じ。オブジェクト指向っぽくはない。

MYSQL_ROW の実体は char ** でポインタのポインタは扱いにくいので structMysqlRow を作ってる。


こんな感じで、Ruby にライブラリが用意されてない大きな C ライブラリの一部をつまみ食いするには Fiddle は便利。

MySQL はちゃんと mysql2ruby-mysql があるのでそれを使いましょう :-)

rkremap: キーボードデバイスの自動検出

https://blog.tmtms.net/entry/202201/rkremap の続き。

blog.tmtms.net

Rkremap.new 時に引数でキーボードデバイスファイルを指定しないといけなかったんだけど、USB や Bluetooth キーボードとかデバイスファイル名がわからない場合に調べるのが面倒なので自動検出するようにしてみた。

入力デバイスの種類の取得

/dev/input/event* に対して ioctl(EVIOCGBIT(0)) をすれば入力デバイスの種類が得られる。

こんな感じ:

#include <linux/input.h>
#include <stdio.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
  unsigned char type[(EV_MAX-1)/8+1];
  int fd = open("/dev/input/event3", O_RDONLY);
  ioctl(fd, EVIOCGBIT(0, sizeof(type)), type);
  for (int i=0; i<sizeof(type); i++) {
    printf("%02x ", type[i]);
  }
  puts("");
}

この typeEV_* ビット目が立ってればその種類のデバイスということになるらしい。 EV_* 定数は /usr/include/linux/input-event-codes.h に定義されている。

ThinkPad T460s 本体のキーボード(/dev/input/event3)だと 13 00 12 00 という出力になった。 並び替えて2進数で表すと 0000 0000 0001 0010 0000 0000 0001 0011 となり、対応するビットは、0, 1, 4, 17, 20 なので、EV_SYN, EV_KEY, EV_MSC, EV_LED, EV_REP となる。

EV_KEY が含まれてるので、キーを持つデバイスだということがわかる。

Ruby で同じようなことをするにはこんな感じ:

EV_KEY = 1
EVIOCGBIT_0 = 2147763488     # EVIOCGBIT(0, 4)
f = File.open('/dev/input/event3')
buf = ''
f.ioctl(EVIOCGBIT_0, buf)
p buf[0, 4].unpack('C*').map{'%02X'%_1}.join(' ')

キーデバイスがキーボードかどうかの判定

ただ、この EV_KEY はキーボードだけじゃなくてキー(ボタン?)を持つデバイス全般が該当するらしい。 ThinkPad で evtest コマンドで調べると EV_KEY はほかにもあって、たとえば event1KEY_SLEEP だけを持つ Sleep Button で、event2KEY_POWER だけを持つ Power Button、タッチパッド(event15 Synaptics TM3145-003) やトラックポイント(event16 TPPS/2 IBM TrackPoint)も EV_KEY だった。

rkremap の対象は普通のキーボードデバイスなので、0, 9, A, Z, SPACE キーがあるデバイスを対象にした。

キーデバイスが対応しているキーを調べるには、ioctl(EVIOCGBIT(EV_KEY)) で得られた値に対してデバイスに対応する KEY_* ビットが立っているかどうかで判定できる。

#include <linux/input.h>
#include <stdio.h>
#include <fcntl.h>

int capable(unsigned char key[], int code)
{
  return (key[code/8] >> (code%8)) & 1;
}

int main(int argc, char *argv[])
{
  unsigned char key[(KEY_MAX-1)/8+1];
  int fd = open("/dev/input/event3", O_RDONLY);
  ioctl(fd, EVIOCGBIT(EV_KEY, sizeof(key)), key);
  printf("KEY_0=%d\n", capable(key, KEY_0));
  printf("KEY_9=%d\n", capable(key, KEY_9));
  printf("KEY_A=%d\n", capable(key, KEY_A));
  printf("KEY_Z=%d\n", capable(key, KEY_Z));
  printf("KEY_SPACE=%d\n", capable(key, KEY_SPACE));
}

Ruby だとこんな感じ:

EVIOCGBIT_EV_KEY = 2153792801  # EVIOCGBIT(0, 96)
EV_KEY = 1
KEY_0 = 11
KEY_9 = 10
KEY_A = 30
KEY_Z = 44
KEY_SPACE = 57
def capable?(code)= @key[code/8][code%8] != 0
f = File.open('/dev/input/event3')
buf = ''
f.ioctl(EVIOCGBIT_EV_KEY, buf)
@key = buf.unpack('C*')
puts "KEY_0=#{capable? KEY_0}"
puts "KEY_9=#{capable? KEY_9}"
puts "KEY_A=#{capable? KEY_A}"
puts "KEY_Z=#{capable? KEY_Z}"
puts "KEY_SPACE=#{capable? KEY_SPACE}"

/dev/input/event* を一つずつ上記のように調べてキーボードかどうかを調べることができる。

キーボードデバイスが複数の場合もあるので、IO.select で複数の入力を待つようにした。これで、本体のキーボードでCtrlキーを押しながら、USBキーボードでAキーを押すみたいなのにも対応できた。

参考にしたもの等


現状では rkremap 起動後にキーボードデバイスが追加/削除されたのには対応できないもどうにかしたいなぁ…。

Linux用キーリマッパー rkremap を作った

11月から仕事で Mac を使うようになって2ヶ月ちょっとたつけど、いまだにショートカットキーが Ctrl キーではなく Command キーであることに慣れない。

慣れないのは仕事以外で普段使ってる Linux と異なるからだと思うんだけど、普通に考えて Mac のショートカットキーの方が合理的だと思うので、Linux 上で Mac と同じような操作ができるようにした方が良いと思った。

というわけで Ruby で rkremap というのを作ってみた。rkremap はツールではなくライブラリなので、rkremap を使ったプログラムを作る必要がある。

github.com

まあ普通は「最強のキーリマッパー」の xremap を使うのがいいと思う。

作ろうと思ったのは xremap では(たぶんほかのツールも)日本語変換有効時を特別扱いできなかったのが発端なんだけど、YAML 等の設定ファイルを書くよりもプログラムで動きを書きたかったのでライブラリとして実装してみた。

以下、rkremap の使い方と背景の仕組みなど。

キーボード入力デバイスファイルを調べる

デバイスファイルは /dev/input/event[0-9]*。 キーボードに対応するデバイスは cat /proc/bus/input/devices でわかるかもしれない。 ThinkPad 本体のキーボードは event3 だった。

I: Bus=0011 Vendor=0001 Product=0001 Version=ab54
N: Name="AT Translated Set 2 keyboard"
P: Phys=isa0060/serio0/input0
S: Sysfs=/devices/platform/i8042/serio0/input/input3
U: Uniq=
H: Handlers=sysrq kbd event3 leds 
B: PROP=0
B: EV=120013
B: KEY=402000000 3803078f800d001 feffffdfffefffff fffffffffffffffe
B: MSC=10
B: LED=7

sudo evtest /dev/input/event3 のようにしてキーを押して反応があればそのデバイスであることがわかる。

キーロガー

/dev/input/eventX ファイルを読むことでそのデバイスのキーイベントを読み取れる。 一般ユーザーには読み取る権限がないので rootinput グループに所属しているユーザーである必要がある。

詳しくは 1.1. Introduction — The Linux Kernel documentation を。

rkremap を使うとこんな感じでキー入力を読める:

require 'rkremap'
def code2key(code)
  Rkremap::CODE_KEY[code].to_s.sub(/\AKEY_/, '')
end
rk = Rkremap.new('/dev/input/event3')
rk.start do |code, mod|
  key = (mod.select{|_, v| v}.keys + [code]).map{|c| code2key(c)}.join('-')
  puts key
end

この場合はキーイベントは本来のアプリに渡されるのでアプリに影響を与えることなく、キー入力を読み取ることができる。

start のブロックは修飾キー以外のキーが押されたときに実行される。 ブロックの第1引数は押されたキーのコード(Integer)。Rkremap::CODE_KEY でシンボルに変換できる。 第2引数は修飾キーの Hash

「hoge!」と入力したときの出力:

H
O
G
E
LEFTSHIFT-1

通常使用しているユーザーを input グループに所属させると sudo とかの手間はなくていいんだけど、簡単にキーロガーを動かせちゃうので、セキュリティ的にはちょっと考えた方がいいかも。

Xアプリ名の取得

特定のアプリでだけ特殊な処理を行いたいとかで入力フォーカスがあるアプリの名前を知りたいことがある。

X11 の XGetInputFocus() でフォーカスしてる Window を得られる。 この Window はアプリのウィンドウではなくてアプリを構成している部品のようなもので、アプリのウィンドウを得るには、XGetClassHint() でクラス名を得られるまで XQueryTree() で親 Window を辿る。

…ということを Rkremap::App(Rkremap#start のブロックの第3引数)でできるようにしてある。ウィンドウタイトルは #title, クラス名は #class_name で得られる。

Ruby から使える X11 ライブラリにいいのが見つからなかったので、ffi を使って実装した。X11 みたいな巨大なライブラリの一部をつまみ食いするには ffi は便利。

この機能を使用するには Rkremap#x11 = true を設定する。

上のキーロガーの出力にアプリ名を追加するにはこんな感じ:

require 'rkremap'
def code2key(code)
  Rkremap::CODE_KEY[code].to_s.sub(/\AKEY_/, '')
end
rk = Rkremap.new('/dev/input/event3')
rk.x11 = true
rk.start do |code, mod, app|
  key = (mod.select{|_, v| v}.keys + [code]).map{|c| code2key(c)}.join('-')
  key << " at #{app.title} [#{app.class_name}]" if rk.x11
  puts key
end

Firefox 上の Google で「ほげ」を検索したときの出力:

HENKAN at Google — Mozilla Firefox [Firefox]
H at Google — Mozilla Firefox [Firefox]
O at Google — Mozilla Firefox [Firefox]
G at Google — Mozilla Firefox [Firefox]
E at Google — Mozilla Firefox [Firefox]
ENTER at Google — Mozilla Firefox [Firefox]
MUHENKAN at Google — Mozilla Firefox [Firefox]

仮想キーボードデバイス

/dev/uinput を使って仮想キーボードデバイスを作ることができる。

ioctl(UI_SET_EVBIT, EV_KEY) でキーボードデバイスを指定し、ioctl(UI_SET_KEYBIT) で入力可能なキーを指定し、ioctl(UI_DEV_SETUP) でデバイスのベンダーや製品バージョンを指定して、ioctl(UI_DEV_CREATE) で作るって感じ。

作った仮想デバイスに対してイベントデータを書き込むことでキーを入力したことにできる。

詳しくは 1.7. uinput module — The Linux Kernel documentation を。

rkremap は Rkremap#key でキーを押したことにできる。

以下は1秒毎に A〜Z のキーを入力する:

require 'rkremap'
rk = Rkremap.new("/dev/input/event3")
while true
  ('A'..'Z').each do |k|
    key = Rkremap::KeyCode.const_get("KEY_#{k}")
    rk.key(key)
    sleep 1
  end
end

キーリマップ

/dev/input/eventX に対して ioctl(EVIOCGRAB, 1) を設定するとキー入力イベントを奪い取ってアプリに入力が渡らなくなる。

そして読み取ったキーイベントに対して何かしらの処理後に /dev/uinput に書くことでキーイベントを書き換えることができる。

rkremap で、Firefox 上での Ctrl-N に置き換えるにはこんな感じ:

require 'rkremap'
include Rkremap::KeyCode
rk = Rkremap.new("/dev/input/event3")
rk.grab = true
rk.x11 = true
rk.start do |code, mod, app|
  if app.class_name == 'Firefox' &&  code == KEY_N && (mod[KEY_LEFTCTRL] || mod[KEY_RIGHTCTRL])
    mod[KEY_LEFTCTRL] = mod[KEY_RIGHTCTRL] = false
    code = KEY_DOWN
  end
  rk.key(code, mod)
end

ということで自分が使うために作ったプログラムはこちら https://github.com/tmtm/rkremap/blob/master/example/tmtms.rb

元々の目的だった Mac 風のショートカットにするために、Alt+英字/EnterCtrl に変換してるほか、CapsLockCtrl として扱ったり、変換/無変換 を長押しすると Alt として扱ったり、日本語キーボードを英語キーボードとして認識させたときのキー配置を入れ替えてたりなどしている。

とりあえず動くところまでできたので、これでリリース。

参考にしたもの等

ruby-mysql

これはMySQLアドベントカレンダーRubyアドベントカレンダーの12日目の記事です。

qiita.com qiita.com

ruby-mysql は Ruby だけで書かれた MySQL 用のクライアントライブラリです。 今は Ruby から MySQL を使う場合は普通は mysql2 を使うだろうから、たぶん誰も使ってない。

誰も使ってないだろうし、6年ほど放置してたんだけど、なぜかその気になったのでまたいじり始めた。退職前の有給消化期間で暇だったからかも。

MySQL 8.0 対応

MySQL 8.0 でデフォルトの認証方式が変更になって、そのままでは接続できなくなったので対応。

認証方式はユーザーごとに異なる場合があるので、サーバーのデフォルト認証方式、クライアントのデフォルト認証方式、ユーザーの認証方式が異なっていた場合のプロトコルに対応。

とりあえず、MySQL 5.7 でデフォルトの mysql_native_password と MySQL 8.0 でデフォルトの caching_sha2_password,と、あと sha256_password に対応してみた。

あと、「MySQL 8.0のcaching_sha2_password + 非SSL接続が転ける」ので、TLS 接続にも対応。

caching_sha2_passwordsha256_password は、接続が TLS の場合は特にハッシュ化せずにそのまま送るようになってる。合理的。

認証プロトコルについては「MySQL の認証プロトコル」に書いた。

Ruby ぽい API

ruby-mysql は libmysqlclient の C API に合わせて作ったので、Ruby らしくないところがあった。 たとえば、MySQL サーバーに接続しないでオブジェクトを作るには Mysql.init とか。普通は Mysql.new ですよねぇ。

今まで Mysql.new は接続までしていたが、それはやめてオブジェクトを作るだけにした。 Mysql.connect がオブジェクト生成&接続をするのはかわらない。

接続パラメータは new でも connect でも指定可

m = Mysql.new('hostname', 'user', 'passwd', 'dbname')
m.connect

m = Mysql.new
m.connect('hostname', 'user', 'passwd', 'dbname')

m = Mysql.connect('hostname', 'user', 'passwd', 'dbname')

Mysql.connect() を使ってれば互換はあるはず。

URI でも指定できるようにした

m = Mysql.connect('mysql://user:passwd@hostname/dbname')

uri = URI.parse('mysql://user:passwd@hostname/dbname')
m = Mysql.connect(uri)

Hash やキーワード引数もOK

m = Mysql.connect(host: 'hostname', username: 'user', password: 'passwd', database: 'dbname')

m = Mysql.connect({host: 'hostname', username: 'user', password: 'passwd', database: 'dbname'})

接続用のオプションも

m = Mysql.init
m.options(Mysql::OPT_LOCAL_INFILE, true)
m.connect('hostname', 'user', 'passwd', 'dbname')

みたいにしないといけなかったのを

m = Mysql.new
m.local_infile = true
m.connect('hostname', 'user', 'passwd', 'dbname')

みたいに書ける。 オプションは newconnect 時のキーワード引数や URI のクエリパラメータでも指定可能。

m = Mysql.new('hostname', 'user', 'passwd', 'dbname', local_infile: true)

m = Mysql.new(host: 'hostname', username: 'user', password: 'passwd', database: 'dbname', local_infile: true)

m = Mysql.new('mysql://user:passwd@hostname/dbname?local_infile=true')

Ruby っぽい!(たぶん)

あと、ストアドプロシジャで 0000-00-00 みたいな不正な日付値と、TIME値(日付なしの時刻値)を扱うために Mysql::Time があったんだけど廃止して Time を使うようにした。 不正な日付値は nil として返す。今どきは不正は日付を使うことはないだろうから別に問題ないだろう。 TIME値は秒換算の Numeric を使うようにした。

こんなテーブルがあった場合

mysql> select * from test.t;
+------------+---------------------+-----------+
| date       | datetime            | time      |
+------------+---------------------+-----------+
| 2021-12-12 | 2021-12-12 01:23:45 | 01:23:45  |
| 0000-00-00 | 0000-00-00 01:23:45 | -01:23:45 |
+------------+---------------------+-----------+
2 rows in set (0.00 sec)

こうなる

irb(main):002:0> m.query("select * from test.t").entries
=> 
[["2021-12-12", "2021-12-12 01:23:45", "01:23:45"],
 ["0000-00-00", "0000-00-00 01:23:45", "-01:23:45"]]
irb(main):003:0> m.prepare("select * from test.t").execute.entries
=> 
[[2021-12-12 00:00:00 +0900, 2021-12-12 01:23:45 +0900, 5025.0],
 [nil, nil, -5025.0]]

古い Ruby は非対応

Ruby 2.5 未満は非対応。さすがにもういいだろうってことで。 Encoding がない Ruby 1.8 用のコードとかが残ってたので削除したり。

MySQL で非推奨の API は削除

list_dbs, list_tables, list_fields, list_processes など。 これらの情報は SHOW DATABASES, SHOW TABLES, SHOW COLUMNS, SHOW PROCESSLIST で取得すればいい。

あと、真偽値を返すメソッドは ? ありと無しの2つあったけど、無しなのは削除。

GitHub Actions でテスト

GitHub Actions で自動化っぽいことができそうだったので、テストを実行するようにしてみた。

git push するたびに Ruby 2.5, 2.6, 2.7, 3.0 と MySQL 5.5, 5.6, 5.7, 8.0 の組み合わせでテストする。 (全然関係ないけど、Ruby と MySQL のバージョンの飛び方が似てるな)

mysql2

mysql2 は MySQL の C API である libmysqlclient を使ってるんだけど、この ruby-mysql を使うようにすれば面白いかもなーと思って作業中。

目標は mysql2 のテストコードをパスすること。いざやってみると ruby-mysql のバグがいくつか見つかったりして、mysql2 のテストコード便利。

ほんとはこのアドベントカレンダーを書くまでに作りたかったんだけど、終わらなかった。残念…。

Ruby の Socket.tcp を知った

Ruby で TCP のクライアントとしてサーバーに接続するには TCPSocket.open(TCPSocket.new)を使うもんだと思ってたんだけど、最近 Socket.tcp というのもあることを知った。

どうやら 1.9.2 で追加されたらしい。10年以上も気づかなかった…。サーバー側の Socket.tcp_server_loop も同じ時に追加されていてこっちは気づいてたんだけどなぁ。

TCPSocket.openSocket.tcp もクライアントとしてサーバーに接続するのでだいたい同じなんだけど、Socket.tcp の方は connect_timeout というキーワード引数で接続タイムアウト時間を指定できる(2.0.0 から)。 なお、2.7 から resolv_timeout というキーワード引数でホスト名解決のタイムアウト時間を指定できるようにもなっている…と思いきや、3.0 では無効になってた。短い命だった。

返すオブジェクトが TCPSocketSocket かの違いはある。けど、TCPSocket の方に addr, peeraddr というメソッドがある程度の違い。

TCPSocket.instance_methods - Socket.instance_methods
 #=> [:addr, :peeraddr]

それに addr, peeraddr と同じ役割のメソッドは Socket の方にも local_address, remote_address として用意されている。

s = TCPSocket.open("google.com", 80)
s.addr
 #=> ["AF_INET6", 51432, "2405:6582:6260:1b00:f7c9:81c:93f6:b1bd", "2405:6582:6260:1b00:f7c9:81c:93f6:b1bd"]
s.peeraddr
 #=> ["AF_INET6", 80, "2404:6800:4004:818::200e", "2404:6800:4004:818::200e"]

s = Socket.tcp("google.com", 80)
s.local_address
 #=> #<Addrinfo: [2405:6582:6260:1b00:f7c9:81c:93f6:b1bd]:51444 TCP>
s.remote_address
 #=> #<Addrinfo: [2404:6800:4004:81d::200e]:80 TCP>

local_address, remote_address の方は配列じゃなくてちゃんとオブジェクトを返すので良さそう。


と、ここまで書いて TCPSocket のソースを読んでみたら、TCPSocket.open の方にも connect_timeout というキーワード引数が増えていた。3.0 で追加されたらしい。

というわけで、まあどっちでも好きな方を使えばいいんだけど、個人的には Socket.tcp の方が良さそうな気がしてるので、今後は Socket.tcp を使おうと思う。

Ruby Net::SMTP

Ruby Net::SMTP

これは 富士通クラウドテクノロジーズ Advent Calendar 2020FUJITSU Advent Calendar 2020の 5日目の記事です。

会社のアドベントカレンダーですが、記事の内容は会社とは関係ありません。

nagano.rb #6 で発表したネタです。


SMTP

SMTP は Simple Mail Transfer Protocol の略でメールを送信するためのプロトコルです。

RFC の変遷:

メールメッセージの形式(Internet Message Format)の RFC もセットで発行されていて、SMTP の次の番号が割り当てられてます:

ポート番号は 25番で smtp という名前が割り当てられています。

テキストプロトコルなので、人も喋ることができます(DATA から . までの形式が RFC 5322)。

S: 220 smtp.example.com ESMTP Postfix</span>
C: EHLO client.example.net</span>
S: 250-smtp.example.com
   250-PIPELINING
   250-SIZE 102400000
   250-VRFY
   250-ETRN
   250-STARTTLS
   250-AUTH DIGEST-MD5 NTLM CRAM-MD5 PLAIN LOGIN
   250-ENHANCEDSTATUSCODES
   250-8BITMIME
   250-DSN
   250 SMTPUTF8
C: MAIL FROM:<sender@example.com>
S: 250 2.1.0 Ok
C: RCPT TO:<rcpt1@example.com>
S: 250 2.1.5 Ok
C: RCPT TO:<rcpt2@example.com>
S: 250 2.1.5 Ok
C: DATA
S: 354 End data with <CR><LF>.<CR><LF>
C: From: sender@example.com
   To: rcpt1@example.com
   Cc: rcpt2@example.com
   Subject: test
   
   message body
   .
S: 250 2.0.0 Ok: queued as F074F9FB0E
C: QUIT
S: 221 2.0.0 Bye

間違ってる人がわりといるようなのですが、MAIL FROM:< の間に空白は入りません。また、メールアドレスは < > で括る必要があります。 実際には空白があったり < > が無くてもエラーにしないサーバーが多いですが、たまにプロトコル違反としてエラーにするサーバーがあったりします。

メール送受信

昔は、メールサーバーは誰から送られた誰宛のメールでも受け取って正しい送り先に転送していたようです。

ですが、送信元が詐称されたり、迷惑メールの送信に利用されたりするようになり、

現在は、

  • 信頼できるクライアントはどこ宛でもOK
  • 認証が通ったらどこ宛でもOK
  • それ以外は自分宛であればOK

という設定がされるのがふつうだと思います。

ただし、これでも自サーバー宛に直接送りつけてくるやつには対処できません。

プロバイダー側の対策として、Outbound Port 25 Blocking (OP25B) というのが導入されました。 これはプロバイダーが外向けの25ポートをブロックすることで、プロバイダーが用意したメールサーバー経由でしか外部にメールを送れないようにするものです。 多くのプロバイダーが導入し、これによりプロバイダー配下のネットワークから外部のメールサーバーに直接接続することはできなくなりました。

そして受信と送信(中継)が分離されるようになりました。

受信(MX)

  • 自分宛のメールを受けつけるため
  • TCP 25番ポート(smtp)
  • DNS の MX レコードでサーバーを指定
  • 一般利用者からは接続されない
  • 怪しいクライアントは拒否 (設定次第)
    • 送信者ドメインのSPFに登録されているか
    • IPアドレスを逆引き&正引きして元のIPアドレスになるか
    • EHLO名がDNS上に存在しているか

送信(中継)

  • 外部に送信するため
  • TCP 587番ポート(submission)
  • TCP 465番ポート(smtps, submissions)
  • どこ宛でもOK
  • 信頼できるクライアントからしか受け付けない
    • 認証が通ったクライアント
    • ローカルネットワークのクライアント

SMTP認証

メール送信時に認証が必要な場合には SMTP の AUTH 命令を使用します。

C: AUTH PLAIN dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ=
S: 235 2.7.0 Authentication successful

一見わけのわからない文字列になってますが、PLAIN はユーザー名とパスワードをBase64 しただけの平文です。

PLAIN 以外の認証方式、たとえば CRAM-MD5 等を使えば Challenge-Response 方式の暗号化もできますが、サーバー内に平文のパスワードを保持しておかないといけないのがイマイチです。

まあ、パスワードだけ暗号化したとしてもメール本文が平文なので盗み見される可能性はありますし。

通信暗号化(STARTTLS)

ということで通信経路を暗号化しましょう。

SMTP 接続後に STARTTLS 命令を発行するとそれ以降 TLS での暗号化通信になります。 なお、465番ポートは HTTPS と同じように接続時から TLS 通信です。

S: 220 smtp.example.com ESMTP Postfix
C: EHLO client.example.net
S: 250-smtp.example.com
   250-STARTTLS              ← EHLO の応答に STARTTLS が含まれてれば使用可
   ...
C: STARTTLS
S: 220 2.0.0 Ready to start TLS
--- ここから TLS 通信 ---
C: EHLO client.example.net
S: 250-smtp.example.com
   ...
C: AUTH PLAIN dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ=
S: 235 2.7.0 Authentication successful

暗号化通信は手動ではできないので手で SMTP を叩きたい場合は openssl を使います。

% openssl s_client -connect smtp.example.com:587 -starttls smtp
(STARTTLS まで自動でやってくれる)
--- ここから手で入力したものが TLS 通信でサーバーに送られる ---
C: EHLO client.example.net
S: 250-smtp.example.com
   ...

TLS証明書の検証

openssl はデフォルトでは証明書の検証をしないので、オレオレ証明書とか期限切れ証明書もスルーするのですが、エラーにしたい場合は -verify_return_error をつけます。

% openssl s_client -connect smtp.example.com:587 -starttls smtp -verify_return_error
...
Verification error: certificate has expired
--- 
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Secure Renegotiation IS NOT supported
Compression: NONE
Expansion: NONE
No ALPN negotiated
Early data was not sent
Verify return code: 10 (certificate has expired)
--- 
% 

証明書ホスト名の検証

証明書が正当でも自分がアクセスしてるサーバー用の証明書じゃない可能性もあります。 証明書内のホスト名を検証したい場合は -verify_hostname をつけます。

% openssl s_client -connect smtp.example.com:587 -starttls smtp -verify_return_error \
  -verify_hostname smtp.example.com

Ruby での SMTP

ここから本題。

Net::SMTP

Ruby で SMTP を使うには net/smtp ライブラリを使用します。めっちゃ簡単に使えます。

require 'net/smtp'

Net::SMTP.start('smtp.example.com', 25) do |smtp|
  smtp.send_message(<<EOS, 'sender@example.com', 'rcpt1@example.com', 'rcpt2@example.com')
From: sender@example.com
To: rcpt1@example.com
Cc: rcpt2@example.com
Subject: test

message body
EOS
end

SMTP認証

SMTP 認証も使用できます。

Net::SMTP.start('smtp.example.com', 587, 'client.example.net',
                'username', 'password') do |smtp|
  ...
end

ユーザー名とパスワードを指定したいだけなのに EHLO 名を書かないといけないのがちょっとイマイチです。

認証が必要なのは送信サーバーでメールを送るときなので、その場合は EHLO 名は重要ではないはずです。

そしてデフォルトの認証方式は PLAIN、つまり平文なんですが、デフォルトでは TLS は使われません 😇

STARTTLS

STARTTLS も簡単に使えるのですが、Net::SMTP.start ではダメで、Net::SMTP.new しないとダメなところがビミョーな感じです。

smtp = Net::SMTP.new('smtp.example.com', 587)
smtp.enable_starttls
smtp.start('client.example.com', 'username', 'password') do
  ...
end

465番ポートのように STARTTLS ではなく接続時から TLS したい場合は enable_starttls ではなく enable_tls を使います。

smtp = Net::SMTP.new('smtp.example.com', 465)
smtp.enable_tls
smtp.start('client.example.com', 'username', 'password') do
  ...
end

なお、 enable_starttlsenable_tls の両方を指定するとエラーになります。

証明書の検証

Net::SMTP はデフォルトでは TLS 証明書を検証しません。オレオレ証明書や期限切れ証明書でもスルーします。

証明書を検証するには次のようにします。

smtp = Net::SMTP.new('smtp.example.com', 587)
context = OpenSSL::SSL::SSLContext.new
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
smtp.enable_starttls(context)
smtp.start('client.example.com', 'username', 'password') do
  ...
end

OpenSSL ライブラリの使い方を知らないといけなくて、かなりダメな感じになってきましたね…。

証明書ホスト名の検証

Net::SMTP は、デフォルトでは証明書の検証をしないのに、なぜかホスト名の検証をしてるという妙な挙動をします。

そして、常に .new() または .start() の第1引数の文字列を使うので、テスト的に別のサーバー名を使うことはできません。

IPアドレスで接続するとホスト名の不適合でエラーになります。

smtp = Net::SMTP.new('192.168.1.2', 587)
context = OpenSSL::SSL::SSLContext.new
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
smtp.enable_starttls(context)
smtp.start('client.example.com', 'username', 'password')
#=> hostname "192.168.1.2" does not match the server
#   certificate (OpenSSL::SSL::SSLError)

Net::SMTP 改造

ということで、このイマイチなところをどうにかしたくて、https://github.com/ruby/net-smtp/ のコミット権をもらっていろいろいじってみました。

キーワード引数化

引数をキーワード引数化しました。

Net::SMTP.start(hostname, port, helo_name, username, password, authtype)

Net::SMTP.start(hostname, port, helo: helo_name,
                user: username, password: password, authtype: authtype)

これで EHLO 名を指定しなくても認証情報を指定できるようになりました。

Net::SMTP.start('smtp.example.com', 587,
                user: 'username', password: 'password') do |smtp|
  ...
end

デフォルトで STARTTLS を使用 [非互換]

サーバーが対応していれば自動的に STARTTLS を使用するようにしました。

EHLO の応答に STARTTLS があれば、とくに何も指定しなくても STARTTLS を使用します。

Net::SMTP.start(hostname, port) do |smtp|
  ...
end

ただし、STARTTLS を使用したくない場合はちょっと面倒です。

smtp = Net::SMTP.new(hostname, port)
smtp.disable_starttls
smtp.start { ... }

テスト環境とかで証明書がちゃんと設定されてないのに EHLOSTARTTLS を返すような環境ではエラーになってしまうかもしれないという意味で非互換です。

デフォルトで証明書を検証 [非互換]

証明書の検証もデフォルトで行います。これもオレオレ証明書等を使ってるような環境でエラーになってしまうので非互換です。

証明書を検証したくない場合用に tls_verify キーワード引数を追加しました。

Net::SMTP.start(hostname, port, tls_verify: false) { ... }

ホスト名の検証

証明書を検証しない時にもホスト名を検証するというバグっぽい挙動は修正しました。 tls_verify: false 時にはホスト名の検証は行いません。

そして接続に使用した名前と異なるホスト名で検証したい場合のために tls_hostname キーワード引数を追加しました。

こんな風に書けます。

Net::SMTP.start('192.168.1.2', 587, tls_hostname: 'smtp.example.com') { ... }

net-smtp gem

net/smtp ライブラリは Ruby 2.7 から Gem になってるので、新しい net/smtp を使いたい場合は次のようにすれば使えます。

% gem install net-smtp

Ruby 2.7 より前は、gem をインストールしても標準添付ライブラリの net/smtp が使われてしまいます(むりやり標準添付ライブラリの net/smtp.rb を消したりすれば使えないこともないですが…)。

証明書の検証まわりに非互換があるので、注意して使ってください。

Ruby の Git リポジトリにも入ったので、特に問題が発生しなければ Ruby 3.0 ではこの新しい net/smtp が標準になると思います。