Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Ruby: 文字列ミューテーションの警告を消すためのメッセージ組み立て専用クラスを作った(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

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の非推奨警告を見逃してはいけないと教わったおかげで、早いうちに気づけました。

Rails: Rubyの非推奨警告はデフォルトで表示されないことを見落としていませんか?(翻訳)

本記事では、文字列リテラルをfrozenにするメリットについては解説しませんが、ご興味のある方は上述のfxnによる解説記事を注意深くお読みください。さらに深く知りたい方は、byrootによる以下の記事もどうぞ。

Ruby: frozen_string_literalの歴史と現状、未来を考察する(翻訳)

🔗 コード内の文字列を改変すると何がまずいのか

とある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

このリファクタリングで得られたもの:

  1. 文字列リテラルのミューテーションが解消されたおかげで、Ruby 3.4で警告が表示されなくなっただけでなく、今後問題が発生することもなくなった。

  2. 繰り返しを減らしたことで、職人技に頼ってテキスト区切り文字を使う必要がなくなった。

  3. StringArrayも同じ<<メソッドや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_strto_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警告は表示されなくなった

関連記事

Ruby: frozen_string_literalの歴史と現状、未来を考察する(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。