Ruby: 文字列ミューテーションの警告を消すためのメッセージ組み立て専用クラスを作った(翻訳)
最近になって、かなり大規模なRailsアプリケーションを最新のRuby 3.4にアップグレードする作業に取り掛かったときに、以下のような文字列リテラルのミューテーション(mutation: 改変)に関する警告が大量に発生していることに気づきました。
warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
🔗 Rubyにはミュータブルな文字列とイミュータブルな文字列がある
これについてはfxnの解説記事をご覧ください。
Ruby 3.4では、ファイルにマジックコメントが書かれていない状態で、リテラルとしてインスタンス化された文字列が改変されると、警告を表示するようになった(改変は引き続き許される)。
私の場合、同僚のPiotrから以下の記事でRubyの非推奨警告を見逃してはいけないと教わったおかげで、早いうちに気づけました。
本記事では、文字列リテラルをfrozenにするメリットについては解説しませんが、ご興味のある方は上述のfxnによる解説記事を注意深くお読みください。さらに深く知りたい方は、byrootによる以下の記事もどうぞ。
🔗 コード内の文字列を改変すると何がまずいのか
とある1個のモジュールが、文字列リテラルが今後frozenに変更されるという非推奨メッセージを大量に発生していることに気づきました。そのモジュールは、Slackで顧客サポートや請求や詐欺などに関連するメッセージの生成や配信を担当していて、本格的なビジネスを日々絶え間なく改善するうえで欠かせません。さまざまなチャネルに配信されるメッセージを表現するためのメソッドが軽く100個以上あります。
メッセージはいろんな方法で組み立てられていました。
module Slack
module Billing
BILLING_CHANNEL_NAME = 'billing'.freeze
extend self
def invoice_sent(invoice)
message = ':postbox: *Invoice sent to customer*'
message << " | #{invoice.customer_name}"
message << " | #{invoice.customer_email}"
message << " | <#{inovice.url}|#{invoice.number}>"
send_message(BILLING_CHANNEL_NAME, message)
end
def payment_received(payment, locale)
message = payment_text(payment, locale)
message.push("\n Invoice: #{payment.invoice_number}")
message.push("\n Customer: #{payment.customer_name}")
send_message(BILLING_CHANNEL_NAME, message)
end
private
def payment_text(payment, locale)
text = ':moneybag: *Payment Received*'
text << " | #{format_amount(payment.amount, locale)}"
text << " | #{payment.channel}"
text
end
def format_amount(amount, locale)
number_to_currency(amount, locale: locale)
end
def send_message(channel_name, message)
Client.deliver_message(channel: channel_name, message: message)
end
end
end
生成されたメッセージは以下のような感じになります。
:postbox: | *Inovice sent to customer* | Jane Doh | jan.doh@example.com | <https://fancyurl.example.com|KAKADUDU123>
:moneybag: *Payment Received* | $123.45 | Credit card
Invoice: KAKADUDU123
Customer: Jane Doh
🔗 パターンが見えてきた
さまざまなメッセージを配信するメソッドたちを精査したところ、ただちにパターンの存在に気づきました。「なるほど、これはArrayの助けを借りれば簡単に対処できそうだし、" | "や"\n"のように繰り返し出現しがちな場当たり的な文字装飾を改善できそう」
+ # frozen_string_literal: true
+
module Slack
module Billing
BILLING_CHANNEL_NAME = 'billing'
extend self
def invoice_sent(invoice)
- message = ':postbox: *Invoice sent to customer*'
- message << " | #{invoice.customer_name}"
- message << " | #{invoice.customer_email}"
- message << " | <#{inovice.url}|#{invoice.number}"
+ message = [':postbox: *Invoice sent to customer*']
+ message << invoice.customer_name
+ message << invoice.customer_email
+ message << "<#{inovice.url}|#{invoice.number}>"
- send_message(BILLING_CHANNEL_NAME, message)
+ send_message(BILLING_CHANNEL_NAME, message.join(" | "))
end
def payment_received(payment, locale)
- message = payment_text(payment, locale)
- message.push("\n Invoice: #{payment.invoice_number}")
- message.push("\n Customer: #{payment.customer_name}")
+ message = [payment_text(payment, locale)]
+ message.push("Invoice: #{payment.invoice_number}")
+ message.push("Customer: #{payment.customer_name}")
- send_message(BILLING_CHANNEL_NAME, messsage)
+ send_message(BILLING_CHANNEL_NAME, message.join("\n"))
end
private
def payment_text(payment, locale)
- text = ':moneybag: *Payment Received*'
- text << " | #{format_amount(payment.amount, locale)}"
- text << " | #{payment.channel}"
+ text = [':moneybag: *Payment Received*']
+ text << format_amount(payment.amount)
+ text << payment.channel
- text
+ text.join(" | ")
end
def format_amount(amount, locale)
number_to_currency(amount, locale: locale)
end
def send_message(channel_name, message)
Slack::Client.deliver_message(channel: channel_name, message: message)
end
end
end
このリファクタリングで得られたもの:
- 文字列リテラルのミューテーションが解消されたおかげで、Ruby 3.4で警告が表示されなくなっただけでなく、今後問題が発生することもなくなった。
-
繰り返しを減らしたことで、職人技に頼ってテキスト区切り文字を使う必要がなくなった。
-
StringもArrayも同じ<<メソッドやpushメソッドを提供しているので、文字列の組み立てに今後も同じメソッドを利用できる。
このコードの以前の実装を大幅に変更しなくても他のメンテナーたちがすぐ使いこなせるようにしたかったので、コードの形はできるだけ同じにしておきたいと思いました。
🔗 コードを改善する
コードベースの細かな部分でどんなことが行われているのかなんて、知りたくもありません。Arrayに関する内部実装をすべて公開するようなことは、メッセージの組み立て処理とは何の関係もないのですから、したくありません。
ここではごくシンプルな例をまず扱い、それを50倍に増やして、さらに複雑なメソッドにしていきます。
以下の処理を専門に行うオブジェクトを導入してみたらどうでしょうか。
- 文字列をイミュータブルに生成する
- 区切り文字の面倒な部分を完全に隠蔽する(
|で区切っているメッセージは100個以上もある) - 空文字列も正しく扱える
- 現在の実装に近いAPIを持つ
- 現在の実装に似た方法でメッセージを組み立てられる
それでは実装を見てみましょう。
# frozen_string_literal: true
module Slack
class Message
DELIMITER = ' | '
def initialize(*parts, delimiter: DELIMITER)
@delimiter = delimiter
@message = parts
end
def <<(message_part) = @message << message_part
def to_s = @message.compact_blank.join(@delimiter)
alias_method :to_str, :to_s
alias_method :push, :<<
end
end
🔗 Active Supportを使うメリット
ここで大事なのは、Railsを使っているおかげで、Active Supportでおいしい思いができることです。特に以下の点です。
compact_blankを明示的な形で使える-
blank?をObjectの拡張という暗黙の形で使える
これらがなければ、クラスで以下のように余分な頑張りが必要になったでしょう。
def to_s
@message
.compact
.reject { |part| part.respond_to?(:empty?) && part.empty? }
.join(@delimiter)
end
これらをprivateメソッドに切り出してもいいのですが、私はcompact_blankを明示的に使える点が好きなので、これで構いません。
🔗 1: 引数に1個または複数の文字列を渡す
*partsパラメータは、Rubyのsplat演算子*を使って@messageの下にある位置引数を自動的に配列にコレクションします。これによって、呼び出し側が引数を配列リテラル[]でわざわざ囲まなくてもコンストラクタが柔軟に対応してくれます。
Slack::Message.new('kaka').to_s
=> "kaka"
Slack::Message.new('kaka', 'dudu').to_s
=> "kaka | dudu"
🔗 2: メッセージを追加するメソッドが複数使える
message = Slack::Message.new('kaka')
message << 'dudu'
message.to_s
=> "kaka | dudu"
message = Slack::Message.new('kaka')
message.push 'dudu'
message.to_s
=> "kaka | dudu"
🔗 デフォルトの|区切り文字をカスタマイズ可能
Slack::Message.new('kaka', 'dudu').to_s
=> "kaka | dudu"
Slack::Message.new('kaka', 'dudu', delimiter: "\n").to_s
=> "kaka\ndudu"
🔗 区切り文字を変えてさまざまなSlack::Messageオブジェクトを組み立てる
Slack::Message.new('kaka', Slack::Message.new('dudu', 'foo', delimiter: " — ")).to_s
=> "kaka | dudu — foo"
msg = Slack::Message.new('kaka', delimiter: "\n")
msg << Slack::Message.new('dudu', 'foo', delimiter: ' ~ ')
msg.to_s
=> "kaka\ndudu ~ foo"
ここではto_strエイリアスメソッドによる魔法を使います。
@message.join(@delimiter)が呼び出されると、RubyのArray#joinは要素ごとに暗黙でto_strを呼び出します(未定義の場合は元のto_sにフォールバックします)。
to_strをto_sにエイリアスしているので、ネストしたSlack::Messageオブジェクトが自動的に文字列化されます。
このフラット化処理が再帰的かつ透過的に行われるのは、このオブジェクトが暗黙のコンテキストで文字列として扱われることをto_strがRubyに知らせてくれるからです。
🔗 最後のリファクタリング
# frozen_string_literal: true
module Slack
module Billing
BILLING_CHANNEL_NAME = 'billing'
extend self
def invoice_sent(invoice)
- message = [':postbox: *Invoice sent to customer*']
- message << invoice.customer_name
- message << invoice.customer_email
- message << "<#{inovice.url}|#{invoice.number}>"
+ message = Message.new(':postbox: *Invoice sent to customer*')
+ message << invoice.customer_name
+ message << invoice.customer_email
+ message << "<#{inovice.url}|#{invoice.number}"
- send_message(BILLING_CHANNEL_NAME, message.join(" | "))
+ send_message(BILLING_CHANNEL_NAME, message))
end
def payment_received(payment, locale)
- message = [payment_text(payment, locale)]
- message.push("Invoice: #{payment.invoice_number}")
- message.push("Customer: #{payment.customer_name}")
+ message = Message.new(payment_text(payment, locale), delimiter: "\n")
+ message.push("Invoice: #{payment.invoice_number}")
+ message.push("Customer: #{payment.customer_name}")
- send_message(BILLING_CHANNEL_NAME, messsage.join("\n"))
+ send_message(BILLING_CHANNEL_NAME, message)
end
private
def payment_text(payment, locale)
- text = [':moneybag: *Payment Received*']
- text << format_amount(payment.amount, locale)
- text << payment.channel
+ text = Message.new(':moneybag: *Payment Received*')
+ text << format_amount(payment.amount, locale)
+ text << payment.channel
- text.join(" | ")
+ text
end
def format_amount(amount, locale)
number_to_currency(amount, locale: locale)
end
def send_message(channel_name, message)
- Slack::Client.deliver_message(channel: channel_name, message: message)
+ Slack::Client.deliver_message(channel: channel_name, message: message.to_s)
end
end
end
単一目的のシンプルなクラスを作ったことがこれほど嬉しかったのは、もういつのことだったか思い出せないほどです。Rubyの真の美しさは、そうした瞬間に垣間見えるのです。
🔗 まとめ
Messageというクラス名のおかげで、「ここでは何らかのメッセージを組み立てている」という意図が明確に伝わっている- 区切り文字の使いこなしにあれこれ煩わされる必要がなくなる
- オブジェクトがさまざまな部品によってうまく組み立てられている
frozen string literal警告は表示されなくなった
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。