🍩

開発効率がUPするリファクタリング術!メンテナンス性を劇的に改善した「抽象化」設計とは

に公開

はじめに

こんにちは!イノベーション開発チームのm_satoです☺️

これまで弊社サービスのbizplayでは、サービスごとに処理が独立して書かれていたため、似たようなコードが量産されていました。
新しいサービスを追加するたびに類似機能であっても同じような実装をコピーして…という感じで、正直メンテナンス性が低い状態でした・・・。

そこで今回!!
新たにサービスを追加する機会があったので、思い切ってリファクタリングに踏み切ることにしました!✨

この記事では、この課題を解決するために、インターフェースを用いて処理を「抽象化」し、依存関係を整理することで、いかにして変更に強く、保守性の高いコードを実現したのかを解説します!

前提

対象読者

  • 過去に書いたコードとそっくりな実装を何度も見かけている方
  • 機能追加や仕様変更のたびに、修正箇所が多くて苦労している方
  • オブジェクト指向の「インターフェース」や「抽象化」の、実践的な使い方を知りたい方

環境

  • Laravel

bizplayのリード生成ってどんな機能か

今回、冒頭でもお話しした類似サービスの基盤となるのが、「リード生成サービス」です!まずリード生成機能とは?を簡単にお伝えします!

bizplayでは、動画の視聴時に同意してくれたユーザー情報を、動画を持つクライアントにリード提供という形で、ユーザー情報を提供するサービスがあります。

この他にも、イベント視聴予約があった際にリード提供するサービスや、ホワイトペーパーのダウンロード時にリードを提供するサービス等々...。

サービスは違うけど、「ユーザーからのアクションがあったタイミングで、そのユーザー情報がリード情報として生成される」という大枠の流れは同じです。という機能が複数存在していました。それをまとめてリード生成の機能として呼んでいます!

今までのコードはどんな感じ??

ざっと動画視聴時のリード生成を例にして、既存コードの処理の流れを簡単に説明します!

リード生成部分はApp/Services/SurveyAnswer/Create.phpにおいていたのでその中身のリファクタリング内容を今回はご紹介します!

※補足... SurveyAnswerは、動画視聴時のリード情報をsurvey_answersテーブルに入れており、その名称。

App/Services/SurveyAnswer/Create.php
App/Services/SurveyAnswer/Create.php
<?php

namespace App\Services\SurveyAnswer;

use App\Eloquent\SeminarClient;
use App\Eloquent\SurveyAnswer;
use App\Eloquent\SurveyAnswerStatus;
use App\Eloquent\User;


class Create
{
    /**
     * クライアント向けにリードを保存する
     * @param SeminarClient $seminarClient
     * @param User $user
     * @return SurveyAnswer
     */
    public function executeForClient(SeminarClient $seminarClient, User $user): SurveyAnswer
    {
        $status = $this->determineSurveyAnswerStatus(
            function () use ($seminarClient) {
                return $this->isNotDisplayClientReasonNotDelivery($seminarClient);
            },
            function () use ($user) {
                return $this->isNotDisplayClientReasonNotValidProfileUser($user);
            },
            // ・・・他条件が続く
        );

        return $this->saveSurveyAnswer($seminarClient, $user, $status);
    }

    /**
     * リードを保存する
     * @param SeminarClient $seminarClient
     * @param array $status
     * @param User $user
     * @return SurveyAnswer
     */
    public function saveSurveyAnswer(
        SeminarClient $seminarClient,
        User $user,
        array $status,
    ): SurveyAnswer {
        $surveyAnswer = app(SurveyAnswer::class)->fill([
            'seminar_client_id'         => $seminarClient->id,
            'user_id'                   => $user->id,
            'is_permitted_delivery'     => $status['is_permitted_delivery'],
            'is_blurred_profile'        => $status['is_blurred_profile'],
            'survey_answer_status_id'   => $status['survey_answer_status_id'],
            'not_display_client_reason' => $status['not_display_client_reason'],
            'invalid_reason'            => $status['invalid_reason'],
            'email'                     => $user->email,
            'first_name'                => $user->first_name,
            'last_name'                 => $user->last_name,
            'first_name_kana'           => $user->first_name_kana,
            'last_name_kana'            => $user->last_name_kana,
            'is_overseas'               => $user->is_overseas,
            'company_name'              => $user->company_name,
            'industry_name'             => $user->industry?->name,
            'department_category_name'  => $user->department_category?->name,
            'employee_size_name'        => $user->employee_size?->name,
            'position_name'             => $user->position?->name,
            'prefecture'                => $user->prefecture?->name,
            'address'                   => $user->address,
            'phone_number'              => $user->phone_number,
            'inbound_source'            => $user->inbound_source,
            'inbound_medium'            => $user->inbound_medium,
            'inbound_campaign'          => $user->inbound_campaign,
            'inbound_term'              => $user->inbound_term,
            'inbound_content'           => $user->inbound_content,
        ]);

        $surveyAnswer->save();

        return $surveyAnswer;
    }


    /**
     * リードのステータスを決定する
     * @param callable ...$getStatuses
     * @return array{
     *      is_permitted_delivery:bool,
     *      is_blurred_profile:bool,
     *      survey_answer_status_id:int,
     *      not_display_client_reason:string|null,
     *      invalid_reason:string|null
     * }
     */
    protected function determineSurveyAnswerStatus(callable ...$getStatuses): array
    {
        $default = [
            'is_permitted_delivery'     => true,  // クライアントに提供する許可をユーザーからとっているか?
            'is_blurred_profile'        => false, // 個人情報にモザイクをかけて表示するか?
            'survey_answer_status_id'   => SurveyAnswerStatus::TYPE_CHARGE,
            'not_display_client_reason' => null,  //クライアント画面に表示とする理由
            'invalid_reason'            => null,  // 無効理由
        ];

        foreach ($getStatuses as $getStatus) {
            $result = $getStatus();
            if ($result !== false) {
                return array_merge($default, $result);
            }
        }

        return $default;
    }

    /**
     * セミナーがリード提供していないステータスか?
     * クライアント画面に非表示(リード提供しない)
     * @param SeminarClient $seminarClient
     * @return array|false
     */
    protected function isNotDisplayClientReasonNotDelivery(SeminarClient $seminarClient): array|false
    {
        if ($seminarClient->is_delivery_available) {
            return false;
        }

        return [
            'is_permitted_delivery'     => false,
            'survey_answer_status_id'   => SurveyAnswerStatus::TYPE_NOT_DISPLAY_CLIENT,
            'not_display_client_reason' => SurveyAnswer::NOT_DISPLAY_CLIENT_REASON_NOT_DELIVERY,
        ];
    }


    /**
     * ユーザーのプロフィールに不備があるか?
     * クライアント画面に非表示(ユーザーのプロフィールに不備がある)
     * @param User $user
     * @return array|false
     */
    protected function isNotDisplayClientReasonNotValidProfileUser(User $user): array|false
    {
        if ($user->is_valid_profile) {
            return false;
        }

        return [
            'survey_answer_status_id'   => SurveyAnswerStatus::TYPE_NOT_DISPLAY_CLIENT,
            'not_display_client_reason' => SurveyAnswer::NOT_DISPLAY_CLIENT_REASON_USER_PROFILE_IS_INVALID,
        ];
    }
}

このような一連のソースコードを、他の新たなリード生成サービスができるたびに、新たにCreate.phpというクラスをServices配下に追加して〜、都度isNotDisplayClientReasonNotDeliveryのような複数の条件を書いて〜、、という風にその場しのぎでやってきてしまったのです...😭

これをいざリファクタリングします!

リファクタリング後のコードは??

まず大元となる、Create.phpは具象クラスとして、BaseLeadCreatorというリード生成の共通処理を定義する抽象クラスを作成しました。

app/Services/Lead/BaseLeadCreator.php
<?php

namespace App\Services\Lead;

use App\Services\Lead\Inspector\InspectorRegistryInterface;

abstract class BaseLeadCreator
{
    /**
     * @var string
     */
    protected string $saleDeliveryDetailHistoryText;

    public function __construct(
        protected InspectorRegistryInterface $inspectorRegistryInterface,
        protected LeadCreatableInterface $modelClass,
    ) {}

    /**
     * 実行
     *
     * @param LeadContext $context
     * @return LeadCreatableInterface
     */
    public function execute(LeadContext $context): LeadCreatableInterface
    {
        $lead = $this->resolveStatuses($context);

        return $this->createLead($context, $lead);
    }

    /**
     * リード生成
     *
     * @param LeadContext $context
     * @param [type] $lead
     * @return LeadCreatableInterface
     */
    public function createLead(LeadContext $context, $lead): LeadCreatableInterface
    {
        $leadClientKey = $context->leadClient->getLeadClientKeyName();

        $lead->fill([
            $leadClientKey              => $context->leadClient->id,
            'user_id'                   => $context->user->id,
            'email'                     => $context->user->email,
            'first_name'                => $context->user->first_name,
            'last_name'                 => $context->user->last_name,
            'first_name_kana'           => $context->user->first_name_kana,
            'last_name_kana'            => $context->user->last_name_kana,
            'is_overseas'               => $context->user->is_overseas,
            'company_name'              => $context->user->company_name,
            'industry_name'             => $context->user->industry?->name,
            'department_category_name'  => $context->user->department_category?->name,
            'employee_size_name'        => $context->user->employee_size?->name,
            'position_name'             => $context->user->position?->name,
            'prefecture'                => $context->user->prefecture?->name,
            'address'                   => $context->user->address,
            'phone_number'              => $context->user->phone_number,
            'inbound_source'            => $context->user->inbound_source,
            'inbound_medium'            => $context->user->inbound_medium,
            'inbound_campaign'          => $context->user->inbound_campaign,
            'inbound_term'              => $context->user->inbound_term,
            'inbound_content'           => $context->user->inbound_content,
        ]);

        $lead->save();

        return $lead;
    }

    /**
     * @param LeadContext $context
     * @return LeadCreatableInterface
     */
    protected function resolveStatuses(LeadContext $context): LeadCreatableInterface
    {
        // 判定条件一覧を取得
        $inspectors = $this->inspectorRegistryInterface->getInspectors();

        $lead = $this->modelClass->replicate();

        // ステータスのデフォルト値を入れる
        $lead->setDefaultStatus($context->leadClient, $context->user);

        // 順次判定実行
        foreach ($inspectors as $inspector) {
            if ($inspector->matches($context)) {
                return $inspector->set($lead); // 最初にマッチした条件で確定
            }
        }

        return $lead; // 全条件に該当しない場合はデフォルトステータス
    }
}

1. execute
実際にリード生成を実行するメソッドです。
引数のLeadContextは複合データオブジェクトで、リード生成に必要な全ての依存関係とデータを一元管理しています!
leadClientに当たる部分が、たとえばセミナーのリードなのであればseminarClientになったりするようなイメージです!

app/Services/Lead/LeadContext.php
<?php

namespace App\Services\Lead;

use App\Eloquent\User;
use App\Services\Utm\UtmParamsGetterInterface;

class LeadContext
{
    public function __construct(
        public readonly LeadClientInterface $leadClient,
        public readonly User $user,
        public readonly ?UtmParamsGetterInterface $utmParamsGetter = null,
    ) {}
}

2. resolveStatuses
ステータス解決を担っています。
複数の判定ロジック(Inspector)を順次実行し、最初にマッチした条件でリードステータスを確定する処理を行なっています!

各判定条件は共通インターフェースを実装しています。

app/Services/Lead/Inspector/InspectorRegistryInterface.php
app/Services/Lead/Inspector/InspectorRegistryInterface.php
<?php

namespace App\Services\Lead\Inspector;

interface InspectorRegistryInterface
{
    public function getInspectors(): array;
}

具現クラスでは以下のようにしています。
各サービス(セミナー、ホワイトペーパーなど)で異なる判定条件をこの中で組み合わせます!

app/Services/Lead/SurveyAnswer/Client/Inspector.php
<?php

namespace App\Services\Lead\SurveyAnswer\Client;

use App\Services\Lead\Inspector\InspectorRegistryInterface;
use App\Services\Lead\Inspector\InvalidProfileUserInspector;
use App\Services\Lead\Inspector\NoDeliveryInspector;

class Inspector implements InspectorRegistryInterface
{
    public function __construct(
        protected NoDeliveryInspector $noDeliveryInspector,
        protected InvalidProfileUserInspector $invalidProfileUserInspector,
        // ...他条件が続く
    ) {}

    public function getInspectors(): array
    {
        return [
            $this->noDeliveryInspector,
            $this->invalidProfileUserInspector,
            // ...他条件が続く
        ];
    }
}

具体的な判定条件の実装例は以下です

app/Services/Lead/Inspector/InvalidProfileUserInspector.php
app/Services/Lead/Inspector/InvalidProfileUserInspector.php
<?php

namespace App\Services\Lead\Inspector;

use App\Services\Lead\Inspector\InspectorInterface;
use App\Eloquent\User;
use App\Services\Lead\LeadContext;
use App\Services\Lead\LeadCreatableInterface;

class InvalidProfileUserInspector implements InspectorInterface, LeadStatusSetterInterface
{
    /**
     * ユーザーの登録情報がリードとして不適当かを判定する
     * @param User $user
     * @return bool
     */
    public function matches(LeadContext $context): bool
    {
        return !$context->user->is_valid_profile;
    }

    /**
     * ステータスを変更
     *
     * @param LeadCreatableInterface $model
     * @return LeadCreatableInterface
     */
    public function set(LeadCreatableInterface $model): LeadCreatableInterface
    {
        return $model->setInvalidProfileUser();
    }
}

resolveStatusesメソッドは、ポリモーフィズムを利用してInspector型のオブジェクトを汎用的に扱います。このメソッドが各オブジェクトの matches と set を呼び出すため、Inspectorの派生クラスはすべてこの共通インターフェース仕様を満たす必要があります。

app/Services/Lead/Inspector/InspectorInterface.php
app/Services/Lead/Inspector/InspectorInterface.php
<?php

namespace App\Services\Lead\Inspector;

use App\Services\Lead\LeadContext;

interface InspectorInterface
{
    public function matches(LeadContext $context): bool;
}

app/Services/Lead/Inspector/LeadStatusSetterInterface.php
app/Services/Lead/Inspector/LeadStatusSetterInterface.php
<?php

namespace App\Services\Lead\Inspector;

use App\Services\Lead\LeadCreatableInterface;

interface LeadStatusSetterInterface
{
    /**
     * @param LeadCreatableInterface $model
     * @return LeadCreatableInterface
     */
    public function set(LeadCreatableInterface $model): LeadCreatableInterface;
}

3. createLead
リード生成処理実行。
それぞれのリードはほとんどが共通カラムを持つので、$lead->fillでモデルを取得してデータを入れます!
もしも個別でのデータ追加が必要なケースは、resolveStatusesの中で、setDefaultStatusでデフォルトで入れるようにしました!

また以下は、Creatorクラスへの依存性の注入(DI)を設定する、サービスプロバイダークラスです!

app/Services/Lead/SurveyAnswer/Client/SurveyAnswerServiceProvider.php
app/Services/Lead/SurveyAnswer/Client/SurveyAnswerServiceProvider.php
<?php

namespace App\Services\Lead\SurveyAnswer\Client;

use App\Eloquent\SurveyAnswer;
use Illuminate\Support\ServiceProvider;

class SurveyAnswerServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(Creator::class, function ($app) {
            $inspector = $app->make(Inspector::class);

            return new Creator(
                $inspector,
                $app->make(SurveyAnswer::class),
            );
        });
    }
}

大変だったこと・対処法

Inspectorで$context->leadClient->getLeadId()のような特定メソッドを呼び出したい時に、LeadClientInterfaceがそのメソッドを保証していないという問題がありました。

if ($context->leadClient instanceof 〇〇Interface) {
    // 安全にメソッドアクセス
}

このようにそのメソッドを持つこと保証する〇〇Interfaceをモデルに実装し、型チェックを行うことで、実行時エラーを防ぎながら特定の実装にアクセスできるようになりました!

インスペクター内の、使用しないset〇〇メソッドについては、モデルクラス内でreturn $thisを返すことで、インターフェースの契約を満たしつつ、実装の柔軟性を保持しました。

以前と比較してコードの保守性は向上しましたが
抽象化による恩恵と個別要件への対応のバランスを取ることの難しさ
を実感しました。

このリファクタリングを行った経験を通じて、さらなる改善点も見えてきており、もうちょっとああしたい!こうしたい!が出てくるのがまた面白いところですね!

まとめ

今回、サービスの中でも主要機能となる部分のリファクタリングを行い、抽象化することで保守性の高いコードが書けました☺️

同じような悩みを持っていた人に届いたら嬉しいです!

最後まで読んでくださって、ありがとうございました!!!

株式会社イノベーション Tech Blog

Discussion