転職、2ヶ月経過して

2022年1月末に退職し、2月から新しい勤務先へと移り、約2ヶ月が経過しました。

サーバーサイドエンジニアとしてジョインしてます。

まだ、正直、自分に求められているものが何なのかはっきりしていませんが、 現場の開発フローが非常に悪い状況でしたので、CI/CDの整備を軸に、以下の開発業務の改善を行いました。

  1. 本番環境など各種環境へのデプロイを自動化
  2. テストコードを書く文化の浸透
  3. チーム単位で週毎の振り返りミーティングを実施

全部、開発する上で当たり前のことなのですが、やらないと前に進めないと思っているので。

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

実装方法

実際にkaminarigraphql-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を利用した際に引数としてpagelimitが自動でフィールドに定義されるようにします。 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
      }
    }
  }
}