11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

久々に気持ちいいオブジェクト指向っぽいコードが書けたから共有したい

Last updated at Posted at 2021-08-11

皆さんはオブジェクト指向でコード書けてる実感ありますか?
クラスだのインターフェースだの、継承だのis-a、has-aだの難しそうな単語ばっかりで…
普通のWebアプリケーションを書き書きしているとあんまり勉強したことを実感したことありませんでした。
ただ、最近ちょうどオブジェクト指向っぽいコードが書けたので、そこだけ切り出して、ライブラリ化して、コードの解説をしようかなってのが話しの内容です。

コードを書くことになったきっかけ

Sentryというエラーがあったときに、エラーの詳細を記録してくれるサービスがありまして。
↓な感じにエラーがあったときに、リクエストパラメータをSentryに送信するようなロジックを書いてました。
あんまり話の本筋じゃないけど、該当のコードのドキュメント

puts params
#> {id: 99999, hoge: "hoge", fuga: "fuga"}

crumb = Sentry::Breadcrumb.new(
  request_parameter: params
)
Sentry.add_breadcrumb(crumb)

ただ、このコードはたまに動作しないという欠点がありまして…
base64で変換した文字列みたく、巨大な文字列を含むリクエストパラメータが含まれるとデータが保存されないというSentryの仕様があったのです。
いろんな回避策はあると思います。
今回は巨大な文字列はログとして不要という前提にしましょう。
解決策はSentryにデータを送信する前に巨大な文字列を削って対応しようってのが、コードを書くきっかけです。

どんなコードにしようか

さて、みなさんならどんなコードを書きますか?
問題を5分で終わらせて家に帰るコードはこんな感じかなと思います。

...
params = { id: 1234, hoge: "too_longs_string" }

max_string_length = 5
slimed_params = params.map do |key, value|
  if value.kind_of?(String)
    [key, value[0, max_string_length]]
  else
    [key, value]
  end
end.to_h
puts slimed_params
#> { id: 1234, hoge: "too_l" }
...

もちろん翌日にはこんな感じに改善しますよ

class StringHashDieter
  def self.lose_weight(heavy_hash)
    heavy_hash.map do |key, value|
      max_string_length = 5
      if value.kind_of?(String)
        [key, value[0, max_string_length]]
      else
        [key, value]
      end
    end.to_h
  end
end

params = { id: 1234, hoge: "too_longs_string" }
slimed_params = StringHashDieter.lose_weight(params)
puts slimed_params
#> slimed_params = { id: 1234, hoge: "too_l" }

It works!

課題と改善 その1

いい感じに書けたと思いますか?
残念ながら、このコードには課題が残っています。
params の中身…ネストするんですよね…

params = { id: 1234, nested_param: { hoge: "too_long_string" } }

その時はそんなパラメータもあるのか…と絶望したんですが…まぁ改善していきましょう

class StringHashDieter
  def self.lose_weight(heavy_hash)
    heavy_hash.map do |key, value|
      max_string_length = 5
      if value.kind_of?(String)
        [key, value[0, max_string_length]]
      elsif value.kind_of?(Hash) # ←new!
        [key, StringHashDieter.lose_weight(value)]
      else
        [key, value]
      end
    end.to_h
  end
end

params = { id: 1234, nested_param: { hoge: "too_long_string" } }
slimed_params = StringHashDieter.lose_weight(params)
puts slimed_params
#> {:id=>1234, :nested_param=>{:hoge=>"too_l"}}

It works!

課題と改善 その2

いい感じに改善できたのでしょうか?
非常に残念ですが…配列が入っているこんなパラメータもあるんです…

params = { id: 1234, nested_param: ["too_long_string", "str"] }

想像以上に対応することが多く、だんだん心が折れそうになりましたが、改善していきましょう…

class StringHashDieter
  def self.lose_weight(heavy_hash)
    heavy_hash.map do |key, value|
      max_string_length = 5
      if value.kind_of?(String)
        [key, value[0, max_string_length]]
      elsif value.kind_of?(Hash)
        [key, StringHashDieter.lose_weight(value)]
      elsif value.kind_of?(Array) # ←new!
        [key, StringArrayDieter.lose_weight(value)]
      else
        [key, value]
      end
    end.to_h
  end
end

# new!!
class StringArrayDieter
  def self.lose_weight(heavy_array)
    heavy_array.map do |value|
      max_string_length = 5
      if value.kind_of?(String)
        value[0, max_string_length]
      elsif value.kind_of?(Hash)
        StringHashDieter.lose_weight(value)
      elsif value.kind_of?(Array)
        StringArrayDieter.lose_weight(value)
      else
        value
      end
    end
  end
end

params = { id: 1234, nested_param: ["too_long_string", "str"] }

slimed_params = StringHashDieter.lose_weight(params)
puts slimed_params
#> {:id=>1234, :nested_param=>{:hoge=>"too_l"}}

一気に雲行きが怪しくなってきましたね…

課題と改善?

さてさて、だいぶつらそうなコードになってきましたね。
そんな中、追い打ちをかけるように追加の改善点がありました。
Sentryに送るデータはhashだけじゃなくてarrayかもしれなかったり、単純な文字列だったり、そうじゃなかったりするそうです
ので、同僚から↓な感じに使えるようにしてほしいそうとのことです。
要は最強のDieterで一本化してほしいとのことです。

crumb = Sentry::Breadcrumb.new(
  request_parameter: StringDieter.lose_weight(params),
  other_data_1: StringDieter.lose_weight(other_data_1), # Hashかもだし、Arrayかもだし、Stringかもだし、Integerかもだし…
  other_data_2: StringDieter.lose_weight(other_data_2)
)
Sentry.add_breadcrumb(crumb)

さて、ここまできて、次にどんなコードになるのかなんとなく想像がつくのではないでしょうか?
おそらく、Dieterに渡される引数のクラスを見て、分岐祭りなコードになるとお思いではないでしょうか?
はたして、それでいいのでしょうか?そろそろ方針を変えるときか来たのではないでしょうか?

方針替え

ここでやっと本筋のオブジェクト指向の話になります。
オブジェクト指向にはいろんな格言があります。
そのなかの一つに Don't Tell, Ask - 求めよ、命じるな という言葉あります。
自分なりに解釈して記述するコードはこんな感じになります。

crumb = Sentry::Breadcrumb.new(
  request_parameter: params.slim_down,
  other_data_1: other_data_1.slim_down, # Hashかもだし、Arrayかもだし、Stringかもだし、Integerかもだし…
  other_data_2: other_data_2.slim_down
)
Sentry.add_breadcrumb(crumb)

とはいえ、こんな感じになったところで何が変わるのでしょうか。

新しい方針のコード

Rubyにはオープンクラスという技法があり、既存のクラスに追加のコードを書けるようになっています。
それを踏まえて実装したコードが下記になります。

class String
  def slim_down
    max_string_length = 5
    self[0, max_string_length]
  end
end

class Hash
  def slim_down
    map { |k, v| [k, v.slim_down] }.to_h
  end
end

class Array
  def slim_down
    map(&:slim_down)
  end
end

# ObjectクラスはInteger等のすべてのクラスの親玉。nilもObjectのクラスに含まれる
class Object 
  def slim_down
    self
  end
end

使い方はこんな感じです

>> "too_long_string".slim_down
=> "too_l"

>> 100000.slim_down
=> 100000

>> nil.slim_down
=> nil

>> ["too_long_string", "a", 100000,["too_long_string",200000]].slim_down
=> ["too_l", "a", 100000, ["too_l", 200000]]

>> { too_long_string: "too_long_string", in: { too_long_string: "too_long_string" } }.slim_down
=> { too_long_string: "too_l", in: { too_long_string: "too_l" } }

どうでしょうか?
所見だと、コードの振る舞いを理解するのが難しいかもしれません。
とはいえ、あんなに大量にあった分岐のコードががっつり減り、だいぶシンプルなコードになりました。
メソッドもだいぶ使いやすくなったのではないでしょうか?
オブジェクト指向のポリモーフィズム的な思想だったり、rubyのダックタイピングの思想をうまく表現できたかなと思っています。
これがオブジェクト指向のパワーと言ってもいいのではないでしょうか?

感想

気軽にオープンクラスするなとか、まだまだいろんな意見はあるかもしれませんが、自分の中で勉強した内容が目に見えるわかる形でコードで表現できたことがうれしかったです。
いろんな本を読みつつ満足のいくコードを書けたことは少ないので、自分のプログラマとしての成長を感じました。
特にメタプログラミングRubyという本は難しいのですが、何度も読み続けて、血肉にするとめちゃんこ強い武器になると思います。
書籍内にあるオープンクラスなんていつ使うんだよとかその当時は思ってました。

この記事の趣旨は勉強を続けているとこういうコードがかけるようになるよという応援メッセージが8割の要素と2割の承認欲求という実はエモい記事です
みなさんも気持ちよく書けたコードがあれば、是非共有してみてはいかがでしょうか?

補足

ソースコード
Gem

11
7
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?