【Active Record】has_manyな関連先の差分更新について

こんにちは、Plex Job開発チームの種井です。

Plex Job開発チームではバックエンド開発にRuby on Railsを使用しています。

開発を進める中で、Active Record で1対多の関連先レコードを差分更新したい場面があります。私個人の話ですが、そのような要件があった際に自前で差分のみを抽出し、関連先の属性に指定するような実装を行っていました。
ある時、関連先の属性に直接指定することで、Active Record が差分更新してくれることを同僚に教えてもらいました。とても便利に思った反面、Active Record の内部でどのように差分更新されているかについて気になったので、調べてみました。

今回は差分更新時の挙動Active Record内部での実装のされ方について調べてみた内容を紹介したいと思います。

例として挙げているソースコードの動作環境は以下となります。

関連先は差分更新される

まずは、差分更新の挙動についてみていきたいと思います。 Railsガイドの例を借りて、以下のようなテーブルを想定します。

モデルの例

対応するモデルは以下のようになります。

class Author < ApplicationRecord  
  has_many :books  
end

class Book < ApplicationRecord  
end

authorsテーブルとbooksテーブルにそれぞれレコードを作成します。

author = Author.new(name: 'Martin Fowler')
author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')]
author.save!

author.books.pluck(:id)
# => [1, 2]

この著者(Author)の書籍(Book)の関連付けを変更してみます。

uml_book = Book.create!(title: 'UML Distilled')
poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture')

refactoring_book = author.books.first
refactoring_book.title = 'Refactoring 2nd Edition'

author.books = [refactoring_book, uml_book, poeaa_book]
author.save!

author.books.pluck(:id)
# => [1, 3, 4]
author.books.pluck(:title)
# => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]

Book(id: 2)は削除され、Book(id: 3, 4)は追加、Book(id: 1)はタイトルが変更されています。

このように、関連先として指定されたオブジェクトに応じて、Active Recordがよしなに差分更新(SQLのinsert, update, delete文の発行)を行ってくれることがわかります。

便利な点

  • 関連先の更新後の状態を指定するだけ
  • 実装者間で差分更新のロジックがムラが出ない

が便利なところだと思っています。

関連先の更新後の状態を指定するだけ

=の挙動として直感的であり、何より差分更新ロジックをフレームワーク側が担ってくれるため記述量も少なくなります。 例えば、以下はこの機能を知る前の私が書いた差分更新の実装です。とても冗長ですね、、

author = Author.new(name: 'Martin Fowler')
author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')]
author.save!

author.books.pluck(:id)
# => [1, 2]
uml_book = Book.create!(title: 'UML Distilled')
poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture')

refactoring_book = author.books.first
refactoring_book.title = 'Refactoring 2nd Edition'

ids = author.books.pluck(:id)
delete_ids = ids - [refactoring_book.id, uml_book.id, poeaa_book.id].map(&:to_i)
new_ids = [refactoring_book.id, uml_book.id, poeaa_book.id].map(&:to_i) - ids

if new_ids.present?  
  author.books << Book.where(id: new_ids)  
  author.save!
end

author.books.where(id: delete_ids).find_each(&:destroy!)
author.books.reload

author.books.pluck(:id)
# => [1, 3, 4]
author.books.pluck(:title)
# => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]

実装者間で差分更新のロジックがムラが出ない

以下は実際にプロジェクトにあった別の差分更新ロジックです。一度関連レコードを全て削除した上で新しいレコードとして作成するシンプルな実装方法です。

author = Author.new(name: 'Martin Fowler')
author.books = [Book.new(title: 'Refactoring Ruby Edition'), Book.new(title: 'Refactoring')]
author.save!

author.books.pluck(:id)
# => [1, 2]
uml_book = Book.create!(title: 'UML Distilled')
poeaa_book = Book.create!(title: 'Patterns of Enterprise Application Architecture')

refactoring_book = author.books.first
refactoring_book.title = 'Refactoring 2nd Edition'

author.books.map(&:destroy)
author.books = [rename_book, book1, book2]

author.books.pluck(:id)
# => [3, 4, 5]
author.books.pluck(:title)
# => ["Refactoring 2nd Edition", "UML Distilled", "Patterns of Enterprise Application Architecture"]

上記の例のように、開発者が個々人で差分更新のロジックを実装することで、レコードの作成のされ方に差が出てしまったり、コードの統一感がなくなってしまいます。 その点、Active Recordの機能を素直に使うことで仕様やコード品質を均一化することができます。

仕組み

ここまで、has_many 関連を持つ属性にオブジェクトを含む配列を渡すことで、Active Record が対応するテーブルに対して差分更新を行ってくれることを紹介してきました。 続いて、Active Record が自動的に行う差分更新の仕組みについて、内部のコードを追いながら見ていきたいと思います。

イメージしやすいように、登場するクラスやメソッドを簡単にまとめてみました。

has_manyクラスマクロの呼び出し

前述した例を改めて。

class Author < ApplicationRecord  
  has_many :books  
end

has_manyクラスマクロの定義から見てみます。

def has_many(name, scope = nil, **options, &extension)
  reflection = Builder::HasMany.build(self, name, scope, options, &extension)
  Reflection.add_reflection self, name, reflection
end

https://github.com/rails/rails/blob/a72205eaf8cff4b36838c49b00ae10f9e72dbb95/activerecord/lib/active_record/associations.rb#L1302

Builder::HasMany.build(self, name, scope, options, &extension)というBuilderを使用して、HasManyAssociationオブジェクト作成しています。

Builderは.build メソッド呼び出し時にaccessorとしてwriter,readerメソッドを動的に定義します。

def self.define_readers(mixin, name)
  mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
    def #{name}
      association(:#{name}).reader
    end
  CODE
end

https://github.com/rails/rails/blob/a72205eaf8cff4b36838c49b00ae10f9e72dbb95/activerecord/lib/active_record/associations/builder/association.rb#L102

def self.define_writers(mixin, name)
  mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
    def #{name}=(value)
      association(:#{name}).writer(value)
    end
  CODE
end

https://github.com/rails/rails/blob/a72205eaf8cff4b36838c49b00ae10f9e72dbb95/activerecord/lib/active_record/associations/builder/association.rb#L110

これにより、以下の例のように関連先の参照、書き込み操作を可能にしています。

author = Author.new

# reader
author.books

# writer
author.books = [Book.new(title: "タイトル")]

上記から今回の場合、HasManyAssociationクラスに定義されているであろう、writerメソッドを見ると差分更新のロジックが分かりそうです。

writerメソッドの動作

HasManyAssociationクラスを確認すると、writerメソッドは定義されていませんでしたが、親クラスのCollectionAssociationクラスにwriterメソッドが定義されていました。

def writer(records)
  replace(records)
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L46

writerはさらに対象となる配列を受取り、replaceメソッドを呼び出しています。

replaceメソッドの動作

# Replace this collection with +other_array+. This will perform a diff
# and delete/add only records that have changed.
def replace(other_array)
  other_array.each { |val| raise_on_type_mismatch!(val) }
  original_target = skip_strict_loading { load_target }.dup

  if owner.new_record?
     replace_records(other_array, original_target)
  else
    replace_common_records_in_memory(other_array, original_target)
    if other_array != original_target
      transaction { replace_records(other_array, original_target) }
    else
      other_array
    end
  end
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L242

親レコードが新規レコードかどうかを確認して処理が分岐しています。今回はすでに親レコードが存在する場合の処理ついて見ていきたいと思います。

まずはreplace_common_records_in_memoryメソッドです。

def replace_common_records_in_memory(new_target, original_target)
  common_records = intersection(new_target, original_target)
  common_records.each do |record|
    skip_callbacks = true
    replace_on_target(record, skip_callbacks, replace: true)
  end
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L430

intersectionメソッドで既存の関連レコードの配列と変更後の配列の共通部分のみを取り出し、replace_on_targetメソッドを呼び出しています。

def intersection(a, b)
  a & b
end

https://github.com/rails/rails/blob/a72205eaf8cff4b36838c49b00ae10f9e72dbb95/activerecord/lib/active_record/associations/has_many_association.rb#L162

def replace_on_target(record, skip_callbacks, replace:, inversing: false)
  if replace && (!record.new_record? || @replaced_or_added_targets.include?(record))
    index = @target.index(record)
  end
  ...
  if index
    target[index] = record
  elsif @_was_loaded || !loaded?
    @association_ids = nil
    target << record
  end
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L457

replace_on_targetメソッドでは、メモリ上で既存の関連レコードを変更後のオブジェクトで上書きしています。

replaceメソッドに戻ります。既存の関連レコードの配列と変更後の配列に差分がある場合はtransactionブロック内でreplace_recordsメソッドを呼び出しています。

def replace_records(new_target, original_target)
  delete(difference(target, new_target))

  unless concat(difference(new_target, target))
    @target = original_target
    raise RecordNotSaved, "Failed to replace #{reflection.name} because one or more of the " \
                                                    "new records could not be saved."
  end

  target
end

https://github.com/rails/rails/blob/6a76cae4fdfe4cae5007e6450988850fc9b5b8cd/activerecord/lib/active_record/associations/collection_association.rb#L418

def difference(a, b)
  a - b
end

https://github.com/rails/rails/blob/9f16995fb9556228ad88a3799b0e349ef6f6e0c7/activerecord/lib/active_record/associations/has_many_association.rb#L158

targetHasManyAssociation(CollectionAssociation)内で共有されているインスタンス変数です。

replace_recordsメソッドが呼び出される前にreplace_common_records_in_memoryメソッドで新旧配列の共通要素に対して属性が更新されている可能性があります。 既存の関連レコードと変更後の配列の差分をとり

  • 変更後の配列にないものは削除
  • 変更前の配列にないものは追加
  • 変更前後の配列に存在するものは上書き

した上で、対応するテーブルに書き込み、差分更新を実現しています。 以上から、Active Recordがどのように関連先のレコードを差分更新するかが分かりました。

まとめ

今回、has_manyな関連先の更新時に Active Record が差分更新を行う挙動について見てきました。 この機能を知る前に自前で実装していた処理が、Active Record 内にすでに組み込まれており、同様の要件であれば Active Record の機能を素直に使用する方が、コードをシンプルに保てることが分かりました。 事前にドキュメントをしっかり読んだり、直感的に使用していれば、そもそも自前で差分更新のロジックを書く必要はなかったかもしれません。 ただ、Active Record がどのように差分更新を行うのかを深掘りできたことで、今後自信を持って活用できるようになる良い機会になりました。

参考

【GraphQL】エラーレスポンスの設計戦略

はじめに

こんにちは、PlexJob開発チームの栃川です。

最近、チーム内でGraphQLのエラーレスポンスについて議論する機会がありました。

そこで今回は、GraphQLにおけるエラーレスポンスの考え方や設計戦略について、改めて整理してみたいと思います。

想定している読者

  • GraphQLを触ったことがある方
  • GraphQLのエラーレスポンス設計について整理したい方

GraphQLのエラー

まず、GraphQLのエラーにはどのようなものがあるのでしょうか?

GraphQLの仕様書を確認したところ、下記いずれかのエラーに分類されるようです。

No エラーケース ステータスコード
1 JSON解析エラー 400 (Bad Request)
2 無効なパラメータ 400 (Bad Request)
3 クエリの解析エラー 200 (OK)
4 クエリの検証エラー 200 (OK)
5 オペレーション失敗 200 (OK)
6 変数の型エラー 200 (OK)
7 実行中のフィールドエラー 200 (OK)

(※ 上記はapplication/jsonの場合であり、application/graphql-response+jsonでは取り扱いに違いがある点に注意。くわしくはこちら

これらのエラーは下記2つに分類されます。

Request Errors

  • 表のID 1~6 に該当
  • GraphQLの文法エラーやリクエストパラメータの問題により、クエリの実行前 に発生するエラー
  • 主にクライアントのリクエストミスが原因

Field Errors

  • 表のID 7 に該当
  • 特定のフィールドの実行中 に発生するエラーで、値の解決 (resolve) や結果値の出力に失敗した場合などに起こる
  • GraphQLサーバー側の不具合が原因で発生することが多い
  • フィールドエラーが発生しても処理は継続され、部分的な結果が返される

参考: GraphQL Spec, GraphQL Serverとエラー処理

このように、一口に「エラー」といっても、それぞれに異なる特性があります。

後述するエラーレスポンスの設計においては、 Request ErrorsとField Errorsによる分類が直接の意思決定に関わるわけではありません。 しかし、これらの分類を理解しておくことで、エラーレスポンスを整理しやすくなり、適切な対応を検討しやすくなるため、今回紹介させていただきました。

エラーレスポンス設計の選択肢

では、前項で見たエラーをどのようにしてクライアントに伝えればよいのでしょうか?

GraphQLのエラー設計には、大きく以下の3つのアプローチがあります。

  • 標準仕様に従う
  • Errors as data
  • Union / Result Types

それぞれの方法にはメリットとデメリットがあり、ユースケースやプロジェクトの要件によって適切な選択が求められます。 以降の節では、これら3つの方法について具体的に紹介していきます。

標準仕様に従う

GraphQLの標準仕様では、いかなるエラーであってもerrorsフィールドにメッセージを格納して、クライアントに送信するようになっています。

GraphQLのエラーは本来、例外的な事象やクライアント関連の問題を表すために設計されており、開発者向けのエラーメッセージを伝えるものとされています。そのため、必ずしもエンドユーザーに伝えるべきプロダクトやビジネス上のエラーが errorsフィールドに含まれるとは限りません。

GraphQL errors were originally designed to represent exceptional events and client-related issues, not necessarily expected product or business errors that need to be relayed to the end-user.

引用: Production Ready GraphQL

例えば、以下のような形でエラーレスポンスが返却されます。

{
  "errors": [
    {
      "message": "System is not available.",
      "extensions": {
        "code": "INTERNAL_SERVER_ERROR"
      }
    }
  ],
  "data": {
    "user": {
      "name": "plexjob",
    }
  }
}

参考: GraphQL Spec, 公式ドキュメント

メリットは、標準仕様であるがゆえに、多くのGraphQLクライアントライブラリでサポートされており、手軽に実装できます。

デメリットは、クライアント側でどのようなエラーが返ってくるのかわかりにくくなる可能性があるという点です。 冒頭で紹介したRequest ErrorやField Errorなどの分類に関係なく、すべてのエラー内容をerrorsフィールドに含めてしまうため クライアント側では、どのようなエラーが返ってくるのかわかりにくくなります。

GraphQL Specの作者も、GraphQLのエラーレスポンスは「システムエラー(※1)」を扱うべきで、エンドユーザーによって引き起こされるような「業務エラー(※1)」はデータとして返すべきと述べています。

The general philosophy at play is that Errors are considered exceptional. Your user data should never be represented as an Error. If your users can do something that needs to provide negative guidance, then you should represent that kind of information in GraphQL as Data not as an Error. Errors should always represent either developer errors or exceptional circumstances (e.g. the database was offline).

引用: Validations that cannot be ran on the client side and the errors object · Issue #117 · graphql/graphql-spec · GitHub

また、後述する2つの手法とは異なり、スキーマ外でエラーを管理するため、宣言的な定義ができない点も、エラーの返却内容が予測しにくくなる原因となっています。

※1: システムエラーと業務エラーについて

Errors as data

Errors as dataは、「エラーもデータとしてクライアント側に示すべきだ」という考えのもと、ペイロード型(Payload Type)にエラーを示すフィールドを追加してエラーメッセージを表現します。

例えば、以下のようなスキーマ例では、SignUpPayload に userErrors フィールドが定義されており、エラーの詳細を格納できるようになっています。

type SignUpPayload {
  userErrors: [UserError!]!
  account: Account
}

type UserError {
  # The error message
  message: String!

  # Indicates which field cause the error, if any
  #
  # Field is an array that acts as a path to the error
  # Example:
  #
  # ["accounts", "1", "email"]
  #
  field: [String!]

  # An optional error code for clients to match on.
  code: UserErrorCode
}

Shopify GraphQL Design Tutorial においても同様の手法が取られていました。

メリットとしては、スキーマで管理できるため、クライアント側がどんなエラーメッセージを受け取るのか予測しやすいという点があります。また、独自のエラーレスポンスのデータ構造を定義できるため、必要な情報を柔軟に設計できます。さらに、userErrors を標準化することで、クライアント側は常に同じ構造でエラー処理を実装できるため、コードの可読性や保守性が向上します。

デメリットとしては、クライアントが userErrors フィールドを明示的にクエリしなければ、エラー情報を取得できないことです。その結果、意図せず null のデータだけを受け取り、なぜ失敗したのか分からない状況に陥る可能性があります。つまり、エラーを正しく処理するために、開発者が常に userErrors を意識する必要があるため、認知負荷が高まるという課題があります。

Union / Result Types

Union / Result Typesは、エラー専用のフィールドを設ける代わりに、Union型を利用して成功時とエラー時の異なる結果を表現する方法です。

例えば、以下のようなスキーマでは、SignUpPayload を SignUpSuccess、UserNameTaken、PasswordTooWeak という複数の型で構成しています。

type Mutation {
  signUp(email: String!, password: String!): SignUpPayload
}

union SignUpPayload =
  SignUpSuccess |
  UserNameTaken |
  PasswordTooWeak

type SignUpSuccess {
  account: Account
}

type UserNameTaken {
  message: String!
  suggestedUsername: String
}

type PasswordTooWeak {
  message: String!
  passwordRules: [String!]!
}

クライアント側では、次のように結果ごとに適切な処理を実装できます。

mutation {
  signUp(email: "marc@example.com", password: "P@ssword") {
    ... on SignUpSuccess {
      account {
        id
      }
    }
    ... on UserNameTaken {
      message
      suggestedUsername
    }
    ... on PasswordTooWeak {
      message
      passwordRules
    }
  }
}

メリットとしては、Union型を使用することで、成功パターンとエラーパターンを型として明確に分離できます。その結果、クライアント側はそれぞれのケースに対して適切な処理を実装しやすくなります。また、各エラーシナリオにカスタムフィールド(例: suggestedUsername や passwordRules)を追加できるため、エラーの表現力が豊富です。

デメリットとしては、クライアントがすべての可能な型に対して明示的にフラグメントを定義しなければ、エラー情報を取得できないことです。また、エラーごとに個別の型を定義する必要があるため、スキーマが肥大化しやすくクエリも煩雑化しやすいという課題もあります。

どの設計方針を選ぶべきか

ここまでの内容を踏まえて、各エラーレスポンスの設計手法のメリデリを整理します。

手法 メリット デメリット
標準仕様に従う ・手軽に実装できる ・どのようなエラーが返ってくるか予測しにくい
スキーマ外で管理される
Errors as Data ・独自のエラーデータ構造を定義できる ・クライアント側での認知負荷が高い
Union / Result Types ・成功とエラーを型として明確に分離できる(強い型付け) スキーマの肥大化・クエリの煩雑化

では一体、どのような設計戦略をとればいいのでしょうか?

Production Ready GraphQL』では下記のような記述がありました。

Honestly, as long as “user errors” are well defined in the schema, I don’t have a strong opinion on how they should be implemented.

一概に「これが正解」という明確な答えはないと述べつつも、 この言葉からは、「システムエラー」と「業務エラー」を明確に分離し、特に「業務エラー」はスキーマで管理された形で実装すべきだという強いメッセージが感じられます。

つまり、重要なのは「業務エラーをスキーマ上で明確に定義し、クライアントが正しく扱える状態にすること」である、という考え方が根底にあるようです。

設計戦略に関する考察

ここからは私個人の意見です。

私としては、現実としてチーム全員がGraphQLに精通しているとは限らず、新しいメンバーがジョインした際の学習コストやオンボーディングの負担を考慮すると、標準仕様に沿ったシンプルな実装が有効な場合もあると考えています。

また、アプリケーションの性質によっては、複雑なエラーハンドリングが不要なケースも少なくありません。たとえば、内部向けのツールや小規模なAPI、あるいは短期間での開発が求められるプロジェクトでは、実装の手軽さや保守のしやすさが優先されることもあります。

さらに、既存のGraphQLクライアントライブラリやツール群が標準仕様に最適化されていることも多く、追加のカスタマイズが不要な点は大きな利点です。こうした観点から、標準仕様に沿ったシンプルなエラーハンドリングも十分に実用的な選択肢であると言えるのではないかと思います。

実際、「標準仕様に従う」という意思決定(あるいは検討)をしている企業様も見受けられました。私が所属するチームにおいても、この手法を採用した実績があります。

techblog.zozo.com

zenn.dev

結局のところ、「これが正解」という唯一の答えは存在しません。 最終的に、「どの手法を採用するか」ではなく、「その選択がチームとプロジェクトにとってどのような価値をもたらすか」を基準に判断することが、納得感のある意思決定につながるのではないかと考えています。

まとめ

今回は、GraphQLのエラーレスポンスについて自分なりに整理してみました。

どの手法にもメリット・デメリットがあり、プロジェクトの規模やチームの状況に応じて最適な方法を選ぶことが重要だと感じました。

変化の激しい開発現場で、より良い意思決定をするためにも、各手法の特徴を理解し、整理しておくことが大きな助けになるはずです。

このブログが、皆さんの開発に少しでも役立てば幸いです!

参考文献

主に下記資料を参考にさせていただきました。 ありがとうございます!

Production Ready GraphQL

GraphQL Serverとエラー処理

GraphQL Spec

最後に

現在プレックスでは、ソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーを募集しています。

少しでも興味を持っていただけた方は、カジュアル面談だけでも大歓迎です!

ぜひお気軽にご連絡ください! 🚀

dev.plex.co.jp

【入社エントリ】プレックスに入社しました!

はじめまして、プレックスの田中と申します。 2024年11月に株式会社プレックスにエンジニアとして入社しました。

入社して2ヶ月半が経過しましたので、自己紹介や入社の経緯、実際に働いてみた感想をお伝えしたいと思います!

自己紹介🎉

エンジニアになる前は、証券会社で株式や投資信託の売買、店舗向け商材の提案営業を行っていました。 また、飲食店ではホールと企画の業務を担当していました。 コロナ禍をきっかけに、飲食業界でのキャリアに対して将来性の不透明さを感じるようになり、今後のキャリアについて改めて考える機会がありました。

その中で、店舗向け商材の営業時にIT関連の商材を扱っていた経験や、飲食店でPOSレジを導入した経験から、IT技術によって生活の利便性を向上させられることに興味を持ち、「作る側」に回りたいと考え、エンジニアの道を志しました。

エンジニアとして最初に入社した会社では、 Laravel を用いたオンライン講座サービスの開発やWordPress や Bootstrap を活用したコーポレートサイトのコーディングを担当しました。

前職(エンジニア2社目)では、受託開発のプロジェクトで React を使用したフロントエンドの開発を行い、加えて PMOとしてプロジェクト管理業務も担当していました。

プレックスへの入社理由🔥

転職の際の軸として、以下の2点を重視しました。

  1. 事業のターゲット市場とプロダクトの成長性
  2. 自身のフロントエンド技術との親和性

この基準にプレックスが合致していたことが大きな理由です。

また、これまでフロントエンド中心の業務を担当してきたため、バックエンドの経験が不足していましたが、プレックスではそこも加味して受け入れて頂いたのが、決め手の一つとなりました!

入社してからの感想✨

入社してから感じたのは、エンジニアの技術レベルの高さです。 エンジニアチームは3つあり、入社初月にはエンジニアチーム全員がLT会を実施し、12月にはアドベントカレンダー企画が行われるなど、アウトプットを積極的に行う文化があります。 こうした取り組みを通じて、自分もより一層成長しなければと刺激を受けました!

プレックスジョブチームでは、コミュニケーションが活発であり、新しいチャレンジを歓迎する文化もあります。そのため、仕事を進めやすい環境に感謝しています!

また技術的負債の解消にも力を入れており、「Renovateを活用した依存関係の更新」、「ボトルネックになっている技術的負債の開発issue化」といった取り組みも積極的に行われています。

オンボーディング改善⚡️

入社後の最初の1ヶ月間はオンボーディング期間が設けられており、その後1スプリント分(1週間)の期間で、オンボーディング期間中に見つかった課題をテーマに改善を行います。 私が取り組んだのはリリースノート機能の実装です!

実装の背景として、以下の理由が挙げられます。

  • リリースした内容を一覧で視覚的に確認でき、整理しやすくなる。
  • バグが発生した際に直近のリリースの振り返りをしやすくなったことで、原因の特定がしやすくなる。

実装については、【Github Actions】リリースノートとタグを自動生成するを参考にしました。

簡単に説明すると、Github Actionsを利用し、特定のブランチにマージした際に、リリース内容をまとめたノートを自動作成する機能になります。 Githubで登録したタグとマージされたブランチ名の整合が合っていれば、タイトルの下にコミットメッセージがリリースした内容として表示されるようになります。

現在の課題は、各リポジトリにリリースノートを設定しているため、それぞれを個別に確認する手間があります。 そのため、統合用リポジトリを作成し、リリースノートの確認を一元化したいと考えています。 今後はこのリリースノートを活用し、スプリントごとにリリース内容を報告する場を設けていきたいと考えています!

最後に🚀

プレックスでは、ドライバー向けの求人サービスプレックスジョブの開発に携わっています。 現在はフロントエンドを軸としながらも、フルスタックエンジニアとしての成長目指しています。 中長期的には、ビジネス視点を持ちながらプロダクトの成長を推進する「技術に強い PdM」としてのキャリアも視野に入れており、 プレックスのミッションである「日本を動かす 仕組みを作る」を実現させていきたいと考えています!

最後にはなりますが、現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーの募集もあります。 もしこの記事を読んで、一緒に熱く働いてみたいと思った方がいましたら是非ご連絡をお待ちしています!

dev.plex.co.jp

【Gem Pundit】Punditの内部実装で学んだこと

この記事は、 PLEX Advent Calendar 2024の24日目の記事です。

はじめに

2024年に株式会社プレックスにエンジニアとして新卒入社した佐藤祐飛(@yuhi_junior)と申します。

業務でGem Punditを利用する機会があり、その内部実装を読んで学びが多くあったため本ブログではそちらを共有したいと思います。

Punditとは

PunditはRuby on Railsのための認可ライブラリで、Policy Objectデザインパターンを簡単に実装することができます。

Punditの使用例

class ApplicationController < ActionController::Base
  include Pundit::Authorization
  ...
end
class PostPolicy
  attr_reader :user, :post

  def initialize(user, post)
    @user = user
    @post = post
  end

  def update?
    user.admin? || !post.published?
  end
end
def update
  @post = Post.find(params[:id])
  authorize @post
  ...
end

Punditが内部で行なっていること

Punditがauthorizeメソッドで行っていることは大きく3つ存在します。特に3つ目はRubyメタプログラミングを利用しているので、その部分を中心に解説していきます。

  1. Policyクラスのインスタンスをキャッシュ
  2. Punditユーザーが定義したPolicyクラスのメソッドを実行
  3. authorizeメソッドの引数に渡されたインスタンスに対応するPolicyクラスを動的に特定

内部実装

今回は上記のPunditの利用例についての内部実装を見ていきます。

Pundit::Authorization#authorize

Postモデルの例におけるコントローラー内で実行されているauthorizeメソッドで、recordには@postが代入されます。action_nameメソッドは実行されているコントローラーのアクション名を返します。その結果queryにはupdate?が代入されます。policy_classにはnilが代入されます。具体の実装はPundit::Authorization#punditメソッドが返すオブジェクトのauthorizeメソッドに定義されています。

def authorize(record, query = nil, policy_class: nil)
  query ||= "#{action_name}?"

  @_pundit_policy_authorized = true

  pundit.authorize(record, query: query, policy_class: policy_class) 
end

https://github.com/varvet/pundit/blob/main/lib/pundit/authorization.rb#L84

Pundit::Authorization#pundit

Pundit::Authorization#punditメソッドはPundit::Contextクラスのインスタンスを返します。Pundit::ContextについてはContextとはで解説します。pundit_userはコントローラー内でcurrent_userメソッドを実行した結果が入ります(実装)。policy_cacheには権限判定のキャッシュに利用する空のハッシュが内部的に定義されます(実装)。これはPunditが内部で行なっていることの2つ目に該当します。

def pundit
  @pundit ||= Pundit::Context.new(
    user: pundit_user,
    policy_cache: Pundit::CacheStore::LegacyStore.new(policies)
  )
end

https://github.com/varvet/pundit/blob/main/lib/pundit/authorization.rb#L33

Pundit::Context#authorize

possibly_namespaced_recordには@postが代入されており、pundit_modelメソッドでは今回はそのまま@postを返します(実装)。possibly_namespaced_というプレフィックスがついているのはPunditがPolicy Namespacingという機能を提供しているためです。

policy!メソッドはpossibly_namespaced_recordから動的にPolicyクラスを特定し、そのインスタンスを返し、policyに代入します。その後、policy.update?メソッドを実行し、権限判定を行います(Punditが内部で行なっていることの2つ目に該当)。もし権限がない場合はNotAuthorizedErrorが発生します。

def authorize(possibly_namespaced_record, query:, policy_class:)
  record = pundit_model(possibly_namespaced_record)
  policy =
    ...
    policy!(possibly_namespaced_record)
  end

  raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)

  record
end

https://github.com/varvet/pundit/blob/main/lib/pundit/context.rb#L56

Pundit::Context#policy!

cached_findメソッドに対してrecord&:policy!を引数として渡しています。cached_findメソッドの内部のyieldの第一引数をレシーバとしてpolicy!メソッドが実行されることになります。このcached_findメソッドはPunditが内部で行なっていることの3つ目に該当します。

def policy!(record)
  cached_find(record, &:policy!)
end

https://github.com/varvet/pundit/blob/main/lib/pundit/context.rb#L86

Pundit::Context#cached_find

Pundit::Contextインスタンス作成時に定義されたpolicy_cachefetchメソッドを叩きます。recordをキーとしてキャッシュが存在すればそのPolicyインスタンスを返し、キャッシュが存在しなければブロックが実行されます(実装)。ブロック内部ではまず、policy_finderメソッドを実行して、recordに対応するPolicyを特定するためのPolicyFinderインスタンスを取得します(実装)。その後、Pundit::Context#policy!で述べたとおり、yieldによってPolicyFinderインスタンス.policy!が実行されます。modelには@postがそのまま代入され、Policyクラスのインスタンスが返されます。

def cached_find(record)
  policy_cache.fetch(user: user, record: record) do
    klass = yield policy_finder(record)
    
    ...

    model = pundit_model(record)

    begin
        klass.new(user, model)
    rescue ArgumentError
        raise InvalidConstructorError, "Invalid #<#{klass}> constructor is called"
    end
  end
end

https://github.com/varvet/pundit/blob/main/lib/pundit/context.rb#L147

Pundit::PolicyFinder#policy!

policy!メソッドは実質policyメソッドを実行しています。findメソッドでいよいよPolicyクラスを特定します。

def policy
  klass = find(object)
  klass.is_a?(String) ? klass.safe_constantize : klass
end

https://github.com/varvet/pundit/blob/main/lib/pundit/policy_finder.rb#L46

Pundit::PolicyFinder#find

findメソッドはfind_class_nameメソッドを実行して、Policyクラス名を特定します。find_class_nameメソッドの内部でsubject.classが実行され、@post.classが返されます。その後、find_class_nameメソッドの結果にSUFFIXが付与され、PostPolicyが返されます。

def find(subject)
  ...
  else
    klass = find_class_name(subject)
    "#{klass}#{SUFFIX}"
  end
end

def find_class_name(subject)
  ...
  else
    subject.class
  end
end

https://github.com/varvet/pundit/blob/main/lib/pundit/policy_finder.rb#L86 https://github.com/varvet/pundit/blob/main/lib/pundit/policy_finder.rb#L110

Contextとは

僕が初めてこのコードを読んだ時はContextって何?Pundit::AuthorizationにそのままContextクラスの内容を定義すれば良いのではないか?と思いました。

Punditのレポジトリを漁ると以下のようなCOMMITを見つけました。

元々はPundit::Authorization#authorizeにおいてPundit.authorizeを叩いていましたが、Pundit::Contextクラスを導入する変更がなされています。また、Pundit.authorizeでもPundit::Context#authorizeを呼び出すように変更されています。

+ def pundit
+   @pundit ||= Pundit::Context.new(
+     user: pundit_user,
+     policy_cache: policies
+   )
+ end

  def authorize(record, query = nil, policy_class: nil)
    query ||= "#{action_name}?"

    @_pundit_policy_authorized = true

-   Pundit.authorize(pundit_user, record, query, policy_class: policy_class, cache: policies)
+   pundit.authorize(record, query: query, policy_class: policy_class)
  end
- def authorize(user, possibly_namespaced_record, query, policy_class: nil, cache: {})
-   record = pundit_model(possibly_namespaced_record)
-   policy = if policy_class
-     policy_class.new(user, record)
-   else
-     cache[possibly_namespaced_record] ||= policy!(user, possibly_namespaced_record)
-   end
-   raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)
-   record
+ # @see [Pundit::Context#authorize]
+ def authorize(user, record, query, policy_class: nil, cache: {})
+   Context.new(user: user, policy_cache: cache).authorize(record, query: query, policy_class: policy_class)
  end

https://github.com/varvet/pundit/commit/9045680e4faef2ee87a2273c09d7d77d7d024f6e

Contextの導入によってPundit全体のインターフェースを薄くすることに成功しています。具体的には以下の箇所で、userとcacheをContextに保持することで、authorizeメソッドの引数が減り、authorizeメソッドの呼び出し側のコードが簡潔になります。また、PunditにはScopesPolicy Namespacingなどの機能もあり、それらの機能もContextに閉じ込めることで、Punditのインターフェースを薄くすることに成功しています。userとcacheをContextの責務と捉えてDRY原則を体現していると言えます。

-   Pundit.authorize(pundit_user, record, query, policy_class: policy_class, cache: policies)
+   pundit.authorize(record, query: query, policy_class: policy_class)

Punditの内部実装とデザインパターン

Punditの内部実装を読んでいくと、StrategyパターンとService Locatorパターンが使われていることがわかりました。

Policyクラスに定義されているupdate?などの認可メソッドはqueryとして利用されており、StrategyパターンにおけるStrategyに該当します。Pundit::ContextはそのままContextに該当します。

Pundit::PolicyFinderはPolicyクラスを特定するためのクラスであり、Service Locatorパターンに該当します。

まとめ

Gem Punditの内部実装を読んでいきました。Rubyらしさが溢れていて読むのが楽しく、Rubyのコードリーディング力が向上した気がします。今後もOSSのコードリーディングを続けていきたいなと思います。

ActiveJobのloggerメソッドをスタブするはなし

こんにちは、Plex Job開発チームの種井です。

この記事は、 PLEX Advent Calendar 2024の23日目の記事です。

私の書いた前回前々回の記事でSemantic Loggerを使用してRailsアプリケーションから出力されるログを構造化する取り組みについて紹介しました。 今回はSemantic Loggerを使用して開発を行う中で、ログイベントのテストを作成する機会がありました。 その上で、いくらか工夫したところがあったので紹介したいと思います。

また、Plex Jobでは

を使用しているため、サンプルコードについてもRailsおよびRSpecの使用を前提として説明しています。

背景

テストを作成していると、あるログイベントが発生したかどうかをテストしたいことがあります。 そのような場合には、Rails.loggerの各メソッドの呼び出しを検証することになります。 以下は、実装例となります。

class HomeController < ApplicationController
  def index
    # Does some stuff ...
    logger.info "Someone visited the site!"
    # Does some more stuff ...
  end
end
it "logs a message" do
  visit root_path

  expect(page).to have_content "Welcome to my site!"

  expect(Rails.logger).to receive(:info).with("Someone visited the site!")
end

everydayrails.com

Semantic Loggerでは、グローバルにRails.loggerを上書きする仕組み上、ログイベントを検証するにあたって、上記のような方法でログを検証することができません。 Semantic Loggerの公式ドキュメントにもあるように

context 'when it blows up' do
  let(:capture_logger) { SemanticLogger::Test::CaptureLogEvents.new } # ①

  it 'should should log the error' do
    allow_any_instance_of(MyThing).to receive(:logger).and_return(capture_logger) # ②
    MyThing.new('asdf').do_something!

    # ③
    expect(capture_logger.events.last.message).to include('Here is a message')
    expect(capture_logger.events.last.level).to eq(:error)
  end
end
  • SemanticLogger::Test::CaptureLogEventsインスタンスを作成する
  • ② 対象のメソッドをallow_any_instance_ofを使ってスタブする
  • SemanticLogger::Test::CaptureLogEventsに対してログイベントを検証する

必要があります。

加えて今回はActiveJobを使った非同期処理に対してテストを書く必要があったので、loggerメソッドをスタブしてSemanticLogger::Test::CaptureLogEventsインスタンスへと差し替える必要がありました。

以下は実装例です。

class SampleJob < ApplicationJob
  def perform(message)
    logger.info(message)
  end
end
require "rails_helper"  
  
RSpec.describe SampleJob, type: :job do
  describe "#perform_later" do
    let(:capture_logger) { SemanticLogger::Test::CaptureLogEvents.new }
      
    it "logs a message" do
        allow_any_instance_of(described_class).to receive(:logger).and_return(capture_logger)
          
        described_class.perform_now('message !!')
        expect(capture_logger.events.last.payload).to eq("message!!")
    end
  end
end

これでもログの出力をテストしたいという当初の目的は果たせますが、allow_any_instance_ofの使用はなるべく避けたいです。(RuboCopにも違反扱いされてしまいます) また、ActiveJobではloggerアクセサが定義されています。そのためRails.loggerではなくloggerメソッドを呼び出してログ出力を行うインターフェースであることも今回実装する上で考慮しなければならいないポイントでした。

今回は、allow_any_instance_ofを使用せずにActiveJobのloggerメソッドをスタブする方法を調査・実装したので紹介したいと思います。

ActiveJobのコードを見てみる

まずは、スタブする対象について調べてみることにします。 ApplicationJobはActiveJob::Baseクラスを継承しているため、そちらを見てみます。

module ActiveJob
    class Base
        include Core
        include QueueAdapter
        include QueueName
        include QueuePriority
        include Enqueuing
        include Execution
        include Callbacks
        include Exceptions
        include Instrumentation
        include Logging
        include ExecutionState
    
        ActiveSupport.run_load_hooks(:active_job, self)
    end
end

github.com

Loggingというモジュールをインクルードしています。

次にLoggingモジュールを見てみます。

module ActiveJob
    module Logging
      extend ActiveSupport::Concern

        included do
          ##
          # Accepts a logger conforming to the interface of Log4r or the default
          # Ruby +Logger+ class. You can retrieve this logger by calling +logger+ on
          # either an Active Job job class or an Active Job job instance.
          cattr_accessor :logger, default: ActiveSupport::TaggedLogging.new(ActiveSupport::Logger.new(STDOUT))

            ...
        end
        ...
    end
end

github.com

cattr_accessorlogger属性が定義されています。cattr_accessorはクラス属性のクラス・アクセサとインスタンス・アクセサの両方を定義するアクセサです。 また、cattr_accessormattr_accessorエイリアスです。

公式ドキュメントの例を借りてmattr_accessorの挙動を簡単に確認してみます。

module HairColors
  mattr_accessor :hair_colors # ①
end

class Person
  include HairColors
end

# ②
HairColors.hair_colors = [:brown, :black, :blonde, :red]
HairColors.hair_colors # => [:brown, :black, :blonde, :red]
Person.new.hair_colors # => [:brown, :black, :blonde, :red]
  • ① mattr_accessorによりhair_colorsクラス変数が定義され、同時にクラスアクセサとインスタンスアクセサの両方が定義される
  • ② アクセサを使って値の参照や代入を行うことができる

ここまでで、AcriveJob::Baseクラスがどのようにloggerを定義しているかがわかりました。

テンプレートメソッドであるperform メソッドはインスタンスメソッドなので、インスタンスアクセサとして定義されたloggerをスタブするとよさそうだとわかりました。

スタブする方法

ActiveJob::Baseクラスがどのようにloggerを定義しているかがわかったところで、早速対象をスタブしていきます。 ActiveJob::Base.new メソッドの返り値をスタブすることで実現することができます。

class SampleJob < ApplicationJob
  def perform(message)
    logger.info(message)
  end
end
require "rails_helper"

RSpec.describe SampleJob, type: :job do
  describe "#perform_later" do
    let(:capture_logger) { SemanticLogger::Test::CaptureLogEvents.new }
      
    it "logs a message" do

        instance = described_class.new('message !!')
        allow(described_class).to receive(:new).and_return(instance)
        allow(instance).to receive(:logger).and_return(capture_logger)
          
        described_class.perform_now('message !!')

        expect(capture_logger.events.last.payload).to eq("message!!")
    end
  end
end
  • ① あらかじめSampleJobのインスタンスをperform_nowと同じパラメータで作成しておく
  • ② SampleJobクラスの初期化メソッドをスタブし、①のインスタンスを返すようにする
  • ③ SampleJobのloggerインスタンスアクセサをスタブし、SemanticLogger::Test::CaptureLogEventsに差し替える

どちらを使うか?

ここまでで、allow_any_instance_ofを個別のインスタンスを指定する方法に書き換えることができました。 それぞれの書き方を改めて並べてみます。

allow_any_instance_ofを使う方法

require "rails_helper"  
  
RSpec.describe SampleJob, type: :job do
  describe "#perform_later" do
    let(:capture_logger) { SemanticLogger::Test::CaptureLogEvents.new }
      
    it "logs a message" do
        allow_any_instance_of(described_class).to receive(:logger).and_return(capture_logger)
          
        described_class.perform_now('message !!')
        expect(capture_logger.events.last.payload).to eq("message!!")
    end
  end
end

allow_any_instance_ofを使わない方法

require "rails_helper"

RSpec.describe SampleJob, type: :job do
  describe "#perform_later" do
    let(:capture_logger) { SemanticLogger::Test::CaptureLogEvents.new }
      
    it "logs a message" do
        instance = described_class.new('message !!')
        allow(described_class).to receive(:new).and_return(instance)
        allow(instance).to receive(:logger).and_return(capture_logger)
          
        described_class.perform_now('message !!')

        expect(capture_logger.events.last.payload).to eq("message!!")
    end
  end
end

allow_any_instance_ofを使う方が、一見するとコードとしては短くなり、使わない方に関してはやや冗長な書きぶりになってしまいます。

今回は、後者のallow_any_instance_ofを使用しない方法を採用することにしました。

というのも、前半に少し触れましたがallow_any_instance_ofrspec-mocks公式のドキュメントでも言及されているように推奨されていません。

  • rspec-mocksのAPIは個々のオブジェクトインスタンス向けに設計されているが、該当の機能はオブジェクトのクラス全体に対して作用するため意図しない挙動や可読性の低下を招いてしまう
  • この機能を必要とする時点で設計上の問題があることが多い。 テストがあまりに多くのことをしようとしすぎているか、テスト対象のオブジェクトが複雑すぎる可能性が高い
  • rspec-mocksの最も複雑な機能で、歴史的に最も多くのバグ報告を受けている

上記のような理由から、Semantic Loggerの公式ドキュメントで紹介されているテストコード上でのログイベントの検証方法を採用せず、個別のインスタンスをスタブすることにしました。

おわりに

allow_any_instance_ofを個別のインスタンスに対してスタブするように書き換えるだけと言えばそうなのですが、ActiveJobのBaseクラスやCoreクラスの仕様をはじめフレームワークの構造をまず理解した上でスタブ対象の特定を行う必要がありました。 同じようなケースでallow_any_instance_ofの書き換えを検討している方の参考になればと思います。

これがkintone開発の理想形!! 開発環境を劇的に改善した話

これがkintone開発の理想形!! 開発環境を劇的に改善した話

この記事は、 PLEX Advent Calendar 2024の21日目の記事です。

はじめに

こんにちは。コーポレートチームの山崎です。

ビジネスを加速させる kintone!!
kintone を加速させるプラグイン・カスタマイズ!!

本記事では、このプラグイン・カスタマイズ開発を加速させる最高の開発環境を作った話をしようと思います。

kintone.cybozu.co.jp

前提

この記事における 「kintone 開発」とは kintone のプラグインおよびカスタマイズの開発を指します。 JavaScript によるフロントエンド開発と捉えてもらって問題ありません。

cybozu.dev

cybozu.dev

背景

以前まではビジネスサイドのメンバーが中心となってkintone開発していました。
開発環境やコードの整備が不十分で「動けばヨシ!!」という思想の元、乱雑なコードが量産されていきました。

alu.jp

その結果、エンジニアには次々とバグ修正の依頼が飛んでくる状況に。
原因の特定や修正に時間がかかり、負担も大きくなっていました。

コーポレートチームのメンバー増員に伴い、この状況を改善するため、エンジニアが主体となって kintone 開発を進めていく方針となりました。

alu.jp

旧開発環境のツラかったところ

一応エンジニアが管理している kintone 用のリポジトリも存在していましたが、かなり不便でした。

バンドラが無い

  • モジュール管理やライブラリの追加ができない
  • 単一の JS ファイルのみで書かれていた

TypeScirpt が書けない

  • Vanila JS しか書けない...
  • 型が欲しい😢

複雑な UI を作るのが辛い

  • React や Vue も導入していなかった
  • createElement して再レンダリングまで自前で実装みたいな...

動作確認が大変

  • kintone が提供しているツールを使っていたが、アップロードに若干の時間がかかり、細かい動作確認時に不便

cybozu.dev

cybozu.dev

プラグインの作成が難しい

  • プラグインを作成する場合、フォルダ構成や必要なファイルが決まっている
  • 若干複雑なので新規プラグインの立ち上げに手間がかかる

cybozu.dev

とても快適に開発できる環境ではなかった...

alu.jp

開発環境を刷新したい!!

上記のツラさから kintone 開発は面倒くさいものという認識が広がりつつありました。

kintone は悪く無い... 石器時代のような環境が悪いんや...

しかし、裏を返せば
難しいことをせずとも、モダンな技術スタックを持ち込むだけで、劇的な効率化が可能ということです!
ローコスト・ハイインパクト!! やるしかない!!

alu.jp

モジュールバンドラを導入しよう

察しの良い方はお気づきかもしれませんが、上述したツラい点のほとんどは、モジュールバンドラを導入することで解決できます。
開発環境刷新に取り掛かったのは 2024年3月頃、モジュールバンドラ界隈は Vite が台頭していました。

blog.tech-monex.com

kintone は多数の開発 Tips を公開していますが、
当時 Vite を用いた開発に関する記事は存在しておらず、自分で頑張って作りました...

という話を書こうと思っていたのですが、最近 kintone が下記記事を公開していたので割愛します。公式を見てください。

cybozu.dev

とにかく...!!
Vite を導入したことで現代フロントエンジニアが満足に開発できる環境が整いました!!

  • ライブラリを追加できるようになった
  • React で簡単に複雑な UI を作成できるようになった
  • TypeScript で書けるようになった

alu.jp

誰でも簡単に kintone 開発ができるようにしたい!!

最低限のフロントエンド開発環境は整ったものの、kintone開発経験が無いエンジニアにとっては、いくつかの障壁が残っていました。

  • React は書けるけど、kintone 上でどう動かすの?
  • どうやって kintone にアップロードするの?
  • カスタマイズとプラグインって作り方違うの!?
  • etc...

alu.jp

カスタマイズ・プラグインの雛形を CLI から作成

kintone 開発リポジトリは Turborepo を使ったモノレポ構成になっています。(これも導入した)
そして、Turborepo にはジェネレータを定義してコード生成する機能が存在します。

turbo.build

プラグイン・カスタマイズの雛形を用意しておき、ジェネレータを定義して、CLI から一発で作成できるようにしました。

雛形にはkintone上でReactコンポーネントを表示する部分まで用意しているので、kintoneとの繋ぎ込みを気にせず、実装そのものに集中できるようになりました!

➜ yarn create:customize

? カスタマイズ名を入力してください。例: project-name customize-name
? 対象のkintoneアプリのIDを入力してください 100
? 開発用アプリがあればIDを入力してください 999
? 機能の概要を入力してください。例: ブログ記事用のテストだよ
>>> Changes made:
  • /apps/100/customize-name/package.json (add)
  • 6 files added
 -> /apps/100/customize-name/README.md
 -> /apps/100/customize-name/tsconfig.json
 -> /apps/100/customize-name/vite.config.ts
 -> /apps/100/customize-name/src/App.tsx
 -> /apps/100/customize-name/src/main.tsx
 -> /apps/100/customize-name/src/vite-env.d.ts (addMany)

>>> Success!
✨  Done in 38.38s.

CLI から GCS 経由でニアリアルタイムにアップロード

kintone から提供されている customize-uploader はアップロードに若干の時間がかかるため、細かい変更を反映させて動作確認するのは少し手間でした。

cybozu.dev

ホットリロードのような開発体験を目指して下記スクリプトを追加しました。

  • nodemon で変更検知&ビルド
  • gsutil で GCS にアップロード
  • concurrently で上記2つのコマンドを同時実行

kintone はURL指定でカスタマイズを利用することができるので、GCSにアップロードしたファイルを指定することで、高速かつ簡単にアップロードできるようになりました!

ただし、プラグインのアップロードにはブラウザの操作が必要なため、公式提供の plugin-uploader を使用しています。

nodemon.io

cloud.google.com

github.com

最高の開発環境ができた

難しい実装はせずに、モダン技術を取り込むだけでストレスの無い最高の開発環境が完成しました!!
やはり、流行りの技術をキャッチアップするのはとても大切だと感じました。

alu.jp

開発環境刷新後の効果

本当に誰でも kintone 開発できるようになった

業務委託の方や入社したばかりメンバーがスムーズに開発を始めることができるようになりました。
「誰でも簡単に kintone 開発ができるようにしたい!!」という目標を達成することができたし、「フロントエンド開発ができる = kintone 開発ができる」という状況を作ることができたのはとても大きいです。

大幅に開発効率が上がった

kintone 開発特有の障壁を取り除く事で、大幅に開発効率が上がりました。(冗談抜きで百倍効率化できたと感じる)
また、kintone 開発をできるエンジニアが増えたので、チーム全体としてのアウトプット量も増やすことができました。

kintone を利用した業務オペレーションの選択肢が広がった

旧環境では難しかったリッチな UI を簡単に作成できるようになったことで、業務オペレーションの選択肢が大幅に増えました。
ビジネスサイドのメンバーが考えるアイデアをより多く実現できるようになり、会社の成長に大きく貢献できるようになりました。

まとめ

最終的な開発環境の構成を簡単にまとめておきます。

  • Turborepo によるモノレポ構成
  • Vite によるバンドル
  • TypeScript で開発可能
  • CLI から雛形を生成可能
  • GCS 経由でニアリアルタイム更新反映

面倒臭いコト・効率が悪いコトを技術で解決する。エンジニア冥利に尽きますね。
みなさんの kintone 開発 tips もコメントで教えていただけると嬉しいです!

さいごに

現在プレックスではソフトウェアエンジニア、フロントエンドエンジニア、UIデザイナーを募集しています。

一緒に働いてみたいと思った方がいましたら、是非ご連絡をお待ちしています!

dev.plex.co.jp

オンプレRedashの運用Tips8選

こんにちは、プレックスの石塚です。この記事は、 PLEX Advent Calendar 202420日目の記事です。

プレックスではBIツールとして2年ほど前からRedashを使用しています。今回の記事ではRedashの簡単な紹介とオンプレで運用する上でのTipsを8つ紹介します。

以前はMetabaseというBIツールを使用していたのですが、データ活用が思ったより進まず、Redashに移行する流れとなりました。このあたりの話もいずれ機会があればブログにまとめたいと思います。

Redashとは?

RedashPython製のオープンソースとして作られたBIツールです。自分が個人的に感じるRedashの最大の特徴はエンジニアフレンドリーであることです。下記の画像のように直接SQLを書いて、それを即座にグラフの形にビジュアライズできるため、エンジニアにとって直感的に使えるBIツールとなっています(APIの利用も非常に簡単です)。

そんな便利なRedashですが、2020年にDatabricksに買収されたことをきっかけに、クラウド版のサービス終了とOSS開発の停止という状況になってしまいました。そのため、現時点でRedashを使うためには、自社でサーバーをホスティングしてオンプレの環境で動かすことが必要です。

redash.io

※2023年4月にコミュニティ主導のプロジェクトとして、OSS開発を再開するというアナウンスがありました。まだメジャーバージョンのリリースはありませんが、コントリビューションを見る限り着々と開発が進んでいるようです。

github.com

オンプレRedashの運用Tips8選

以前はクラウド版があったため、運用について考える部分が少なかったのですが、今はオンプレで動かすしか選択肢がないということで、運用を楽にするためのTipsをいくつか紹介していきます。Redashのバージョンは 10.1.0 を想定しています。

1. パスワードログインを無効にする

Redashではパスワードログイン、Googleログイン、SAMLの3つの認証方法をサポートしています。パスワードログインを有効にしてしまうと、Redash側で退職者の整理などのアカウント管理をしなければならなくなるため、運用のコストが増えてしまいます。

https://redash.io/help/user-guide/users/authentication-options/

ログイン方法はRedashの画面上( /settings/general )から設定が可能です。

2. グループを使ったデータソースの管理

Redashでは権限をグループ×データソース×アクション( Full Access or View Only )で管理できます。複数の事業部やチームからの利用を想定している場合は、初期からグループの設計をしておくとよいでしょう。

https://redash.io/help/user-guide/users/permissions-groups/

3. redashbotを導入する

redashbotは、Slack上でRedashのURLをメンションするとグラフを展開してくれるツールです。Slack標準のリマインダーを使って、定期でグラフを流すといったことも可能です。

https://raw.githubusercontent.com/yamitzky/redashbot/refs/heads/main/images/screenshot.png

以前はRedashと同じインスタンス内で docker run で動かしていたのですが、たまにプロセスが落ちることがあったため、Redashを動かしている docker-compose.ymlrestart: always オプションを付けて組み込んだところ、落ちなくなりました。

github.com

4. クエリの同時実行数を調整する

Redashの手動でのクエリの同時実行数はデフォルトで2になっています。1つの事業部で使用している場合は問題にならないかもしれませんが、複数の事業部で複数のデータソースに対してクエリを実行したいケースでは、クエリの同時実行数を上げたい場合があります。

これは docker-compose.yml から adhoc_worker環境変数である WORKERS_COUNT の数字で指定することで調整可能です。

  adhoc_worker:
    <<: *redash-service
    command: worker
    environment:
      QUEUES: "queries"
      WORKERS_COUNT: 4

5. Redashサイトの死活監視

オンプレで運用しているRedash上で、LIMITを付け忘れるなど取得するデータ量の多いクエリを投げてしまうと、サービスが落ちてしまうことが多々あります。プレックスではこれをすぐに検知できるように、Google Cloud MonitoringのUptime checksを使用して、Slackに通知しています。設定方法は下記のドキュメントを参照していただきたいのですが、簡単に死活監視を実現できます。

公開の稼働時間チェックを作成する  |  Cloud Monitoring  |  Google Cloud

死活監視の上で工夫しているポイントとしては、通知先をRedashの利用者が集まるSlackのチャンネルにしていることです。以前は開発者しかいないエラー通知部屋に通知していたのですが、利用するメンバーにもサービスが落ちていることが周知できた方が良い、落ちる原因がほとんどクエリ起因なのでクエリ実行者に自覚してもらいたい、ということで変更しました。

また通知のメッセージには直近のクエリの実行時間、結果行数を調査するクエリのURLを付与しており、原因のクエリの調査をしやすくしています。Redash内部のPostgreSQLに対して発行できるクエリなので、参考までに貼っておきます。

SELECT
    '<a target="_blank" href="https://{domain}/queries/' || q.id || '">' || q.id || '</a>' AS id,
    q.name,
    q.user_id,
    u.name AS user_name,
    q.is_archived,
    q.is_draft,
    q.tags,
    qr.id AS query_result_id,
    qr.runtime AS seconds,
    JSON_ARRAY_LENGTH(qr.data::json->'rows') AS rows,
    qr.retrieved_at + INTERVAL '9 hours' AS retrieved_at_jst
FROM
    query_results AS qr
JOIN
    queries AS q
ON
    q.query_hash = qr.query_hash
JOIN
    users AS u
ON
    u.id = q.user_id
WHERE
    qr.retrieved_at > NOW() - INTERVAL '1 hour'
ORDER BY retrieved_at_jst
LIMIT 1000;

6. 定期実行の死活監視

Redashには定期実行の機能がサポートされているのですが、プレックスで運用していたところ、定期実行が実行されていないというケースが稀にありました。そのため、サイト以外にも定期実行の死活監視も行っています。

方法としては、Railsのrakeタスクで定期実行をセットしたクエリの /api/queries/<id>/results APIを叩いて、クエリの最終実行時間(retrieved_at)がセットした時間内に行われているかをチェックしています。

https://redash.io/help/user-guide/integrations-and-api/api/

余談ですが、定期実行が失敗する根本の原因はRQ Schedulerのバージョンが古いことにあるようでした。コンテナの中に入って直接RQ Schedulerをバージョンアップしたところ、この問題は解決することができました。

https://github.com/getredash/redash/issues/5797 https://dev.classmethod.jp/articles/fix-redash-scheduler-error-by-rq-scheduler/

7. クエリのアーカイブ

Redashはクエリの検索機能の精度があまり良くなかったり、クエリを整理するためのフォルダ機能などがないためかクエリが乱立して探しづらいといった特徴があります。プレックスも例に漏れず、運用を開始して2年弱で約1,000個のクエリが作成されており、クエリを探すコストはどんどん上がっています。

命名やタグ付けのルールも定めていますが、いまいち浸透しきっていない状況です。そのため、2週間に1度、期間中に作成されたクエリを全部チェックして不要なクエリがあればアーカイブするという力技の作業を行っています。

UnpublishedなクエリのアーカイブなどはRedashの内部APIを使用すれば自動化できそうなので、部分的にやっていきたいと思っています。

8. いざという時のためのトラブルシューティング

実はRedashには内部のクエリの実行状況やキューの状況を確認できる管理画面が存在します。 /admin/status のURLから管理者権限を持っているユーザーのみアクセスできます。

クエリが実行中のまま終わらなくなってしまった、キューにクエリが溜まりすぎていて新しいクエリが実行できない等トラブルが発生した際の状況確認に上の管理画面は有用です。一方で管理画面から実行中のクエリを停止させる、キューの中身をクリアするといった機能は提供されていないため、Redashの内部APIを叩くかサーバーに入って作業する必要があります。

内部APIを叩く一例として、実行中のタスクを取得して、それを停止させるサンプルを載せておきます。

% curl -s "https://{domain}/api/admin/queries/rq_status?api_key={api_key}" | jq .queues.queries
{
  "name": "queries",
  "started": [
    {
      "id": "e891f8ff-10b4-4b6f-a6c6-982cda1efa6f",
      "name": "redash.tasks.queries.execution.execute_query",
      "origin": "queries",
      "enqueued_at": "2024-12-18T01:51:43.744",
      "started_at": "2024-12-18T01:51:43.764",
      "meta": {
        "data_source_id": 6,
        "org_id": 1,
        "scheduled": false,
        "query_id": "adhoc",
        "user_id": 1
      }
    }
  ],
  "queued": 0
}
% curl -X DELETE "https://{domain}/api/jobs/e891f8ff-10b4-4b6f-a6c6-982cda1efa6f?api_key={api_key}"
null%

このようにRedashは内部で使用しているAPIAPIキー( /users/me から生成可能)があれば叩けるようになっています。もちろんドキュメントは用意されていないので、Githubのコードを直接読んで、自己責任での実行をお願いします。キューの中身を削除する場合は直接Redisに入ってredis-cliからクリアするといったことも可能です。

おわりに

いくつかオンプレでRedashを運用するためのTipsをご紹介させていただきました。少しでも自社でRedashを運用している方の参考になれば幸いです。

コミュニティ主導のプロジェクトとして再稼働したRedashですが、これからの開発や新しいバージョンのリリースにも期待したいですね。