バックエンドから送信されている通知内容の把握を容易にするために行ったリファクタリング

Azit Inc. プロダクトチームの十亀(id:Pocket7878_dev)です。

課題

弊社のアプリケーションには、Android アプリアーキテクチャの紹介でもふれたように、 Push 通知を契機とした画面遷移が多数存在します。

今までは、送信されている通知のタイプや文面は PM と開発者たちがコードレビュー等を通して把握していれば良い状況でした。 しかし、今後サービスの安全性様々な面で担保していくためには、プロダクトチームだけではなく社内の様々なチームが連携していく必要があります。 そのため、今回は通知システムのリファクタリングを実施し、ユーザに届いている文面を管理画面等から容易に把握できる環境を整えました。

現状

f:id:Pocket7878_dev:20190220172723p:plain

現状のコードベースでは、通知ペイロードの構築に必要なデータを PushData という単位で定義しています。 そして、受け取った引数から PushData を生成するメソッドを多数定義している PushFactory クラスが存在しました。

また、呼び出しの窓口として PushService クラスが定義されており:

  1. 送信したい通知のタイプに対応するPushServicepublish_hogeメソッドをパラメータを渡して呼び出す
  2. PushServicePushFactoryhoge_dataメソッドをパラメータを渡して呼び出す
  3. 返ってきたPushDataの配信をAWS SMS, Pusher, PubNubといったバックエンドに委譲する

という構成になっていました。

以下はそのようなpublish_hogeを定義するために利用されていたメタプログラミングのコードです。

def self.publish_method(name)
  publish_method_name = "publish_#{name}"
  build_method_name = "#{name}_data"
  define_method(publish_method_name) do |destinaton_user, *data_args|
    factory = ::PushFactory::Base.new
    datum = factory.send(build_method_name, *data_args)
    pub = publisher(datum, destinaton_user)
    pub.send(:publish, destinaton_user, datum)
  end
end

# 通知のタイプごとに以下
publish_method :driver_start_transporting
...

そのため、既存のコード内では PushServicepublish_hoge メソッドの呼び出し箇所が多数存在します。 今回は、変更の規模を小さくするため、可能な限り呼び出し側の変更を避ける前提で改修することにしました。

PushFactory のメソッドから PushStrategy 子孫クラスの登録へ

f:id:Pocket7878_dev:20190311145309p:plain

今回のリファクタリングでは、PushFactoryのメソッドとして定義していた 通知タイプ毎のPushData生成を、共通のインタフェースを持つPushStrategyの子孫クラスとして定義しなおしました。 そして、定義された子孫クラスを、PushServiceに通知のタイプ名とペアで登録する形式に変更しました。

# config/initializer/push_service.rb
PushService::Strategies.add(
  :driver_request,
  PushService::Strategies::DriverRequestPush
)

そして、登録されたタイミングで、後述のメタプログラミングで 既存のpublish系メソッドと同じ命名のメソッドをPushServiceに自動定義する事で呼びだし元の変更を避けます。

Mustache テンプレートによる文面の雛形の分離

PushFactory のメソッド内では PushData のタイトル生成時に

"#{rider.last_name}からのリクエストです"

といった形で変数展開を直接おこなっていました。

しかし、今回のリファクタリングの目的は通知の文面確認の容易化なので、 Push Strategy では

  • タイトルのテンプレートを返す #title_tmpl
  • テンプレートに与える値を計算する#title_args

という2つのメソッドにタイトルの生成を分離しました。 このように分離する事によって、管理ページ上にはテンプレートを表示する事ができます。

これらのテンプレートは開発者以外のメンバーもチェックするため、複雑なロジックを含むテンプレートを許容したくありません。 そこで、今回はMustacheをテンプレートエンジンとして採用しました。

PushStrategy 登録のメタプログラミング

今回の要件では、現在登録されている通知の一覧を管理ページ等に表示する必要があるので、 登録されているタイプの一覧を実行中に取得可能でなくてはなりません。 そのため、登録されている通知のタイプと対応するストラテジの一覧をどこかに溜めおく必要があります。 そのような形式はユーザー認証で使っている warden の認証パターンの登録を参考にしました。

warden/strategies.rb at master · wardencommunity/warden · GitHub

# app/services/push_service/strategies.rb

module PushService
  module Strategies
    class << self
      def add(label, sklass)
        # Check required methods
        [:type].each do |required_method|
          unless sklass.method_defined?(required_method)
            raise NoMethodError, "#{required_method.inspect} is not declared in the #{label.inspect} strategy"
          end
        end

        base = PushService::Strategies::Base
        unless sklass.ancestors.include?(base)
          raise "#{label.inspec} is not a #{base}"
        end

        strategies[label] = sklass

        PushService::Base.send(:define_method, "publish_#{label}") do |destination_user, *data_args|
          if sklass.parameter.count != data_args.count
            raise ArgumentError.new("wrong number of arguments (given #{data_args.count + 1}, expected #{sklass.parameter.count + 1})")
          end

          fnarg = sklass.parameter.zip(data_args).to_h
          datum = sklass.new._build!(fnarg)

          publish(destination_user, datum)
        end
      end

      def [](label)
        strategies[label]
      end

      def strategies
        @strategies ||= {}
      end
    end
  end
end

このようにしておくことで、PushService::Strategies::strategies を取得し、管理ページ等で一覧表示する事ができます。

カスタム Generator の作成

上記の作業で既存の呼び出し側のコードを変更することなく差し替えることはできるのですが、 タイプ毎のクラスを手作業で作ってしまうと、各関数でのパラメータの受け取り方のミスなどにつながるため避けたいです。 そこで、カスタム Generator として push_strategy を追加して、PushStragegy のひな形となるファイルを自動生成するようにしました。

module PushService
  module Strategies
    class <%= class_name %>Push < Strategies::Base
      # TODO: app/services/push_services/strategies/base.rb を参考に実装してください
      <%- if attributes_names.size.positive? -%>

      def self.parameter
        <%= "%i[" + attributes_names.map{|a| "#{a}"}.join(' ') + "]" %>
      end

      <%- end -%>
      def type
        # FIXME 通知のタイプを設定してください
        # PushService::PushData::PushType::<%= push_type_const_name %>
      end

      def title_tmpl
        # FIXME 通知のタイトルがある場合は設定してください(必要なければ定義自体消してください)
      end

      def data(<%= attributes_names.map{|a| "#{a}:"}.join(', ') %>)
      end
    end
  end
end

これで、簡単に

$ bundle exec rails g push_strategy driver_deny rider_user car_allocation

PushStrategy のひな形が作成できるようになりました。

結果

このように作業を進めていくなかで、呼び出し側を修正しないという前提を曲げなくてはならない箇所もありました:

  • あたえられたデータで表示する文面を分岐している箇所
  • 汎用的な通知として利用されている箇所

上記のような箇所では通知の種類番号のみを共有した、タイプの異なる通知として個別に登録しなおしました。 それにともなって呼びだし元も修正が必要になりましたが、結果的には見通しが良くなり負債の解消になりました。

## 管理ページでの一覧

f:id:Pocket7878_dev:20190314171457p:plain
管理ページ内でのテンプレートの確認

このように、エンジニアでなくても管理ページ上で容易に文面の確認ができるようになりました。 また、プログラム上の見通しも改善し、レビューの際のチェックも楽になりました。