外部APIを自社システムに連携するときに気をつけること

先日、自社システムで新しく外部APIの連携を行いました。
その過程で、外部APIを使うときに気をつけることがいくつかあるなーと思ったので記事にします。

1. 外部API側のエラーが起きる前提の実装をし、リカバリーの手段を用意しておくこと

外部APIは、いつ、どんな事情でエラーが起きるか分かりません。
外部API側でエラーが起きたとき、開発チームに通知するところまでは行うことが多いと思います。
ただし、そこで終わってしまうのは勿体無いです。

開発者に通知するだけでは、実際にエラーが起きたとき初動でできることは、状況を確認し、それを元にAPIの提供側に問い合わせる、という程度に留まってしまい、お客さんに影響が出てしまった場合のリカバリーまで時間がかかることになります。

利用するAPIの性質にもよりますが、可能であれば管理者画面でリトライor手動でリカバリーできる方法を用意しておくこと(サービス運用者が力技で対応できる手段を残しておく)のが良いと思っています。

それば難しい場合は、せめてエラーが起きた際に状況を確認するマニュアルを用意しておく(レコードのどの値をまず確認するべきか、その場合の影響範囲など)くらいはしておくと、リカバリーまでの時間を大幅に早めることができるのでは、と思いました。

実際、今回のAPI導入後、外部API側でエラーが起きましたが、管理画面で対応する手段を実装しておいたことで障害にならず、かつ開発側で特に何もせず乗り越えることができました。

2. API管理画面も熟知しておく

私の反省ですが、外部API連携を進めるにあたって、どうしても実装の方に気を取られてしまい、外部API側の管理画面を隅々まで確認することをしばらく怠ってしまいました。
その結果、後から管理画面でいくつか不具合や不自然な挙動にあたり、慌てて外部API提供側に確認することになりました。

早めに管理画面を一通り触り、疑問点や運用していく上で障害になりそうな点を確認しておくと良さそうです。 例えば、以下のような点です。

  • APIキーの管理の仕方はどのようになっているか
  • 管理画面を触れるユーザーの管理はどのようになっているか
    • ロールがあるなら、それぞれのロールができること、できないことを確認
  • 外部API管理画面内の各種設定や機能を使ってみて、エラーが起きないか
    • 若いAPIサービスだと割とここでつまづくこともあります

利用する外部APIにまつわることは全て自分で知っておく、という気持ちでいるのが大切かなと思いました。

まとめ

今まで業務で外部APIの導入をやったことがなかったので色々と反省と気づきがありました。
次回以降機会があれば、今回の経験を活かしたいなと思います。

『なるほどUNIXプロセス』読書メモ

tatsu-zine.com

自分なりに整理したもの、理解しづらかったので追加で調べて理解したものなどメモです。

forkとCoW(Copy on Write)

  • fork: とあるプロセスから子プロセスを作ること。親プロセスがメモリに持つ情報をすべて持つ。

  • コピーをするためのオーバーヘッドが問題になってしまうことを避けるために、CoWの仕組みで対応

    • fork時にメモリ情報をコピーするのではなく、まずは親と同じメモリを共有。子プロセス側で情報の変更が必要になった場合(配列の中身を変えるなど)に、初めてコピーする。親プロセスのメモリの情報は変えない。
  • 子プロセスを作った親プロセスが死んでも、カーネルは子プロセスを特別視しないので、子プロセスには何も起きない(=一緒に死なない)

    • こうなったプロセスを孤児プロセスという
  • forkは2度返る

    • 親プロセスに対しては子プロセスのidを返し、子プロセスからはnilが返る
    • これを活用するしたコードが、exit if fork
      • 親プロセスを終了させ、子プロセスを孤児プロセスとして継続させる。

Process.wait2

  • 子プロセスのどれか1つの終了を待つ、という役割は同じだが、pidのみ返すのがpidで、pidと終了ステータスを返すのがwait2。

  • また、子プロセスを指定して終了を待つにはProcess.waitpidを使う。

  • Process.waitProcess.waitpidは同じ関数なので、Process.waitで引数に子プロセスを指定すればProcess.waitpidと同じ動きをするが、そのときやりたいことによってメソッドを変えるのが可読性の点で良い。
  • 感想
    • 同じ関数にする必要あったのかな?と感じた。waitは引数取れない、waitpidは引数必須とかだと問題あるのかなあ。

ゾンビプロセス

  • カーネルは終了したプロセスの情報をキューに持っている
  • Process.waitなどされない限り、プロセス情報を破棄できない(ゾンビプロセス)
  • リソースが無駄にならないよう、Process.detachを使うと、プロセスの終了を監視するスレッドを生成することができる。(親プロセスはブロックされない)

docs.ruby-lang.org

  • detachはpidを引数に取る必要があるが、trap(:CHLD)で子プロセスが親プロセスに送信するSIGCHLDを見て、処理を書くことができる。
    • ただし、シグナルはいつ受診するかわからないので、Process.waitする時点ですでに子プロセスがなく、Errno::ECHILDの例外を出すことも。そのため、この例外のrescueをしておくべし。
  • chatGPTに聞いてみた。detachtrap(:CHLD)の使い分けは?
    • Process.detach は 1つの子プロセスを個別に管理するのに適している。
    • trap(:CHLD) は すべての子プロセスを一括管理するのに適している。forkしまくる場合など。

trap

trap(:INT) { puts 'first' }
  old = trap(:INT) { 
  old.call
  puts 'second'
exit
}
sleep 5

↑これだとエラーが起きずに、

system_handler = trap(:INT) {
  puts 'abort to exit!'
  system_handler.call
}

sleep 5

↑これだとエラーが起きる(undefined methodcall' for "DEFAULT":String (NoMethodError)`)理由が最初よくわからなかった。

  • エラーが出ないコードは、最初にtrap(:INT)が設定されていて、old.callではそれを呼んでいる。
  • エラーが出るコードは、他にtrap(:INT)をしていないので、system_hander.callでデフォルトのアクションを呼ぼうとし、失敗。
  • これが、書籍の中の「システムのデフォルトの振る舞いは保存できない」ということと理解した。

  • その他メモ
    • プロセスはいつでもシグナルを受信できる

デーモンプロセス

exit if fork
Process.setsid
exit if fork

このコードの2回目のexit if forkをする理由がよくわからなかったので調べながら以下の理解をした。

①1回目のexit if forkでやっていること
  • 親プロセスを終了して孤児プロセスを作っている
  • プロセスグループ(セッション)は親から引き継いでいる状態
Process.setsidでやっていること
  • ①で作成した子プロセスをプロセスグループリーダー(セッションリーダー)にする
  • 親プロセスから切り離すことができるが、セッションリーダーは端末制御をしようと思えばできる
③1回目のexit if forkでやっていること
  • ①で作成した子プロセス(③の子プロセスの親プロセス)を終了する
  • ここでできた子プロセスはセッションリーダーでないので、端末制御ができず、完全に端末と分離された状態になる。

【Ruby】rails-settings-cached gemのコードリーディング

rails-settings-cached gemについて、設定の読み出し方、値の更新の仕方などが興味があったので読んでみました。

github.com

なお、rails-settings-cachedのバージョンは2.9.4です。

簡単なgemの使い方

  • ユーザーが以下のように設定ファイルを書くとSetting.admin_emailのような形で値を設定できる
  • Setting.admin_email = 'abc'のようにすると値をDBに保存し、今後はその値が使われる
class Setting < RailsSettings::Base
  cache_prefix { "v1" }

  field :admin_email, default: "admin@example.com", type: :string
end

1.fieldの定義で読み出しと上書きのメソッドを提供している仕組み

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/base.rb
def field(key, **opts)
  _define_field(key, **opts)
end

private

def _define_field(key, default: nil, type: :string, readonly: false, separator: nil, validates: nil, **opts)
  key = key.to_s

  raise ProtectedKeyError.new(key) if PROTECTED_KEYS.include?(key)

  field = ::RailsSettings::Fields::Base.generate(
    scope: @scope, key: key, default: default,
    type: type, readonly: readonly, options: opts,
    separator: separator, parent: self
  )
  @defined_fields ||= []
  @defined_fields << field

  define_singleton_method(key) { field.read }

  unless readonly
    define_singleton_method("#{key}=") { |value| field.save!(value: value) }

    if validates
      validates[:if] = proc { |item| item.var.to_s == key }
      send(:validates, key, **validates)
      define_method(:read_attribute_for_validation) { |_key| self.value }
    end
  end

  if type == :boolean
    define_singleton_method("#{key}?") do
      send(key)
    end
  end

  # delegate instance get method to class for support:
  # setting = Setting.new
  # setting.admin_emails
  define_method(key) do
    self.class.public_send(key)
  end
end

コードリーディグしてわかったこと

  • field = ::RailsSettings::Fields::Base.generate(...)の定義は以下
    • typeの定義に応じて、RailsSettings::Fields::XXXインスタンスを作っている
    • rails-settings-cached-2.9.4/lib/rails-settings/fields/base.rb
class << self
  def generate(**args)
    fetch_field_class(args[:type]).new(**args)
  end

  private

  def fetch_field_class(type)
    field_class_name = type.to_s.split("_").map(&:capitalize).join("")
    begin
      const_get("::RailsSettings::Fields::#{field_class_name}")
    rescue StandardError
      ::RailsSettings::Fields::String
    end
  end
end
  • define_singleton_method(key) { field.read }の部分で、fieldに記載したものを読み出しメソッドにしている
  • define_singleton_method("#{key}=") { |value| field.save!(value: value) }の部分で、更新用のメソッドを定義している
    • =field.save!が呼ばれる仕組み
  • ちなみに、真偽値の場合は以下で?メソッドも提供するようにしているのもわかる

2. 設定値をDB→デフォルト値という順番で読み出す箇所のコード

前述の通り、field.readを見れば良い

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/fields/base.rb
def read
  return deserialize(default_value) if readonly || saved_value.nil?

  deserialize(saved_value)
end

def deserialize(value)
  raise NotImplementedError
end

def saved_value
  return parent.send(:_all_settings)[key] if table_exists?

  # Fallback to default value if table was not ready (before migrate)
  puts(
    "WARNING: table: \"#{parent.table_name}\" does not exist or not database connection, `#{parent.name}.#{key}` fallback to returns the default value."
  )
  nil
end

コードリーディグしてわかったこと

  • DBに値が保存されている場合(saved_valueが存在する場合)はその値を引数にして、そうでない場合はデフォルト値を引数にしてdeserializeを呼んでいる
  • deserializeは、fieldの各クラスに実装されている
    • 例えば、RailsSettings::Fields::Hashでは、with_indifferent_accessでkeyに対して文字列でもシンボルでもアクセスできるようにされていることがわかる
    • rails-settings-cached-2.9.4/lib/rails-settings/fields/hash.rb
module RailsSettings
  module Fields
    class Hash < ::RailsSettings::Fields::Base
      def deserialize(value)
        return nil if value.nil?

        return value unless value.is_a?(::String)

        load_value(value).deep_stringify_keys.with_indifferent_access
      end
    end
  end
end
  • DB設定値(_all_settings)からkeyで探し、一致すればそれを返すことになっている

3. 値のキャッシュの仕方

  • saved_valueparent.send(:_all_settings)[key]の中身を見れば良い。
  • parentはユーザー定義のSettingクラス

対応するコード

  • rails-settings-cached-2.9.4/lib/rails-settings/base.rb
def _all_settings
  RequestCache.all_settings ||= cache_storage.fetch(cache_key, expires_in: 1.week) do
    vars = unscoped.select("var, value")
    result = {}
    vars.each { |record| result[record.var] = record.value }
    result.with_indifferent_access
  end
end

コードリーディングしてわかったこと

  • Railsのキャッシュの仕組みを使って、キャッシュされたものがなければすべての値をselectで取得し、そこから値を探している

4. 「=」でDB保存をしているコード

前述の通り、=field.save!が呼ばれる仕組み

def save!(value:)
  serialized_value = serialize(value)
  parent_record = parent.find_by(var: key) || parent.new(var: key)
  parent_record.value = serialized_value
  parent_record.save!
  parent_record.value
end
  • 値を読み込んで、すでに存在するか判断した上でsaveしているというシンプルな作り

【Ruby】Banken gemのauthorize!を読む

はじめに

bankenというgemを使って権限管理をしたことがあるのですが、どのように実現しているのか興味があったため、一番主要なauthorize!メソッドを読んでみました。

github.com

bankenのバージョンは1.0.3です

まとめ

  • 全体的に非常にシンプルなコードで特に難しいことはしていない
  • Loyaltyを探すのはLoyaltyFinderという専用のクラスで担っている
  • エラーはStandardErrorを継承し、newの引数にメッセージを渡している(独自のエラーを定義する方法を知らなかったので勉強になった)

authorize!メソッドについて

  • banken-1.0.3/lib/banken.rb
module Banken
  def authorize!(record=nil)
    @_banken_authorization_performed = true

    loyalty = loyalty(record)
    unless loyalty.public_send(banken_query_name)
      raise NotAuthorizedError.new(controller: banken_controller_name, query: banken_query_name, loyalty: loyalty)
    end

    true
  end
end

@_banken_authorization_performed ってなんだ?

  • Rails7の環境では関係なさそう
  • Rails3まで?あったhide_actionというのに関連していた

Loyaltyの見つけ方は?

  • authorize!メソッドの中で以下が実行される
loyalty = loyalty(record)
  • loyaltyメソッドは以下
  • banken-1.0.3/lib/banken.rb
module Banken
  def loyalty(record=nil, controller_name=nil)
    controller_name = banken_controller_name unless controller_name
    Banken.loyalty!(controller_name, banken_user, record)
  end
end
  • 少なくともauthorize!メソッドから呼ばれる場合はcontroller_namenilになりそう
  • banken_controller_nameメソッドではparams[:controller]でコントローラ名とってきているだけ
  • Banken.loyalty!の中身は以下
module Banken
  class << self
    def loyalty!(controller_name, user, record=nil)
      LoyaltyFinder.new(controller_name).loyalty!.new(user, record)
    end
  end
end
  • LoyaltyFinderでコントローラ名に対応したloyaltyを探している
  • LoyaltyFinderloyalty!メソッドと関連メソッドは以下
module Banken
  class LoyaltyFinder

    SUFFIX = "Loyalty"

    def loyalty!
      loyalty || raise(NotDefinedError, "unable to find loyalty `#{loyalty_name}` for `#{controller_name}`")
    end

    def loyalty
      loyalty_name.constantize
    rescue NameError
      nil
    end

    private

    def loyalty_name
      "#{controller_name.camelize}#{SUFFIX}"
    end
  end
end
  • コントローラ名をcamelizeしたものとLoyaltyという文字列をくっつけたものをLoyaltyにする、極めてシンプルな実装方法
  • 上記の文字列をconstantizeでクラスの形式にして返すまで行っている

実際の認証方法

  • bankenのauthorize!メソッドの中で以下が実行されている
loyalty.public_send(banken_query_name)

def banken_action_name
  params[:action]
end

def banken_query_name
  "#{banken_action_name}?"
end
  • paramsからアクション名を取り出し、?を文字列結合しているだけのシンプルな方法

NotAuthorizedErrorの中身は?

  • bankenのauthorize!メソッドの中で権限がなかった場合、以下が定義されている
raise NotAuthorizedError.new(controller: banken_controller_name, query: banken_query_name, loyalty: loyalty)
  • NotAuthorizedErrorについてのコードは以下
  • banken-1.0.3/lib/banken/error.rb
module Banken
  class Error < StandardError; end

  class NotAuthorizedError < Error
    attr_reader :controller, :query, :loyalty

    def initialize(options={})
      if options.is_a? String
        message = options
      else
        @controller = options[:controller]
        @query      = options[:query]
        @loyalty    = options[:loyalty]

        message = options.fetch(:message) { "not allowed to #{query} of #{controller} by #{loyalty.inspect}" }
      end

      super(message)
    end
  end
end
  • 権限がない場合、引数のoptionsはHashで渡ってくる
  • "not allowed to ..."の文字列を作成した上で、親クラスであるStandardErrornewの引数として渡している
  • StandardErrornewに引数で文字列を渡すと、それがエラーメッセージになる

  • rails console

e = StandardError.new('No!!')
e.message
#=> "No!!"

Active Supportのメソッドを自前で実装して本物と比較してみた

RailsのActive Supportには便利なメソッドがたくさんあります。 ふと思いつきで、その便利メソッドを自分で実装して、Railsの実装と見比べたら何か気づきがありそうと思ってやってみました。

対象は、truncateメソッドです。(オプションはomissionだけ対応)

railsguides.jp

実装してみたコード

class String
  def truncate(num, omission: '...')
    omission_count = omission.size

    if size <= num # そのまま表示できる場合
      self
    else # 「...」など表示する場合
      display_str_count = num - omission_count # 表示する文字数
      display_str_count = 0 if display_str_count <= 0 # 表示する文字数がないときは0で固定
      self[...display_str_count] + omission
    end
  end
end
  • ...など、省略記号の文字数を考慮しながら、省略が不要な場合、省略が必要な場合で分岐
  • 省略が必要な場合、何文字目まで表示するかは範囲演算子を使ってみた
    • ただし、truncateの第一引数の数字が省略記号の数を下回る場合の考慮が冗長になってしまった

実際の実装

def truncate(truncate_to, options = {})
  return dup unless length > truncate_to

  omission = options[:omission] || "..."
  length_with_room_for_omission = truncate_to - omission.length
  stop = \
    if options[:separator]
      rindex(options[:separator], length_with_room_for_omission) || length_with_room_for_omission
    else
      length_with_room_for_omission
    end

  +"#{self[0, stop]}#{omission}"
end
  • lengthの方が意味が直接的で良さそうと感じた
  • そのままの文字列を返す場合にdupを使っている
  • 省略する際に、文字を表示する部分は範囲演算子ではなくstring[a, b]を使っている
    • こうすることで、自分の実装で冗長になった箇所が解決されている
'abc'[0,-1]
#=> nil
'abc'[0..-1]
#=> "abc"

感想

仕様に漏れはなさそうでしたが、やはりRailsのコードの方が綺麗だなと思い、勉強になりました。

【Rails】sandbox_by_defaultのPRを読みながらRailsの設定の仕組みを少し読んだ

はじめに

先日、Railsガイドを読んでいたら、sandbox_by_defaultという設定がRails7.1から追加されたことを知りました。 本番環境での操作ミスを低減できるので良いなと感じ、早速業務で使っているアプリでもこの設定を有効にしました。

この設定ってどういうコードで実現しているんだろうというのを疑問に思ったので、sandbox_by_defaultのPRを見ながらざっくりRailsのコードリーディングしてみました。

github.com

(まとめ)PRと関連のRailsコードを読んでわかったこと

  • Railsでconfigを管理しているのはRails::Application::Configurationクラス
    • config.xxxで書くような設定値はこのクラスのインスタンスのアクセサメソッド(attr_accessor)で管理されている
  • コンソールでのオプション管理を行うのはRails::Command::ConsoleCommandクラス
    • class_optionrailsコマンドのオプションを設定可能
    • そのうち、オプションの値を管理しているのはThor::Optionsクラス
  • 機能としてはただ単に設定を追加しただけに見えるが、PRを見ると、明示的にオプションをfalseで指定した時にはオプションを優先するといった考慮がされているのがわかった

コードリーディング内容

1. /rails/application/configuration.rb への変更

Rails::Application::Configurationクラスのattr_accessorsandbox_by_defaultが追加されている。

Rails::Application::Configurationクラスとは?
=> config/application.rbの中にconfig.xxxの形で設定を書くが、このconfigRails::Application::Configurationクラスのインスタンス

initializeの中で以下を設定し、デフォルトはfalseとして設定する。

@sandbox_by_default = false

config.sandbox_by_default = trueとすることで値を設定できるのは、 Rails::Application::Configurationクラスのインスタンスのアクセサメソッドが定義されているので、falseをtrueに書き換えているということ。

2. /rails/commands/console/console_command.rb への変更

class_optionへの変更

class_optionとは?の説明はRailsガイドにあった。

railsguides.jp

default: falseというのを見て気づいたが、--sandbox-s)のオプションには引数でbooleanが渡せる。

rails c -s false
#=> sandboxモードで起動しない

rails c -s true
#=> sandboxモードで起動する
疑問: 何も渡さない場合はtrueになるのはなんで?どこで制御している?

(1) railsコンソールを起動すると、class_optionを定義しているRails::Command::ConsoleCommandクラスのinitializeメソッドが呼ばれる

  • ruby/3.1.0/gems/railties-7.1.3.3/lib/rails/commands/console/console_command.rb
  • local_optionsという引数に ["-s", "false"]["-s"]というかたちで値が渡されている
  • その中でsuperでRails::Command::Baseinitializeが呼ばれる
    • ruby/3.1.0/gems/thor-1.3.0/lib/thor/base.rb
module Command
  class ConsoleCommand < Base
    # 略
    class_option :sandbox, aliases: "-s", type: :boolean, default: nil,
      desc: "Rollback database modifications on exit."

    def initialize(args = [], local_options = {}, config = {})
      # 略
      super(args, local_options, config) # local_options => ["-s", "false"]や["-s"]
    end
  end
end

(2) Rails::Command::Baseinitializeの中のopts.parse(array_options)でオプションのパースを行っている

  • array_optionsには ["-s", "false"]["-s"]の値が入っている
  • optsThor::Optionsインスタンス
module Base
  # 略
  def initialize(args = [], local_options = {}, config = {})
    # 略
    if local_options.is_a?(Array)
      array_options = local_options
      hash_options = {}
    else
      # 略
    end

    opts = Thor::Options.new(parse_options, hash_options, stop_on_unknown, disable_required_check, relations)

    self.options = opts.parse(array_options)
    # 略
  end
end

(3) opts.parseの中のparse_peek(switch, options)の中で実際にパースしている

  • ruby/3.1.0/gems/thor-1.3.0/lib/thor/parser/options.rb
  • parseの中でparse_peekを呼ぶ
  • parse_peekの中でparse_booleanが呼ばれて、オプションのキー(--sandbox)に対する値が渡されているかどうかを見て、値が渡されている場合は値をパースする
class Thor
  class Options < Arguments
    def parse(args)
      # 略
      result = parse_peek(switch, option)
      assign_result!(option, result)
      # 略
      assigns
    end

    private

    def parse_peek(switch, option)
      # 略
      send(:"parse_#{option.type}", switch) # option.typeはbooleanなのでparse_booleanメソッドが呼ばれる
    end

    def parse_boolean(switch)
      if current_is_value?
        if ["true", "TRUE", "t", "T", true].include?(peek)
          shift
          true
        elsif ["false", "FALSE", "f", "F", false].include?(peek) # 'false'という文字をfalseにする
          shift
          false
        else
          @switches.key?(switch) || !no_or_skip?(switch)
        end
      else # 値がない場合は下記の処理をする
        @switches.key?(switch) || !no_or_skip?(switch) # --sandboxがkeyに含まれているのでtrueを返す
      end
    end
  end
end

sandbox?メソッドへの変更

class_optionの変更にあるように、デフォルト値がnilになっている。 つまり、--sandboxのオプションを使用せずrailsコンソール起動した場合、Rails7.0までは、options[:sandbox]falseだったが、Rails7.1からはnilを返すことになる。

この修正によって、以下の2点がわかる。

  • rails c --sandbox falseのように「オプションを明示的にfalseで指定した時」とrails cのように「オプションを指定しないとき」の区別がつくようになる。
  • オプションを明示的で指定したときは、configの設定に関わらずオプションの方を優先するようになる。(rails c --sandbox falseだとsandboxモードは無効にする。)
# config.sandbox_by_default = true の設定を書いている時

$ rails c
#=> sandboxモードで起動

$ rails c --sandbox false
#=> sandboxモードは無効

ちなみに、この設定は本番環境で間違ってDB更新をすることを避けるために作られているため、developmenttest環境では無効となっている。 この制御をするために使われているRails.env.local?もRails7.1から入ったメソッド。

techracho.bpsinc.jp

『オブジェクト指向設計実践ガイド』を2回目読んだら今度は理解できた


以前こんなブログを書きました。

blog.m-ito27.com

「勉強にはなった部分がたくさんあるが、意味が分からない点も多かったのでもう少し力をつけてまた読みたい」といったことを書きました。

ということで、約1年半経ったのでリベンジで読んでみました。
すると、今度はすんなり理解できる部分が多くなっていて、少しは成長できているな、と嬉しくなりました。

なるほどと思った点と感想を簡単にまとめました。

第1章 オブジェクト指向設計

世界をあらかじめ決められた手続きの集まりと考えるのではなく、オブジェクト間で受け渡されるメッセージの連続としてモデル化する。

読書メモ: Aが何かをして、Bがこうなった、という事象を、決まった流れとして考えるのではなく、「Aが何かをする」「Bがこうなる」というそれぞれは別物として考え、そのやりとりを考えるのがオブジェクト指向設計というもの、という理解をしました。(うまく書けないですが)

設計の目的は「あとにでも」設計をできるようにすること

読書メモ: 面白い表現ですが、確かにこういう言語化もできるなと納得しました。ストンと腹落ちする表現だと感じました

第2章 単一責任のクラスを設計する

クラスが単一責任かどうかを見極めるには、

  • クラスの持つメソッドを質問に言い換えたときに意味を成す質問になっているか
  • 1文でクラスを説明できるか(「それと」が含まれていると、 複数の責任を負っている可能性あり。「または」が含まれるなら、互いに関係のない責任を負っている可能性あり)

  • 書籍の例では、Gearの中でWheel.newするのではなく、wheelインスタンスを渡すようリファクタリングしている。
    これにより、GearWheelというクラス名を知っていることや引数の数や順番を知らない状態にできる。

  • ポイントとしては、Gearクラスは、「Wheelインスタンスdiameterメソッドを持っていることを知っている」のではなく、「単に@wheeldiameterメソッドを持っていることを知っている」のみということ。(ダックタイピングの思考)

依存方向の管理

「自身より変更されないものに依存しなさい」

  • GearWheelクラスのことや引数の順番、数を知っているのは具象的なコードに依存している状態。 一方で、diameterメソッドを持っているオブジェクトである、ことを知っている状態は抽象化されたものに依存している状態と言える。

第4章 柔軟なインターフェースをつくる

  • ドメインオブジェクトから考えて、何をさせるか、を考えるのではなく、オブジェクト間で交わされるメッセージに注意を向けることが必要であることが必要。(書籍の例で言えば、Customersuitable_tripsを送ること自体はなんら問題なく、考えるべきはその受け手だ、という話)

第5章 ダックタイピングでコストを削減する

  • ダックタイピングはまさに「メッセージに注目した」技術。 書籍の例だと、特定のクラスのインスタンスに依存しているのではなく、prepare_tripメソッドを返すオブジェクトであることに依存している。というもの。

第6章 継承によって振る舞いを獲得する

読書メモ:変数にtypeやcategoryといった類の名前がついているときは、要注意。クラス以内でifでそのカテゴリーごとに処理が分かれる箇所が多数発生している場合、継承という選択が良いかもしれない。

  • 階層構造を作るのはコストがかかるため、重複するコードがいくつか発生することを許しても継承するというコードを遅らせるという判断もあり得る。

  • 継承は、具象クラスから抽象的な振る舞いを抽出して抽象クラスに押し上げる方向で行うこと。逆になると具象的な振る舞いが抽象クラスに置き去りにされ、抽象クラスが信頼できないものになる。

第7章 モジュールでロールの振る舞いを共有する

抽象スーパークラス内のコードを使わないサブクラスがあってはなりません。

読書メモ: これは守れていないことも多そう。一部のサブクラスでしか使わないなら、スーパークラスではなくサブクラスに定義しないと間違った振る舞いを与えるサブクラスが生まれる。

継承する側でsuperを呼び出すようなコードを書くのは避けましょう

読書メモ: 上記にも書いたフックメソッドを使うようにして、スーパークラスアルゴリズムを知っておく責務から解放するべきというもの。super使ってしまうことは多いと思うので、注意して確認してみたい。

第8章 コンポジションでオブジェクトを組み合わせる

  • 継承が親クラスの機能を全て引き継ぐ(is_a)関係なのに対し、コンポジションはあるクラスが他のクラスのオブジェクトを含む(has_a)関係を表す。

  • 継承と違い、各クラスが独立するため、影響範囲の測定のしやすさ等でメリットがある。

一般的なルールとしては、直面した問題がコンポジションによって解決できるものであれば、まずはコンポジションで解決することを優先するべきです。継承の方がより解決法であるとはっきり言い切れないときはコンポジションを使いましょう。

読書メモ: Railsアプリにおけるコンポジションの考え方は前島さんの記事が分かりやすかった。https://tech.medpeer.co.jp/entry/2017/11/08/120000

第9章 費用対効果の高いテストを設計する

テストのセットアップに苦痛が伴うのであれば、コードはコンテキストを要求しすぎています。1つのオブジェクトをテストするために、ほかのオブジェクトをいくつも引き込まなければならないのであれば、そのコードは依存関係を持ちすぎています。

読書メモ: これはRailsのモデルspecを考える上でも参考になりそう

  • プライベートメソッドが多い場合はそれらの処理を別のクラスに切り出せないか疑う。

  • テストを書く際、メソッド内で送信するメッセージについては、副作用のないクエリメッセージの場合はテストしない(受信するクラス側でテストを書くべき)。 ただし、副作用のあるコマンドメッセージの場合は送信することをテストする必要はある。(ただしメソッドの中身までは見ない。これも受信するクラス側でテストを書くべき。)

読書メモ: コマンドメッセージの例は、メールの配信で考えられそう。例えばモデルのメソッドの中でメール送信している場合、(モックを使うなどして)メール送信のメソッドを呼ぶこと自体はテストするべきだが、どんなメールが送られれるかは、メールクラスの単体テストでテストされるべき、という話。確かに重複をなくすという点では良さそう