【graphql-batch】カスタムローダーの運用を考えてみた

実際のプロジェクトでカスタムローダーを運用していると、graphql-rubyリポジトリに用意されているexamplesのように抽象化されたデータローダーと、アプリケーション固有の要件を満たすためのロジックを含んだデータローダーをそれぞれ作成することがあります。

上記のようなローダーをドメインローダーとユーティリティローダーに分類すると運用しやすくなるのではないかと思ったので、ドメインローダーとユーティリティーローダーの分け方について整理してみようと思います。

ユーティリティローダー

graphql-ruby公式のexamplesに存在するAssociationLoaderやRecordLoaderのようなもので、基本的にはどのようなActiveRecordのオブジェクトに対しても適用することができるような共通処理を扱うローダーです。

ドメインローダー

ローダー内にロジックを持たせたい場合があります。 例えば、記事投稿サービスを開発しているとして、複数のタグ情報からandやorの組み合わで対象の記事の一覧や記事の投稿数を取得したいとします。 perform時(バッチ処理の実行時)にLoaderの初期化に保持しておいた条件を使用して - タグのリストを分割したクエリによるidのリスト生成 - サブクエリ などを用いてkeysで取得できる集合に対して絞り込みをしたくなったりします。

perform時の特定の条件がポイントで、「特定の条件」はアプリケーションのユースケースに依存することが多いため、どのモデルにも適用できる汎用的な作りにすることが難しかったりします。

以下は、タグに応じてand検索とor検索を組み合わせた結果を取得し、レコードのカウントを取りたい場合を想定したローダーの例となります。

module Loaders
  class PostCountLoader < GraphQL::Batch::Loader 
    def initialize(model, column: model.primary_key, where: nil, joins: nil, distinct_column: nil, tag_ids: nil)
        @model = model  
        @column = column.to_s  
        @column_type = model.type_for_attribute(@column)  
        @where = where  
        @joins = joins  
        @distinct_column = distinct_column  
        @tag_ids = tag_ids
    end

    def load(key)  
      super(@column_type.cast(key))  
    end  
          
    def perform(keys)  
      query(keys).each { |key, count| fulfill(key, count) }  
      keys.each { |key| fulfill(key, 0) unless fulfilled?(key) }  
    end

    private

      def query(keys)
         scope = @model
         scope = scope.joins(@joins) if @joins

         if @tag_ids
           # 絞り込みに向けて複数のクエリを組み合わせ、検索の元となるIDのリストを作成する必要がある
           and_tag_ids = Tag.and_category_tags(@tag_ids).pluck(:id)  
           post_ids = PostsTag.or_category_tags(@tag_ids).pluck(:tag_id)  
           if and_tag_ids.count.positive?  
             query = PostsTag.by_tags(and_tag_ids)  
             query = query.where(post_id: post_ids) if post_ids.count.positive?  
             post_ids = query  
           end

           # 作成したidのリストを使用して検索
           @where = @where.merge(id: post_ids)  
         end
              
         scope = scope.where(@where) if @where  
         scope.where(@column => keys).group(@column).distinct(@distinct_column).count
      end
end

このようなローダーを「ドメインローダー」と分類します。

ユーティリティローダーとドメインローダーの分け方

今のところ以下のような観点で分類するとよさそうだと思っています。

  • どのModelでも使用することができる
    • →ユーティリティローダー
  • Loaderのinitializerの引数やLoaderにアプリケーション固有の属性やロジックが含まれており、対象のModelが限定される

Railsでのディレクトリ構造

graphql/loaders
    ├utils
    │  ├AssociationLoader.rb (ユーティリティーローダー)
    │  ...
    ├ PostCountLoader.rb (ドメインローダー)
    ...
  1. ユースケースが限られるものはドメインローダーとして作成する
  2. しばらく運用して、抽象化できると判断できるものは、ユーティリティローダーとしてutils配下に配置する

方針にするとよさそうです。

まとめ

カスタムローダーを共通のユースケースを想定したものと、アプリケーション固有のユースケースを想定したものに分類することで、役割が明確になり運用しやすくなるのではないかということを書いて来ましたが、まだ実際の運用には至っていないので、今後運用してみた結果も別記事かこの投稿に更新していこうと思っています。

参考

https://github.com/rmosolgo/graphql-ruby https://github.com/Shopify/graphql-batch

graphql-batchのカスタムローダーと作り方

カスタムローダーの作り方

graphql-batchのリポジトリに用意されているexamplesを参考に、カスタムローダーの作り方を見ていこうと思います。

まずLoaderと定義されているpublicメソッドを書き出してみます。

  • AssociationLoader
    • initialize
    • load
    • cache_key
    • perform
  • RecordLoader
    • initialize
    • load
    • perform
  • WindowKeyLoader
    • initialize
    • perform
  • HTTPLoader
    • initialize
    • perform
  • ActiveStorageLoader
    • initialize
    • perform

リファレンス実装されているどのLoaderでも共通して実装されているメソッドは、以下です。

  • initialize
  • perform

また、RecordLoaderでは、加えて「load」メソッド、AssociationLoaderでは加えて「load」、「cache_key」メソッドが実装されています。

共通で実装されるメソッドから役割や機能を見ていきましょう。

RecordLoaderを例にします。

initialize

initializeでは、graphql-rubyのfieldでLoaderを呼び出す際の、forメソッドに渡す引数を定義します。

class RecordLoader < GraphQL::Batch::Loader
  def initialize(model, column: model.primary_key, where: nil)
    super()
    @model = model
    @column = column.to_s
    @column_type = model.type_for_attribute(@column)
    @where = where
  end

  ...
end
field :product, Types::Product, null: true do
  argument :id, ID, required: true
end

def product(id:)
  RecordLoader.for(Product).load(id)
end

perform

performでは、GraphQL resolverの解決後に遅延実行されるバッチ処理を記述します。 各fieldの呼び出し時にキャッシュしてあるkeyの一覧を受け取った上で実行される、バッチ処理のロジック定義します。 バッチ処理の実行後はキャッシュキーを使用して、Promiseをfulfillして完了させます。

class RecordLoader < GraphQL::Batch::Loader
  ...
  def perform(keys)
    query(keys).each { |record| fulfill(record.public_send(@column), record) }
    keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
  end
  ...
end

最低限、initializeとperofmメソッドを実装することでカスタムローダーが作成できますが、一部の例ではそれ以外のメソッドを実装しているので、そちらも見ていきたいと思います

load

cache keyへの登録処理をカスタマイズする場合に使用します。

RecordLoaderの場合、cache keyはそのまま、whereメソッドの特定のカラムの値になるため、loaderメソッドをoverrideして適切な型にあらかじめ変換しています。

...
def load(key)
  super(@column_type.cast(key))
end
...

AssociatoinLoaderの場合、対象となるモデルに指定されたassociationが存在するかどうかのチェックと、すでに対象のレコードのassociationが呼び出されている場合はcache keyへの登録はスキップするようになっています。

...
def load(record)
  raise TypeError, "#{@model} loader can't load association for #{record.class}" unless record.is_a?(@model)
  return Promise.resolve(read_association(record)) if association_loaded?(record)
  super
end
...

cache_key

AssociatoinLoaderのみで実装されています。 cache keyをDBから取得したレコードのIDで設定すると、別々のAssociations間でcache keyが競合することから、ActiveRecordのレコードオブジェクトのIDをcache keyとして扱うようになっています。

# We want to load the associations on all records, even if they have the same id
def cache_key(record)
  record.object_id
end

まとめ

カスタムローダーを作成するにあたっての基本は - initializeメソッド - performメソッド を実装することが基本となります。

Initializeでバッチ処理対象のキーを受け取る、performでキャッシュされたキーを元にバッチ処理を実行し、Promiseを完了させます。

また、必要に応じてloadやcache_keyなどでキャッシュ対象のキーの前処理やcache_keyのカスタマイズなどを行う流れとなります。

実際にカスタムローダーを作成する場合は、N+1クエリの解消目的とすることが多いことから、まずはRecordLoaderやAssociationLoaderをベースにperformメソッドでのバッチ処理のロジックを拡張するとよいかと思います。

参考

graphql-batchの仕組みを調べてみた

graphql-batchを用いたバッチ処理の仕組みについて調べてみたので、まとめておきたいと思います。

graphql-batchの仕組み

公式のexamplesにあるRecordLoaderを使って、実際のバッチ処理を例にして処理流れや仕組みをみていきます。 例として、記事一覧の記事ごとに投稿したユーザーを一緒に取得する場合を想定します。

カスタムローダーの準備

モデルから特定のattribute(カラム)と任意の条件の組み合わせでレコードを取得する際のバッチ処理を扱うもので、belongs_toな関連を持つレコードの取得時に実装されることが多いです。

record_loader.rb

class RecordLoader < GraphQL::Batch::Loader
  def initialize(model, column: model.primary_key, where: nil)
    super()
    @model = model
    @column = column.to_s
    @column_type = model.type_for_attribute(@column)
    @where = where
  end

  def load(key)
    super(@column_type.cast(key))
  end

  def perform(keys)
    query(keys).each { |record| fulfill(record.public_send(@column), record) }
    keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
  end

  private

  def query(keys)
    scope = @model
    scope = scope.where(@where) if @where
    scope.where(@column => keys)
  end
end

fieldの定義

:postsはTypes::Postのオブジェクトを複数取得するfieldで、Types::Postのオブジェクトは記事の投稿ユーザーを表すuser fieldを持っています。 user fieldでは、object.user_idをkeyとして、RecordLoaderのforメソッド、loaderメソッドを呼び出しています。

class Types::User < GraphQL::Schema::Object
  field :name, String, null: false
end

class Types::Post < GraphQL::Schema::Object
  field :user, Types::User, null: false

  def user
    RecordLoader.for(User, :id).load(object.user_id)
  end
end

field :posts, [Types::Post], null: true

def posts(id:)
  Post.all
end

RecordLoaderを使用せずにクエリを実行すると、user fieldが記事の取得毎によびだされるため、N+1クエリが発生するからです。

N+1クエリが発生する例

class Types::Post < GraphQL::Schema::Object
  field :user, Types::User, null: false

  def user
    User.find(object.user_id)
  end
end

上記のようなN+1クエリを防ぐために、RecordLoaderがどのようにバッチ処理を行っているのかを追っていきます。

Loaderの呼び出し

まず、user fieldでは、RecordLoader.for(User, :id)が呼び出されます。 forメソッドには、RecordLoaderクラスのinitializerで定義した引数を渡します。

内部的には、fieldから渡された引数を元にRecordLoaderクラスのインスタンスを作成し、executorにRecordLoaderクラスのオブジェクトを登録しています。

executorには、forで指定した引数とオブジェクト自身(RecordLoader)の組み合わせをキー(loader_key)としてローダーオブジェクトが登録されます。

lib/graphql/batch/loader.rb

...
else
  def self.for(*group_args)
    current_executor.loader(loader_key_for(*group_args)) { new(*group_args) }
  end
end
...

# loaderのキーを作成
def self.loader_key_for(*group_args, **group_kwargs)
  [self, group_kwargs, group_args]
end

すでに同じキーでローダーオブジェクトが登録されている場合は、既存のローダーオブジェクトを使い回します。

forメソッドにより、RecordLoaderオブジェクトの作成もしくは取得に成功すると、load(object.user_id)でバッチ処理の対象となるモデルのカラムの値をLoaderに渡します。

内部的には、loadメソッドは親クラス(GraphQL::Batch::Loader)のloadメソッドを呼び出しています。

RecordLoaderクラスでは、loadメソッドに渡す引数はクエリの実行時にwhereメソッドの引数となるため、フィルター対象のカラムに合わせた型にキャストしています。

def load(key)
  super(@column_type.cast(key))
end

superで親クラスのloadメソッドを呼びだし、Promiseオブジェクトの作成を行い、キューおよびLoaderのキャッシュストレージ(インメモリ)に保存します。

def load(key)
  # キャッシュからkeyを元にPromiseオブジェクトを取得する
  # ない場合はsourceを自身(RecordLoader)としてキャッシュに保存した上でPromiseオブジェクトを作成し、作成したオブジェクトを返す
  cache[cache_key(key)] ||= begin
    queue << key
      ::Promise.new.tap { |promise| promise.source = self }
    end
end

このタイミングでuser fieldではPromiseのオブジェクトがセットされている状態になります。 RecordLoaderの観点からは、user fieldに対して、複数のPromiseオブジェクトを持つ1つのRecordLoaderオブジェクトという形になっています。

Promiseオブジェクトのlazy_resolve

ここから、取得対象のPostの数だけuser fieldが呼び出されることにより、Promiseオブジェクトが作成された後の処理を見ていきたいと思います。

resolverがresolveされた後、lazy_resolve(::Promise, :sync)メソッドにより、作成されたPromiseオブジェクトのsyncメソッドが呼び出されます。

lazy_resolveは、graphql-rubyで用意されている遅延実行用の仕組みです。 lazy_resolve(::Promise, :sync)メソッドは、graphql-batchのセットアップ手順にあるuse GraphQL::Batchで定義されることになります。 どのようにlazy_resolveが呼び出されているかは、lib/graphql/batch.rbのself.useメソッドを見てみるとよいです。

xx_schema.rb

class MySchema < GraphQL::Schema
  query MyQueryType
  mutation MyMutationType

  use GraphQL::Batch
end

Promiseオブジェクトのsyncメソッドが呼び出されると、Promiseのsourceとして登録されているLoaderクラスのwait(sync)が実行されます。

waitメソッドはloaderのresolveメソッドを実行します。

resolveメソッドはLoaderクラスの継承クラス(RecordLoader)で実装されたperformメソッドを実行します。その際引数として、load_keysとしてキューに保存されていたkeyの一覧が渡されます。

Loaderオブジェクトはすでに呼び出されたPromiseオブジェクトの実行結果を状態として保持しているため、resolved?でperformでロジックの実行が必要がないPromiseオブジェクトの場合はそのまま処理を終了させます。(最初の実行ですでに解決されているため)

def resolve #:nodoc:
  return if resolved?
  load_keys = queue
  @queue = nil

  around_perform do
    perform(load_keys)
  end

  check_for_broken_promises(load_keys)
rescue => err
  reject_pending_promises(load_keys, err)
end

Loaderのperformメソッドでは、受け取ったkeysを元にバッチ処理を実行し、keyに対応するキャッシュ中のPromiseオブジェクトをfulfill(成功したものとして完了)させます。

fulfillする際には、後続の処理(fieldへのマッピングやthenのコールバック)で必要となる値、RecordLoaderの場合は取得したレコードのカラムをkey(loadでkeyにした値)に、実際のレコードをvalueとして引数に渡します。

RecordLoaderでは指定したkeyに対応するレコードが存在しない場合もあるため、keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }で漏れた分のkeyに対応するPromiseを解決しています。

record_loader.rb

...
def perform(keys)
  query(keys).each { |record| fulfill(record.public_send(@column), record) }
  keys.each { |key| fulfill(key, nil) unless fulfilled?(key) }
end

private

  def query(keys)
    scope = @model
    scope = scope.where(@where) if @where
    scope.where(@column => keys)
  end
...

ここでバッチ処理が完了し、user fieldへバッチ処理を経て取得した値がマッピングされることになります。

Promiseを使うメリット

ここまでgraphql-batchの処理を追ってきましたが、その中でPromiseオブジェクトがよく登場してきました。 lazy objectを作成してlazy_resolveを使用するという、graphql-rubyで用意されている機能を使うだけでもバッチ処理は可能ですが、graphql-batchはバッチ処理時の

  • .thenでLoaderの実行結果を使用したデータの加工ができること
  • Loaderの結果に依存する別のLoaderの呼び出しができること
  • Promise.allで複数のLoader(promise object)をまとめることができる

などのような後工程でのデータの変換や直列、並列なバッチ処理をシンプルに実現するための手段としてPromiseを使用しているようです。

まとめ

fieldに定義したLoaderがどのように遅延実行されバッチ処理が実現されるのかを、graphql-batchのソースコードを読みながら自分なりにまとめてみました。

自分への備忘として、最後にgraphql-batchによるバッチ処理のポイントをまとめておきたいと思います。

  • 1fieldに対して、1Loader、Loaderはfield呼び出しで作成された複数のPromiseオブジェクトを保持する実装であること
  • lazy_resolve実行時のPromiseオブジェクトのresolveは1Loaderにつき一度だけ実行され、同じものはスキップされる
  • カスタムLoader実装時はperoformでのfulfill処理が後続処理(fieldへの値のマッピングやthenなどの後工程への値のパス)に向けて必須になる
  • Promiseを使うことで、後工程でのデータの変換や直列、並列なバッチ処理をシンプルに実現するための手段としてPromiseを使用している

参考

【GraphQL Ruby】InputObjectのField参照について

graphql-rubyではFieldとして引数をとることができます。さらに引数を定義するための型として、以下があります。

  • Scalar
  • Enum
  • InputObject
  • List
  • NonNull

特にInputObjectに関して、オブジェクトに定義されたFieldを参照する方法として

  • メソッドとして参照する方法
  • #[]としてSymbolで参照する方法

が用意されていますが、参照方法が2通りある背景と使い分けについて整理しておきたいと思います。

参照方法が2通りある背景

graphql-rubyのInputObjectに関する公式ドキュメントを見てみると「Using Input Objects」の欄に

  • calling its method, corresponding to the name (underscore-cased)
  • calling #[] with the camel-cased name of the argument (this is for compatibility with previous GraphQL-Ruby versions)

とあり、#[]は前バージョンとの後方互換性のためのものであるようです。

#[]による参照は非推奨なのでしょうか?もう少し調べたいと思います。

graphql-rubyChangelogに参照方法の変更に関しての記述がありました。

  • calling its method, corresponding to the name (underscore-cased)
  • Field arguments may be accessed as methods on the args object. This is an alternative to #[] syntax which provides did-you-mean behavior instead of returning nil on a typo. #924 For example:

...The old syntax is not deprecated.

# using hash syntax:
args[:limit]    # => 10
args[:limittt]  # => nil
# using method syntax:
args.limit      # => 10
args.limittt    # => NoMethodError
  • Hash形式の参照時にタイポなどで未定義のFieldへのアクセスした場合にnil値を返すのではなく、NoMethodErrorにするための機能であること
  • 旧式(Hash形式での参照方法)も今の所、非推奨ではない

ことが分かりました。

使い分け

  • Changelogにもあるように、タイポに気づきやすくする
  • 後のアップデートで非推奨になる可能性がある

ことから、基本的にメソッドによるFieldの参照(args.limit)を行う方法で実装するとよさそうです。


参考

https://graphql-ruby.org/fields/arguments.html https://github.com/rmosolgo/graphql-ruby/blob/93ecc6d8c21606a571d0c87c6e3430b71b5acd84/CHANGELOG.md#170-18-sept-2017 https://graphql-ruby.org/type_definitions/input_objects.html#using-input-objects https://graphql-ruby.org/api-doc/2.0.20/GraphQL/Schema/InputObject#[]-instance_method

Pumaとメトリクス

WebサーバーとしてPumaを使用している場合の以下についてまとめておく

  • 監視の観点
  • メトリクスの収集方法

監視の観点

  • Pumaの動作するサーバーに問題が起きていないか
  • Pumaに問題が起きていないか

Pumaの動作するサーバーに問題が起きていないか

ホストマシンの以下の指標をみておく

  • CPU
    • sar
  • メモリ
    • vmstat
  • ネットワーク

観点: メモリを使いすぎていないか?

  • メモリ容量の上限値を超える場合はworker数減らすかメモリ上限を上げるなどの検討をする

Pumaに問題が起きていないか

監視上の観点とPuma.statsで提供される情報から、指標として使用するとよさそうなものをピックアップしてみる

観点: 必要なプロセスやスレッドが正常に動作しているか?

  • puma.workers: 起動中のワーカー数
  • puma.running: 起動中のスレッド数

  • 設定値通りにプロセスやスレッドが起動しているかを確認する

観点: リクエストに対して受け入れ可能な状態にあるか?

  • puma.backlog: キューにあるリクエストの待ち行列の数
  • puma.pool_capacity: スレッドプール中の使用可能なスレッド数

  • backlogが常にある一定以上存在し続けている場合は、workerを増やしてリクエストの待ち行列を減らす

  • pool_capacityのスレッド数が常に0に値の場合は、workerあたりのthread数を増やすか、worker数を増やす

DatadogでPumaのメトリクスを収集する

puma-plugin-statsdgemを使用し、ddagentのカスタムメトリクスとして追加する方法で収集してみた。

※ ddagentがインストールされていてかつ起動されることが前提

Gemfileに以下を追記

...
gem 'puma-plugin-statsd'
...

config/puma.rbに以下を追記

...
plugin :statsd
...

動作環境の環境変数として以下を追加する

STATSD_HOST=127.0.0.1

ddagentのintegrationでもPumaのメトリクスを収集できるようなので、こちらを使った方がよさそう

参考

https://speakerdeck.com/s4ichi/metorikusuke-shi-hua-karashi-meru-rails-uebusabafalsetiyuningu-kaigi-on-rails-2021 https://blog.naoty.dev/373/

「コーディングを支える技術」を読んでだので、クラスに関して整理してみた

背景

「コーディングを支える技術」を読んだので、クラスに関して理解したことを整理するとともに、 普段Rubyを使う機会が多いため、クラスの機能がRubyでどのように表現できるのか?に関しても考えてみたいと思います。

クラスとは?

クラスという機能がプログラミング言語に導入されるに至った背景や、概念としての機能を整理しておきます。

  • オブジェクト指向とクラス
    • クラスに至るまでのニーズや背景
      • 現実世界のモノ(object)をコンピュータで扱えるようにするための概念をつくりたい
      • 変数と関数をまとめて模型をつくりたい
      • クラスの概念とプログラミング言語への実装
      • オブジェクト指向の設計者によって概念が異なることに注意
    • はじまりは分類として
      • C++Javaで今日のクラスの役割ができた
  • クラスの機能(概念)
    1. まとまったもの(object)を作る生成器
    2. どのような操作が可能かという仕様
      • インターフェース
    3. コードを再利用する単位
      • 継承

オブジェクト指向プログラミングにおけるクラスの機能

前述のクラスの機能に関しての概念をもとに、オブジェクト指向プログラミングにおけるクラスの機能を書き出したのが以下となります。

  • まとまったもの(object)を作る生成器
  • どのような操作が可能かという仕様
  • コードを再利用する単位
    • 継承

Rubyにおけるクラスの表現

Rubyにおいて前述の「クラスの機能」を表現する方法として以下の2機能が用意されています。

  • module
    • メソッドと定数をまとめたもの
    • インスタンスを生成することはできない
    • クラスとその他のモジュールにmix-inすることができる
    • 継承することはできない
  • class
    • インスタンスを作成することができる
    • インタンス変数として内部に状態を持つことができる
    • 他のクラスから継承することができる
    • モジュールを継承することはできない

modlueとclassを使用して、「クラスの機能」をRubyで表現する方法に関して整理や使い分けに関して考えてみようと思います。

まとまったもの(object)を作る生成器

「module」や「class」が該当します。 それぞれの役割として基本的には - module: メソッドや定数をまとめたいが、内部に状態(インスタンス変数)を持つ必要がない - class: 内部に状態を持つ必要がある の観点で使い分けます。

また、型に関しては、Rubyは動的型付け言語であるためオブジェクトの型はそのクラスとは厳密には結びついておらず、オブジェクトが反応するメソッドの集合であることから、objectの生成器としてではなく、操作の仕様(後述)として分類することにしました。

どのような操作が可能かという仕様

動的型付け言語のため、インターフェースの定義のようなものは言語機能としては用意されていないため、使い分けなどの観点などはなさそうでした。 (インタフェースの定義を実現したい場合は、moduleを使用することで、近しいものを実装することは可能のようです。) また、ポリモーフィズムはダックタイピングにより実現されています。

コードを再利用する単位

  • 継承
    • Rubyにおいては単一継承のみが可能
    • 多重継承を実現する場合はmix-inを活用する
  • mix-in
    • 基本的にモジュールのメンバメソッドやクラスメソッドに委譲して使用する
    • インスタンス変数は使用せず、委譲し、引数経由で状態の受け渡しを行うことで再利用性を高く保ようにする

classによる単一継承、moduleを活用したmix-inが用意されています。 基本はmix-inを活用し、委譲によりコードの再利用をすることが推奨されていますが、以下の観点で使い分けを行うとよさそうです。

  • 継承
    • 1つのクラスから再利用できる部分が1つだけの場合
  • mix-in
    • 1つのクラスから再利用できる部分が複数にわたる(オブジェクトに共通の振る舞いがある)場合

どちらの場合もテンプレートメソッドパターンやフックメソッドを使用して、使用者に変化可能な箇所とそうではないインターフェースに関して伝える努力をすることが重要です。

また、継承やmix-inの使い分け以前に「継承可能なコード」を書くことがポイントとなります。

継承可能なコードの状態とは、以下のような状態を指します。 ※「オブジェクト指向設計実践ガイド」に詳しく書かれています。

  • 抽象スーパークラス内のコードを使わないサブクラスがないこと 
  • リスコフの置換原則に従っていること
  • テンプレートメソッドが使用されていること
  • 継承する側でsuperを呼び出すことを避け、継承時の親子関係を疎結合にすること(フックメッセージの活用)
  • 階層構造を浅く保つこと です。

上記より継承かmix-inかを決めきれない場合には、以下のような流れで設計を進めるとよさそうです。

  1. 継承可能なコードにする
  2. その上で、共通の振る舞いを持つオブジェクトがあれば、その振る舞いをmoduleとして切り出しmix-inする

おわりに

クラスという機能が言語仕様として実装された背景や機能としての概念をベースに、Rubyのクラスという機能の表現に関して改めて考えることができました。

gemやフレームワークを提供する側になると使用者にどのように使ってほしいか?をコードからも意図伝える必要が出くるのではないでしょうか。

前述した、Javaのinterfaceに近しいものをmoduleで実現するように、その他の言語におけるクラスの機能を持ち込むための工夫(abstract classを実現する)に関しても今後深堀っていきたいと思います。

参考

【書籍】 www.amazon.co.jp

www.amazon.co.jp

【その他】

https://xtech.nikkei.com/it/article/COLUMN/20050912/220974/

【RealityKit】モデルにMaterialを設定する方法

作成したmodelをEntityとして読み込む

guard let model = try? Entity.load(named: "sample.scnassets/sample") else { return }

読み込んだEntityからmaterialを設定する対象のEntityを検索して、ModelComponentを取得する

if var modelComponent = model.findEntity(named: "設定対象のEntity名").components[ModelComponent.self] as? ModelComponent {
}

取得したModelComponentのmaterialsプロパティに任意のmaterialを設定して更新する

if var modelComponent = model.findEntity(named: "設定対象のEntity名").components[ModelComponent.self] as? ModelComponent {
    modelComponent.materials = [UnlitMaterial(color: UIColor.green.withAlphaComponent(0.8))] // 例として、UnlitMaterialを設定
    model.findEntity(named: "設定対象のEntity名").components[ModelComponent.self] = modelComponent
}

参考

https://stackoverflow.com/questions/56828648/realitykit-adding-a-material-to-a-modelentity-programmatically

https://developer.apple.com/documentation/realitykit/modifying_realitykit_rendering_using_custom_materials