転職、2ヶ月経過して
2022年1月末に退職し、2月から新しい勤務先へと移り、約2ヶ月が経過しました。
サーバーサイドエンジニアとしてジョインしてます。
まだ、正直、自分に求められているものが何なのかはっきりしていませんが、 現場の開発フローが非常に悪い状況でしたので、CI/CDの整備を軸に、以下の開発業務の改善を行いました。
- 本番環境など各種環境へのデプロイを自動化
- テストコードを書く文化の浸透
- チーム単位で週毎の振り返りミーティングを実施
全部、開発する上で当たり前のことなのですが、やらないと前に進めないと思っているので。
1. 本番環境など各種環境へのデプロイを自動化
ソースコードはオンプレ環境のgitlabで管理しており、以前はgitlab runnerを使って自動でデプロイしていたみたいなのですが、ある時を境にgitlab runnerが動作しなくなり、放置され、それ以降は、とある社員がメモを片手に手動でローカルからデプロイしていました。社内にメンテできるエンジニアがいなかったから、放置されていたみたいです。なので、ここは単純にgitlab runnerを動作させるようにし、今まで通りマージをhookに各種環境に自動デプロイするようにしました。
ただ、オンプレ環境のgitlabを利用している限り、gitlab / gitlab runnerのサーバ自体管理が必要になるので、githubへ移行中です。
少しでも人の手作業によるオペレーションミスや、オペレーションの属人化は無くしていきたいですね。
2. テストコードを書く文化の浸透
テストコードがないプロダクトをいくつか見てきました。が、結局、開発効率が落ちたりしており、書かいないことのメリットは書くコスト/時間の削減以外ないのではないかと思います。また、テストコードを書くことで、ロジックの質もテストを書くことを意識することで自然と良いコードになると思います(きっと多分
業務ではAPIサーバの開発をメインとしており、リポジトリには、ユニットテストがいくつか存在してました。ただ、ローカルで実行したところ、いくつかのテストが失敗したりしており、テストコード自体が正しいかどうか、誰も判別がつかない意味不明な状態で、なおかつユニットテストがCI上で実行されている形跡もありませんでした。
なので、完全に腐りきった既存ユニットテストのコードを一度全部削除し、ゼロから追加していくことにしました。
また、バックエンドチームは僕含めて4人なのですが、僕以外テストコードを書いた経験がない状態でしたので、同時にテストコードの書き方を共有しつつ、地道にテストコードを書く文化を浸透させていきました。
全てのテストを一気に追加することは、エンジニアのリソース/時間的にも現実的ではないので、テストコードの追加方針は以下にしました。
アプリケーションの実装アーキテクチャがMVCではなく、ADRを採用し実装しているため、業務ロジックとなるドメイン部分がはっきりしており、なおかつドメインとレスポンスがほぼ1対1となっているので、まずはドメイン部分のロジックをモックせずに大味なテストを用意することが大事なのかなと判断しました。ある程度、APIレスポンスのアサーションテストが充実してきたら、ドメイン部分のテストを拡充していく方針です。
また、当たり前ですが、新たに追加していくテストコードが腐らないようにCI上でテストが自動実行されるようにもしておきました。
副次的な効果として、テストコードがあることで、仕様の把握がしやすくなり、コードレビューもしやすくなりました。
3. チーム単位で週毎の振り返りミーティングを実施
現状の開発チームは、社員/業務委託合わせて10人ほどになります。バックエンド/フロントエンド(アプリ)と2チームに分け、それぞれ隔週毎に振り返りミーティングを実施するようにしました。主な目的は、コミュニケーションの促進、日々のヘイトを吐き出してもらい、ミーティング中に出た意見を吸い上げ、次のプロジェクト/スプリントで、吸い上げた意見に対するアクションを小さく始めたりして、開発プロセスを少しでもよりよくしていきたいからです。ダメだったり、効果がなければすぐやめて違うやり方をすればいいという方針で行ってます。また、堅苦しい雰囲気にもしたくないので、意見を言いやすい雰囲気を作るように心がけてます。
チームメンバーがおかしくないって?思っていることをカジュアルに共有することができ、開発プロセスで曖昧になっている部分を大小関わらず洗い出すことができるようになりました。以前よりもdiscord/slackでのコミュニケーションが活発になったような気がします。回を重ねるごとに、良い意見が出てくるようになっているので、今後も継続していきたいと思ってます。
振り返りをすると、ダメなところばっかり出やすいのですが、いいところもたくさん意見し、お互いに尊敬しつつチームビルディングできればなって思ってます。
graphql-rubyでkaminariを利用したページネーション実装について
背景
GraphQL のデフォルトで提供されているページングの方式が Relay スタイル(カーソルベース)であるため、
デフォルトのままでは、オフセットベースのページングを利用したアプリケーションに対応するには少々困ることがあります。
こちらの記事ではpagyを使った実装方法が紹介されており、そちらの記事を参考にkaminariを使ったオフセットベースのページネーションを実現してみようと思います。
といっても、ほぼ記事で紹介されてる内容のpagy
で処理している箇所をkaminari
に置き換えた感じになります。先人に感謝です。
実装後のゴール
Post
というモデルが存在すると仮定し、graphql-ruby
でリゾルバを定義すると以下のような実装になると思います。
connection_type
ではなく、collection_type
を呼び出すことで、kaminari
でページネーションできるようにします。
module Types class PostsResolver < GraphQL::Schema::Resolver type Post.collection_type, null: false def resolve ::Post.all end end end
実装方法
実際にkaminari
をgraphql-ruby
に導入していきましょう。
kaminari の処理を行うクラス
コントローラーでkaminari
を利用する時のようにinitialize
メソッドにてインスタンス化します。
また、Kaminari::paginate_array
を使って、nodes
が配列の場合も考慮しておきます。
併せて、後述するページネーション情報を@page_info
に定義します。
module Types class KaminariExtension attr_reader :page_info, :collection def initialize(nodes, page: nil, limit: nil) @collection = kaminarize(nodes).page(page).per(limit) @page_info = create_page_info end private def kaminarize(nodes) case nodes when ActiveRecord::Relation then nodes when Array then Kaminari.paginate_array(nodes) else raise "#{nodes.class} is not supported." end end def create_page_info { current_page: collection.current_page, is_first_page: collection.first_page?, is_last_page: collection.last_page?, has_next_page: collection.next_page, has_previous_page: collection.prev_page, total_pages: collection.total_pages } end end end
引数とフィールドを定義するクラスを定義
collection_type
を利用した際に引数としてpage
とlimit
が自動でフィールドに定義されるようにします。
resolve
メソッド内では、arguments
からpage
, limit
が渡ってくるので、その値を先ほど定義したTypes::KaminariExtension
クラスに渡します。
module Types class KaminariExtension < GraphQL::Schema::FieldExtension def apply field.argument :page, Integer, required: false, description: 'ページ番号' field.argument :limit, Integer, required: false, description: '1ページあたりの件数' end def resolve(object:, arguments:, **) args = arguments.dup page = args.delete(:page) limit = args.delete(:limit) obj = yield(object, args) ::Types::KaminariExtension.new(obj, page: page, limit: limit) end end end
kaminari
のページネーション情報を GraphQL オブジェクトとして定義
次のページが存在するかなどのページネーション情報をGraphQL オブジェクトとして定義します。
module Types class CollectionPageInfo < ::Types::BaseObject description 'ページネーション情報' field :current_page, Integer, null: false, description: '現在のページ番号' field :is_first_page, Boolean, null: false field :is_last_page, Boolean, null: false field :has_next_page, Integer, null: true, description: '次のページ番号' field :has_previous_page, Integer, null: true, description: '前のページ番号' field :total_pages, Integer, null: false, description: '全ページ数' end end
動的に GraphQL オブジェクトを作成するクラスを定義
GraphQLオブジェクトに対してcollection_type
を利用するとXxxCollection
というオブジェクトを動的に生成するクラスを定義します。
type Post.collection_type
とすることでPostCollection
というGraphQLオブジェクトを動的に生成します。
module Types class Collection < ::Types::BaseObject def self.create(type) Class.new(self) do graphql_name "#{type.graphql_name}Collection" field :collection, [type], null: false field :page_info, ::Types::CollectionPageInfo, null: false end end end end
BaseField の拡張
::Types::KaminariExtension
で定義したフィールドが、自動的に引数に定義されるようにTypes::BaseField
を拡張します。
::Types::KaminariExtension
で定義したpage
およびlimit
が自動で定義されるようになります。
module Types class BaseField < GraphQL::Schema::Field argument_class Types::BaseArgument def initialize(**kwargs, &block) super return unless kwargs[:type].is_a?(Class) # Types::Collectionを継承したクラスでなければ、return return unless kwargs[:type] < ::Types::Collection extension(::Types::KaminariExtension) end end end
BaseObject に collection_type の実装
どのオブジェクトでもcollection_type
が使えるようにベースオブジェクトにクラスメソッドとしてcollection_type
を定義します。
module Types class BaseObject < GraphQL::Schema::Object edge_type_class Types::BaseEdge connection_type_class Types::BaseConnection field_class Types::BaseField def self.collection_type @collection_type ||= ::Types::Collection.create(self) end end end
結果
request
query Posts { Posts(page: 1, limit: 3) { collection { __typename id } pageInfo { currentPage isFirstPage isLastPage hasNextPage hasPreviousPage totalPages } } }
response
{ "data": { "Posts": { "collection": [ { "__typename": "Post", "id": "QnVpbGRlci04Mzk=" }, { "__typename": "Post", "id": "QnVpbGRlci04NDA=" }, { "__typename": "Post", "id": "QnVpbGRlci04NDE=" } ], "pageInfo": { "currentPage": 1, "isFirstPage": true, "isLastPage": false, "hasNextPage": 2, "hasPreviousPage": null, "totalPages": 2 } } } }