Holmes開発者ブログ

契約マネジメントシステム「ホームズクラウド」の開発者ブログです

SpringBoot+Slackアプリでメッセージ通知を実装してみる

こんにちは、id:c-terashimaです。
技術書典11で無料配布している「Holmes Tech Book」ですが、多くの方にダウンロードしていただいております!ありがとうございます!!
ドメイン駆動設計やmiroアプリの作成などバラエティに富んだ内容になっていますので、ぜひダウンロードしていただけると幸いです。

techbookfest.org

さて、今回はJava(SpringBoot)からSlackへメッセージ送信を試してみました。

Slackアプリを作る

Slackとアプリのやり取りを行うためのアプリを作成します。作成するSlackアプリからDMが届くことを想定しています。

  1. SlackAPIのアプリページに移動して、「Create New App」をクリックします f:id:c-terashima:20210719152014p:plain
  2. 「App Name」に作成するアプリ名、「Pick a workspace to develop your app in:」に使用するワークスペースを選択し、「Create App」ボタンをクリックします f:id:c-terashima:20210719152450p:plain
  3. 「OAuth&Permissions」にアプリに許可させたい権限を付与させます。今回は「chat:write」を付与します f:id:c-terashima:20210719162232p:plain
  4. 「Redirect URLs」に認証時に呼び出されるエンドポイントを指定します。後述で作成する「/slack/oauth」を指定します。SSL化されたエンドポイントを指定する必要があるのでご注意ください。(画像はダミー) f:id:c-terashima:20210720094057p:plain
  5. 「App Home」に移動して「Display Name」と「Default username」を入力します f:id:c-terashima:20210719163418p:plain
  6. 「OAuth&Permissions」の設定が完了したら、作ったアプリをワークスペースにインストールします f:id:c-terashima:20210719162650p:plain
  7. 「Basic Information」に移動して「Client ID」と「Client Secret」をメモします。この2つはホームズクラウドなどのアプリケーションからメッセージを書き込む際に必要となります

アプリとの認証

サンプルは以下の環境で動作確認をしております。

  • Java8
  • SpringBoot 2.3

Slack SDK

build.gradledependencies に以下を追加してSlack SDKをインストールします。

dependencies {
    implementation 'com.slack.api:slack-api-client:1.7.1'
}

アプリケーションとの認証

Slackユーザとアプリケーションユーザの紐付けを行います。まずは認証用のボタンを表示します。

<a href="https://slack.com/oauth/v2/authorize?client_id=「Client ID」&scope=chat:write&state=「任意のデータ」">
  <img alt="Add to Slack"
    height="40"
    width="139"
    src="https://platform.slack-edge.com/img/add_to_slack.png"
    srcset="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" />
</a>

client_id には作成したSlackアプリの「Client ID」を指定します。
state はSlackからアプリケーションの認証エンドポイントが実行された際に設定される値になります。認証用エンドポイントの呼び元がこのアプリによって実行されたのかをチェックするのに利用します。

次にSlackから呼ばれるエンドポイントを作成します。

@RestController
@RequiredArgsConstructor
@Slf4j
public class SlackController {
    private static final String STATE = "state_id";
    private final String slackClientId = "client_id";
    private final String slackClientSecret = "client_secret";

    @GetMapping("/slack/oauth")
    @SneakyThrows
    public ResponseEntity<Void> oauth(@RequestParam("code") String code, @RequestParam("state") String state) {
        // stateの確認
        if (!Objects.equals(state, STATE)) {
            log.error("stateが一致しません。{}", state);
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        try (Slack slack = Slack.getInstance()) {
            // Slackアプリとの認証
            OAuthV2AccessResponse response = slack.methods()
                    .oauthV2Access(req -> req.code(code).clientId(slackClientId).clientSecret(slackClientSecret));

            if (!response.isOk()) {
                log.error("認証に失敗しました。{}", response.getError());
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
            }

            // SlackユーザID、Slackアプリのトークンを取得
            // メッセージを送信するのに必要になるため、DBかファイルに保存しておこう
            String slackUserId = response.getAuthedUser().getId();
            String slackAccessToken = response.getAccessToken();
        }

        // 認証後のリダイレクト先を指定する
        HttpHeaders headers = new HttpHeaders();
        UriComponentsBuilder builder =
                UriComponentsBuilder.fromUri(URI.create("http://localhost:8080/slack"));
        headers.setLocation(URI.create(builder.toUriString()));

        return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY);
    }
}

「Add to Slack」ボタンをクリックすると次の画面が表示されますので、「許可をする」ボタンをクリックすることで上で作ったエンドポイントが呼び出されます。
f:id:c-terashima:20210719184234p:plain

Slackへ通知

通知するメッセージはJSON形式で指定します。(Block Kit Builder)https://app.slack.com/block-kit-builder/T3HBJHYRHを利用するとかんたんに作ることができます。
以下のメッセージを送ってみたいと思います。
f:id:c-terashima:20210719185834p:plain

認証を行ったユーザにメッセージを送るソースは次になります。

private String slackAccessToken = "accessToken";
private String slackUserId = "userId";

@SneakyThrows
public void postApprovalRequestMessage() {
    try (Slack slack = Slack.getInstance()) {
        MethodsClient client = slack.methods(slackAccessToken);

        ConversationsOpenResponse openResponse =
                client.conversationsOpen(req -> req.users(Arrays.asList(slackUserId)));
        if (!openResponse.isOk()) {
            throw new Exception("DMを開くことができませんでした");
        }

        String message =
                "[\n" +
                "    {\n" +
                "        \"type\": \"section\",\n" +
                "        \"text\": {\n" +
                "            \"type\": \"mrkdwn\",\n" +
                "            \"text\": \"こんにちは!メッセージを送るよ!!\"\n" +
                "        }\n" +
                "    }\n" +
                "]\n";


        ChatPostMessageResponse messageResponse = client.chatPostMessage(req ->
                req.channel(openResponse.getChannel().getId())
                .blocksAsString(message)
        );
        if (!messageResponse.isOk()) {
            throw new Exception("DMを送ることができませんでした." + messageResponse.getError());
        }

        ConversationsCloseResponse closeResponse = client.conversationsClose(req ->
                req.channel(openResponse.getChannel().getId()));
        if (!closeResponse.isOk()) {
            throw new Exception("DMを閉じることができませんでした");
        }
    }
}

実行するとメッセージが!!
f:id:c-terashima:20210719191910p:plain

まとめ

Slackは情報も豊富でかんたんにDMを送ることが出来ました。
Block Kitのテンプレートを見ていただくとボタンやテキストボックスをメッセージで送ることができますので、ぜひ色々試していただければ楽しいと思います。
参考にさせていただいた資料は下記に載せておきます。

参考資料


Holmesではエンジニア・デザイナーを募集しております。ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com

#技術書典 11に参加します!

こんにちは、id:c-terashimaです。

今週末に開催される技術書典11にHolmes開発部の有志メンバーで参加させていただきます!

techbookfest.org

DDDやmiroアプリ作成、E2EテストにPM視点と盛りだくさんとなっております。
価格は無料を予定しておりますので、ぜひお手に取っていただきホームズクラウドの開発裏話を楽しんでいただけたらと思います。
乞うご期待!

techbookfest.org

Holmesのとあるスクラムチームのとある一週間

Holmesでエンジニアをしている三澤です。

Holmesではアジャイル開発を採用しており、開発部には複数のスクラムチームが存在しています。 私はその中のWakaruチームに所属しているのですが、日々、ホームズクラウドが分かりやすいものになるよう改善を続けています。

今回はWakaruチームの一週間の活動を通して、開発メンバーに焦点を当てて、どのようにスクラム開発を行っているのか、その雰囲気をお伝えしたいと思います。

前提として、Holmesの各スクラムチームはコンポーネントチームではなく、フィーチャーチームとして構成されています。つまり、開発の企画からリリースまでを一貫して行う機能を持ったチームになっています。具体的に言うと、Epic作成→PBI作成→デザイン→実装→テスト→リリース(※一部Opsチームに依頼)をチーム内の作業で完結させています。

  • チーム構成とスプリントについて
    • 役割
    • スプリントについて
    • 主なスクラムイベント
  • 一週間の流れ
    • 水曜日(午後)
      • 15:30〜18:00 スプリントプランニング
    • 木曜日
    • 金曜日
    • 月曜日
      • 各メンバーの個別作業
    • 火曜日
      • 11:00〜12:00 PBIのリファインメント
      • 各メンバーの個別作業
    • 水曜日(午前)
      • 9:30〜10:30 スプリントレビュー
      • 10:30〜12:00 スプリントレトロスペクティブ
  • まとめ
続きを読む

TestCafeによるPage Object Patternの実装

こんにちは。Holmesでエンジニアをしている山本です。

以前TestCafeについて調べた際、TestCafeでもPage Object Patternが利用できることが分かりました。

E2Eテストとしての導入を検討しているため、簡単にではありますが、TestCafeによるPage Object Patternの実装を試してみました。

実行環境

名前 バージョン
OS Windows 10 Pro 64bit バージョン2004
Node.js 14.16.1
Yarn 1.22.5
TestCafe 1.14.0
TypeScript 3.9.7

Page Object Patternの概要

テスト対象となるページへのインターフェイスとなるオブジェクトまたはクラスを作成し、テストケースのコードと分離する手法です。

UIをページオブジェクトで隠蔽し、操作をメソッドとしてテストケースに提供することで、UIに変更があってもページオブジェクトの変更のみで対応できるようになります。

Seleniumを用いたテスト自動化において、コードの重複を減らしてメンテナビリティを高めるためのデザインパターンとして広まりました。

JavaSeleniumラッパーであるSelenideで推奨されており、GroovyのSeleniumラッパーであるGebでもファーストクラスとしてサポートされています

近年人気のCypressのブログでは、Page Object Patternの問題点を指摘されていますが、少なくともテストを都度スクリプトとして記述するよりも、メンテナンス性は高くなると思います。

TestCafeにおけるPage Object Patternの実装

ガイドページに、Page Modelとして実装例が記載されています。

今回は、弊社プロダクトであるホームズクラウドの、2021年4月末時点でのログイン画面を対象として、ページオブジェクトおよびテストケースを実装していきます。

TestCafeではJavaScriptおよびTypeScriptでテストを実装できますが、今回は型の指定が可能なTypeScriptを利用します。

実装対象画面

ログイン画面の初期表示は、ログインID入力欄とソーシャルログイン用のリンクが表示されます。

f:id:h-yamamoto_holmescloud:20210506193738p:plain
ログイン画面の初期表示

ログインIDを入力して「次へ」ボタンをクリックすると、パスワード入力欄が表示されます。

f:id:h-yamamoto_holmescloud:20210506195835p:plain
ログイン画面のパスワード入力欄

パスワードを入力し、「ログイン」ボタンをクリックすると、ログインIDとパスワードが正しければ、ダッシュボード画面に遷移します。

また、ログインの導線以外に、パスワードリセット画面へのリンクが表示されます。

ページオブジェクトの実装

まず、 pages/login.ts として、以下のファイルを作成します。なお、今回はソーシャルログインに関する要素を除外しております。

import { Selector, t } from 'testcafe';

export default class LoginPage {
  private url = 'http://localhost:8080/login';

  private loginIdInput = Selector('#username');
  private nextButton = Selector('.footer button[type=button]');

  private passwordInput = Selector('#password');
  private loginButton = Selector('.footer button[type=submit]');

  private alert = Selector('.alert');
  private passwordResetLink = Selector('a[href="/reset"]');

  async open(): Promise<void> {
    await t.navigateTo(this.url);
  }

  async toPasswordReset(): Promise<void> {
    await t.click(this.passwordResetLink.filterVisible());
  }

  async typeLoginId(loginId: string): Promise<void> {
    await t.typeText(this.loginIdInput, loginId).click(this.nextButton);
  }

  async doLogin(loginId: string, password: string): Promise<void> {
    await this.typeLoginId(loginId);
    await t.typeText(this.passwordInput, password).click(this.loginButton);
  }

  async hasError(): Promise<boolean> {
    return await this.alert.exists;
  }
}

実装例ではコンストラクタ内でSelectorを設定していましたが、TestCafeのSelectorは遅延評価されるため、privateなフィールドとして初期化しています。

UIの各要素を隠蔽し、操作をメソッドとして提供しています。また、初期ページのためURLが必要ですが、それもページオブジェクトに持たせています。

TestCafeのテストにはTestControllerが不可欠なため、当初はSelenium系のページオブジェクトを作る場合と同様、コンストラクタ引数でTestControllerを引数として受けてやる必要があるかと思ったのですが、TestCafeの場合、TestControllerをimportするだけで、適切なコンテキストが解決されるため、非常にシンプルになりました。

テストケースの実装

作成したページオブジェクトを用いて、実際のテストケースを記述していきます。TestCafe実行時に指定するファイルはこちらになります。

今回は、 features/login.ts として以下のファイルを記述します。

import { t } from 'testcafe';
import LoginPage from '../pages/login';

fixture `ログイン画面`;

// 実際は、IDやパスワードは外部ファイルから読み込みます
const loginId = 'test@example.com';
const password = 'test-password';

test('ログインするとダッシュボードが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.doLogin(loginId, password);

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/ws/dashboard');
});

test('ログインID入力前にパスワードを忘れた場合のリンクをクリックすると、パスワードリセットが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.toPasswordReset();

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/reset');
});

test('ログインID入力後にパスワードを忘れた場合のリンクをクリックすると、パスワードリセットが開く', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.typeLoginId(loginId);
  await loginPage.toPasswordReset();

  const location = await t.eval(() => window.location);
  await t.expect(location.pathname).eql('/reset');
});

test('存在しないログインIDを入力すると、エラーメッセージが表示される', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.typeLoginId('notfound-loginId@example.com');

  await t.expect(await loginPage.hasError()).ok();
});

test('パスワードを間違えていると、エラーメッセージが表示される', async (t) => {
  const loginPage = new LoginPage();
  await loginPage.open();
  await loginPage.doLogin(loginId, 'invalid-password');

  await t.expect(await loginPage.hasError()).ok();
});

実際のテストケースの流れを記述していきます。入力する値などもこちらで制御し、ページオブジェクトに渡すようにします。

今後の実装予定

今回はログイン画面のテストの例に対象を絞ったため、パスワードリセットやログイン後のページ遷移先ではURLを確認しただけですが、ページ遷移を伴うメソッドでは、遷移先のページオブジェクトを生成し、それを返すように実装していきます。

今回の例でいうと、 pages/reset.tsPasswordResetPagepages/ws/dashboard.tsDashboardPage を追加し、 LoginPage#toPasswordResetLoginPage#doLogin の戻り値としてそれぞれ返すイメージです。該当部分だけ抽出すると、以下のようになります。

export default class LoginPage {

  async toPasswordReset(): Promise<PasswordResetPage> {
    await t.click(this.passwordResetLink.filterVisible());
    return new PasswordResetPage();
  }

  async doLogin(loginId: string, password: string): Promise<DashboardPage> {
    await this.typeLoginId(loginId);
    await t.typeText(this.passwordInput, password).click(this.loginButton);
    return new DashboardPage();
  }

}

また、ログイン後の画面には、ヘッダやフレームといった共通コンポーネントが存在します。

それらもページオブジェクトとして実装し、継承やCompositeパターンを用いることで、各ページオブジェクトでのUIや操作を共通化できます。

class Header {
  ...
}

class DashboardPage extends Header {
  ...
}

感想

Page Object Pattern自体は歴史もあり、枯れたパターンであるため、特に違和感なく実装できました。

ただ、TestCafeの場合、どうしても処理の実行に asyncawait が必要になるため、以下の2点が気になりました。

  1. ページオブジェクトを生成したタイミングでのURL遷移が難しい
    • コンストラクタに async を付与できないため、今回はページオブジェクト側でURL遷移を行わせるメソッドを用意しました
    • static なファクトリーメソッドを作成し、それに async を付与することは可能ですが、やや煩雑になります
  2. Promise で値を返す必要があるため、メソッドチェーンが書きにくく、1行ずつ await して実行していく記述になりがち
    • TestController のように、 this | Promise<any> を返すようにすればできるかもしれませんが、テスト記述としては実装難度が高そうです

とはいえ、 async/await の頻出はJavaScriptベースのE2Eテストツールでは仕方のないことかと思います。また、E2Eテストはメンテナンスが難しいため、書きやすさよりも読みやすさを重視すべきですが、どちらのケースでも行単位で処理を実行していくことになるため、結果的にテストケースの可読性が上がることにもつながるかと思いました。

参考ページ

最後に

Holmesではエンジニア・デザイナーを募集しております。ご興味がある方はこちらからご連絡ください。

lab.holmescloud.com

lab.holmescloud.com

SpringBootAdminでかんたん見える化

こんにちは、サーバサイドエンジニアをしているid:c-terashimaです。
ホームズクラウドはSpringBootをメインフレームワークとして採用して複数のAPIサービスが稼働しています。
これからも増えていくが予想されるAPIサービスを管理運用していく上で、以下の課題がありました。

  • 障害発生時のログ調査権限がSREチームメンバーのみで運用負荷が高い
  • JVMが適切に稼働しているかリアルタイムに確認したい
  • どのバージョンのアプリケーションがデプロイ・稼働しているのか確認したい
  • サーバが増減しても低コストでかんたんに運用したい

これらを解決するのにSpringBootAdminが適していそうなので導入を検討していきます。

SpringBootAdminとは

SpringBootAdminはSpringBootで作られたアプリケーションの管理者向けのGUIツールです。
以下の情報を可視化することができます。

  • 各種メトリクスの表示
    • JVMとメモリ
    • データソース
    • キャッシュ
  • ビルド番号の表示
  • ログファイルのリアルタイム表示とダウンロード
  • ログ出力レベルの変更
  • JVMパラメータや環境設定プロパティの表示
  • SpringBoot構成プロパティの表示(application.yml)
  • スレッドダンプの表示
  • http-tracesの表示
  • endpointsの表示
  • スケジュールされたタスクの表示(SpringBatchやTaskschedulerなど)
  • SpringSessionの表示や削除
  • Flywayなどのマイグレーション状況の表示
  • heapdumpのダウンロード
  • イベントの通知(メール、Slackなど)

SpringBootAdminサーバの作成

導入は非常にシンプルで公式に書いてあるとおりに設定すればOKです。
新規にSpringBootアプリケーションを作成し、bild.gradleにSpringBootAdmin Serverの依存を追加し@EnableAdminServerアノテーションを付与するだけです。
application.ymlを作成して任意のポートを指定しておきます。server.port=9000

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("de.codecentric:spring-boot-admin-starter-server")
}
@SpringBootApplication
@EnableAdminServer
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

SpringBootAdminクライアントの設定

監視したアプリケーションにSpringBoot Actuatorを依存に追加し、application.ymlでエンドポイントを制御してあげるだけで設定はOKです。
非常にかんたんですね!

// 抜粋
dependencies {
    implementation "org.springframework.boot:spring-boot-starter-actuator"
    implementation "de.codecentric:spring-boot-admin-starter-client:2.3.1"
}

management以下は監視する情報を制御する設定になります。
Spring Boot Admin Reference Guideで監視したい設定を指定してください。
以下はすべてを公開する設定になっています。

spring:
  application.name: アプリケーションの名前     // SpringBootAdminサーバに表示させるアプリケーション名
  boot.admin.client:
    url: http://localhost:9000

management:
  server:
    port: 9001
  endpoints:
    web:
      exposure:
        include: "*"  
  endpoint:
    health:
      show-details: ALWAYS

スクリーンショット

http://localhost:9000にブラウザで接続すると管理コンソール画面が表示され、クライアントの情報が可視化されたと思います!

f:id:c-terashima:20210511185957p:plain f:id:c-terashima:20210511190016p:plain f:id:c-terashima:20210511190037p:plain f:id:c-terashima:20210511190050p:plain

まとめ

非常にかんたんな設定だけで構築することができ、たくさんの情報を可視化することができました。
その反面、セキュリティをしっかりしておかないと重大な事故につながる危険性もあり、監視する項目はしっかりと見極めないといけないことがわかりました。
皆様の導入の参考になれば幸いです。


Holmesでは現在エンジニアを募集しています。 興味がある方は是非こちらからご連絡ください!

lab.holmescloud.com

テストコードに関する疑問とその個人的アンサー

holmesの倉島です。テストコード記述に関連する個人的な疑問点を、意見・考えをまとめて自分なりの回答を出してみました。

Q1: TDDで実装中に、別のテスト記述可能なメソッド(publicメソッド等)を実装したくなった。これもテスト対象?

TDDのred→greenの実装中に「あれ?次に書く予定のメソッドもTDDで実装した方が良いのでは?」と、ふと思い作業の手が止まってしまったので考えてみました。
結論を述べると、TDDでは実装しないで、書く予定のメソッドで他に試したいパターンがあるなら記述後に改めてテストコードを書く でまとまりました。理由としては、本筋のコードで通るメソッドであるため、ある程度の担保はされているからですね。

Q2: コンストラクタのテストコードは必要?

コンストラクタ内に分岐があり想定パターンが複数存在する場合はテストコードを記述するのはアリだと思います。
ただ想定パターンが複数存在しない場合、コンストラクタはいずれかのテストコードの範囲内の筈かつ、コンストラクタは言語(Lombok等を使用しているのであればライブラリ)の機能で信頼性が高いのでテストコードは不要だと思います。

Q3: テストの結果として想定している値は複数でも良い?

一つのテストコード内で(結果として判断できる値は)一つであることが望ましい。理由としては、複数の値を結果として想定していると手前で落ちた場合その後の値が評価されないからですね(アサーションルーレットアンチパターン)。ただ例外として、まとめて審査しないと意味がないもの(インテグレーションテスト等)は結果として複数想定する場合があります。

Q4: TDDによる実装後のTODOリストの扱い

捨てます。TODOリストは飽くまでTDDを行うための道標であって成果物ではないことと、自分が良い・解りやすいと思ったテストコードを記述できているのであれば(テストコードにTODOリストの内容が落とし込めているため)TODOリストは不要だからですね。

Q5: テスト結果の検証を行う時 assertEqualsのように、 ===== を使用しないでメソッドを使用して比較する理由は?

言語によっては=====を使用した比較結果が異なる場合があるので、なるべくテスティングフレームワークのassertionに任せた方が良い、という理由ですね。
また余談として...、検証処理自体をテスティングフレームワークに移譲することによって、例えばテストが失敗した場合に、expectedとactualの差分をわかりやすく表示してくれたりなどのメリットがあります。

(最後に)おまけ

TDDを知って約1年半が経過しました。未来の自分に向けて、何故自分がTDDを続けているかの理由を少しだけ分析・記述しておきたいと思います。
恐らく一番の理由としては、単純に楽しいと思っているからですかね。TDDはred→green→リファクタリング のサイクルを回して実装を行う開発手法です、段々にステップを踏んで着実な一歩/前進を感じることができるので「楽しい」と思っている ですね。

Holmesでは現在エンジニアを募集しています。 興味がある方は是非こちらからご連絡ください!

lab.holmescloud.com

ドメインサービスを書く時の判断基準と大事にしていること

こんにちは。Holmesでサーバーサイドエンジニアをしている友野です。例年ひどい花粉症ですが、医者に処方してもらった薬で今年は快適に過ごせています。薬すごい。

ドメイン駆動設計(以降、DDD)を実践する上で悩みどころは色々ありますが、中でもドメインサービスをいつ、どのように使うかは特に難しい問題です。

今回は、開発時にドメインサービスを使うかどうかを判断するポイントを整理しておこうと思います。 現時点での整理であり、これがいかなる場面においても正解であるとは思いませんが、少なからず社内理解は進んでいます。

TL;DR

弊社ではドメインサービスの使い所として、そのふるまいが以下のいずれかに該当するかを判断基準としています。

  1. 複数集約間で整合性を担保する必要があるか
  2. 単一集約でも複数インスタンスを扱うか
  3. 実装したいドメインルールが外部サービスと連携する表現をした方が自然か

そしてなにより大事なのは、ビジネスルールとして名前がつけられるかどうかです。

背景−ドメインサービスを使わない選択

こちらの記事の通り、ホームズクラウドはSpring MVCに則ったパッケージ構成で構築されており、データクラスとサービスクラスの組み合わせでビジネスルールを実現していました。

この考えのままドメインサービスを適用しようとすると、手続き的なロジックがドメイン層に移るだけでドメイン貧血症に陥りやすい、というのはよく知られています。DDD原典である「エリック・エヴァンスのドメイン駆動設計(牧野 祐子 牧野 祐子 今関 剛 今関 剛 今関 剛 和智 右桂 和智 右桂 Eric Evans)|翔泳社の本(以降、Evans本)」でも、ドメインサービスに頼りすぎるのは良くないとする記述もあります。

サービスは節度を持って使用すべきで、エンティティと値オブジェクトからすべてのふるまいを奪ってはならない。

ユースケース層でドメインオブジェクトを組み合わせれば、ある程度のビジネスルールが表現できることもあり、積極的にドメインサービスは使用していませんでした*1。なにより、何かしらの基準を設けないとドメイン貧血症になる懸念がありました。

一方で、アーキテクチャを含めた書き換えをしていく中で、いよいよ表現が難しくなってくるシーンが出てきました。試験的にドメインサービスで書いてみようと一部メンバーと試行錯誤を始めて、手続き的なロジックが徐々に生まれてきたのもこの時です。本記事のモチベーションは、過去を振り返り、同じ徹を踏まないようにすることです*2

ドメインサービスの定義

まずは定義を確認します。Evans本では以下のように定義*3されています。

サービス

…概念的にどのオブジェクトにも属さないような操作が含まれることがある。強引に決着をつけるのではなく、問題領域に引かれる自然な輪郭にしたがって、モデルの中に明確にサービスを含めればよい。
<中略>
サービスとは、モデルにおいて独立したインタフェースとして提供される操作で、エンティティと値オブジェクトのようには状態をカプセル化しない。

また、「実践ドメイン駆動設計(ヴォーン・ヴァーノン 髙木 正弘)|翔泳社の本(以降、Vernon本)」では以下のように解説されています。

第7章 サービス

ドメインにおけるサービスとは、そのドメインに特化したタスクをこなす、ステートレスな操作のことだ。実行すべき何かの操作があって、それを集約や値オブジェクトのメソッドにするのは場違いだと感じたときは、ドメインモデルの中でサービスを作るべきだと考えられる。

対象のソフトウェアが実現すべきビジネスルールのうち、ドメインオブジェクトに持たせるには不自然なふるまいはドメインサービスに持たせる、と理解できます。

具体例に挙げられているものから考えてみる

不自然なふるまいとは一体どんなものがあるのでしょうか。プロダクションコードで利用するためにもっと解像度を高めていく必要があります。いくつかの書籍の例をピックアップしてみます。

これらを俯瞰してみると、以下のようなケースに使われることがありそうです。

  1. 単一/複数集約の複数インスタンス間にまたがって実現するケース(資金振替、メールアドレス重複チェック、拠点間の輸送)
  2. その処理過程・順序そのものがビジネスルールであるケース(認証の仕組み隠蔽)
  3. インタフェースを通じてインフラ層のふるまいと組み合わせるケース(メールアドレス重複チェック

これらのケースを踏まえて、どうやってドメインサービスを実装していくかを考えてみます。

考えるポイント

プロダクトに適用した際に考えたポイントは以下の通りです。

  1. 複数集約間で整合性を担保する必要があるか
  2. 単一集約でも複数インスタンスを扱うか
  3. 実装したいドメインルールが外部サービスと連携する表現をした方が自然か

以下、例を挙げながら、1つずつ見ていきます。サンプルコードは読者の方にイメージしていただくためのものであり、弊社プロダクトにおける実際のビジネスルールを正確に表したものではありません。また、弊社プロダクトの開発環境に合わせ、Javaで記述しています。

複数集約間で整合性を担保する必要があるか

最もドメインサービスの価値を発揮すると考えています。どちらかの集約にビジネスルールを書いたとしても、もう片方の集約のことを知らないとそのルールが実現できないのはやはり不自然です。かといって、そこで集約を1つにまとめるのはもっと不自然ですし、とても大きな集約になる懸念もあります。

複数集約間の整合性担保については、松岡さんのブログ記事にも記載がある通り、非常に分かりやすく、まさにドメインサービスの提供価値だと思います。

little-hands.hatenablog.com

弊社プロダクトのコアドメインである契約ドメインでいえば、例えば「業務委託契約の更新を行うために、今年度分の契約書に押印する」はこのケースに当てはまります。集約の分割は別テーマになるため詳細は省きますが、契約と契約書を同一集約としてモデリングすることも可能とは思います。ただ、異なる概念・異なるライフサイクルのため、別集約とする方が弊社プロダクトにとって、よりドメインに沿った考え方です。契約ライフサイクルの考え方は次の記事を参照ください。

www.holmescloud.com

契約は当事者間の合意によって成り立つもので、基本的にはそれを文書化したものが契約書です。しかし、必ずしも契約書がなくとも、口頭や覚書で契約を更新することも可能ですし、複数契約書によって1つの契約を結ぶこともあります。いずれにせよ、契約は何かしらの合意を示すエビデンスによって更新されるものです。今回はその中でも分かりやすい契約書の例で進めます。

f:id:a-tomono:20210328231352j:plain
契約書に押印して契約を更新する

契約書の押印により契約を更新するドメインサービスの、必要最小限の要素のみのサンプルは以下の通りです。

@RequiredArgsConstructor
public class ContractDomainService {
    // 契約書のリポジトリインタフェース
    // フレームワークによってDIされる
    private final DocumentRepository documentRepository;
    // 契約のリポジトリインタフェース
    private final ContractRepository contractRepository;

    /**
     * 契約書による契約更新
     */
    public void renewContract(
            DocumentId documentId,
            DocumentPartyId documentPartyId,
            RenewDateRange renewDateRange) {

        // 集約の復元
        Document document = documentRepository.findById(documentId);
        Contract contract = 
                contractRepository.findById(document.getContractId());

        // 契約書の当事者(DocumentParty)が押印する
        document.signBy(documentPartyId);

        // 契約期間を引数の期間で更新する
        contract.renew(renewDateRange);

        // 状態を変えた集約の永続化
        documentRepository.save(document);
        contractRepository.save(contract);
    }
}

処理詳細は置いておくとして、2つの集約の状態が変わることで実現できるビジネスルールであることが表現できていると思います。状態変更は、それぞれの集約のふるまいに移譲するので、ドメインサービス自身はステートレスです。

ドメインサービス内で、リポジトリ等のインタフェースを利用することについては賛否あると思います。Evans本、Vernon本ではそれぞれ以下のような記述があります。

サービスと隔離されたドメイン層(Evans本)

ドメイン層とアプリケーション層のサービスは、これらインフラストラクチャ層のサービスと協力して動作する。例えば、ある銀行のアプリケーションでは、口座残高が一定限度額を下回ると、顧客に電子メールを送信するかもしれない。この電子メールシステムはインタフェースによってカプセル化され、場合によっては、代わりの通知手段もそこに含まれるかもしれない。このインタフェースが、インフラストラクチャ層のサービスである。

7.3 ドメインにおけるサービスのモデリング(Vernon本)

ドメイン内のサービスは、必要に応じてリポジトリを使える。しかし、集約のインスタンスからリポジトリにアクセスすることは、お勧めできない。

これらを踏まえて、ホームズクラウドではドメインサービス内でのインタフェース利用を良しとしています。また別のサンプルとして、集約のインスタンスを引数に受け取り、直接状態を変えることも考えられます。こちらの方がシンプルに書けますね。

public class ContractDomainService2 {
    /**
     * 契約書による契約更新
     */
    public void renewContract(
            Document document,
            Contract contract,
            DocumentPartyId documentPartyId,
            RenewDateRange renewDateRange) {

        // 契約書の当事者(DocumentParty)が押印する
        document.signBy(documentPartyId);

        // 契約期間を引数の期間で更新する
        contract.renew(renewDateRange);

        // 集約の永続化はユースケース層で行う
    }
}

単一集約でも複数インスタンスを扱うか

ユーザーの存在チェック、重複チェックドメインサービスの例としてよく聞きます。同一集約でも任意のインスタンスが他のインスタンスの状態を知らないのでイメージはしやすいですが、この例だけだとプロダクトに適用するイメージはあまり持てませんでした。

こちらも契約ドメインの例を考えると「任意の契約書のレビュー依頼がすでにされているかどうか」がこのパターンに当たりそうです。ドメインモデルとサンプルコードは以下の通りです。

f:id:a-tomono:20210326001332j:plain
レビュー依頼は契約書につき同時に1つのみ依頼できる

@RequiredArgsConstructor
public class RenewRequestDomainService {
    // レビュー依頼のリポジトリインタフェース
    private final ReviewRequestRepository reviewRequestRepository;

    /**
     * レビュー依頼済みの契約書か判定
     */
    public boolean isAlreadyReviewRequested(DocumentId documentId) {

        // リポジトリインタフェース経由で集約を取得する
        Optional<ReviewRequest> reviewRequest =
                reviewRequestRepository.findByDocumentId(documentId);

        // すでにレビュー依頼がされている場合はtrue
        return reviewRequest.isPresent();
    }
}

リポジトリから集約を復元して真偽を判定しているだけなので、ユースケース層のふるまいとしても表現できそうです。ドメインサービスにするかどうかは、ビジネスルールとして名前がつけられるかどうかだと考えています。以下、Evans本の引用です。

モデルの言語を用いてインタフェースを定義し、操作名が必ずユビキタス言語の一部になるようにすること。

この場合は、契約書に対してレビューは常に1つしかない、というのが表現したいビジネスルールです。ユースケース層でリポジトリから復元してチェックするだけだと、アプリケーション上は同じふるまいですが、このルールは表現できなくなります。

実装したいドメインルールが外部サービスと連携する表現をした方が自然か

前述のEvans本からの引用部分、

ドメイン層とアプリケーション層のサービスは、これらインフラストラクチャ層のサービスと協力して動作する。

にて述べられているインフラストラクチャ層のサービスは、リポジトリだけではありません。

先日、ホームズクラウドは、契約書の締結にクラウドサイン連携を選択可能になりました(プレスリリース)。当事者に契約書の締結を依頼する締結依頼とクラウドサイン側のAPIをコールするリクエストを作る必要があります。この時、ドメインサービスからインタフェースを使います。

@RequiredArgsConstructor
public class ExecutionRequestDomainService {
    // クラウドサインAPIをコールするインタフェース
    private final CloudSignConnector cloudSignConnector;
    // 締結依頼のリポジトリインタフェース
    private final ExecutionRequestRepository executionRequestRepository;

    /**
     * クラウドサインを利用した締結依頼を発行する
     */
    public void issueExecutionRequestWithCloudSign(
                                        DocumentId documentId,
                                        CloudSignRequest cloudSignRequest) {

        ExecutionRequest executionRequest = 
                ExecutionRequest.initialize(documentId);

        // 必要な情報を渡して締結を依頼する
        cloudSignConnector.issue(cloudSignRequest);

        // 締結依頼を進行中にする
        executionRequest.inProgress();

        // 進行中にした締結依頼を永続化
        executionRequestRepository.save(executionRequest);
    }
}

ちなみに cloudSignConnector.issue() インタフェースの実装では、RestTemplate等でクラウドサインAPIをコールする、レスポンスモデルを変換するといった実装を書きます。

リポジトリインタフェースの例と同様で、こちらもビジネスルールとして名前がつけられるかどうかを重要視しています。

逆に、ユースケースの最後で集約の情報をログに出力する場合など、アプリケーションの関心事はビジネスルールとして名前がつけにくいので、ユースケースクラスからインタフェース経由で実装しています。

さいごに

ドメインサービスは使わない方が良い、というのはある意味で正しいと思います。MVCパターンからリアーキテクティングを試みると、多かれ少なかれドメイン貧血症に陥る可能性は高いですし、部分的には実際に陥りつつあります。

しかし、ドメインサービスを使わない選択をすることで、ビジネスルールが見つけられないとしたら、それは本末転倒な気がします。 ドメインルールとして名前をつけられるかどうかを注意深く考えながら、3つの判断基準に照らしてドメインサービスを書いていくと、最初の迷いは軽減できるはずです。

We're hiring

少しずつですが、HolmesでもDDDの考え方が浸透してきました。さらにスピードを上げて良いプロダクトを作るために、エンジニアを積極採用中です。DDDに関して知見や興味があり、一緒にHolmesの目指す世界観を作りたい方、是非力を貸してください!

Holmesでは現在エンジニアを募集しています。 興味がある方は是非こちらからご連絡ください!

lab.holmescloud.com

*1:ちなみに、Evans本ではドメインオブジェクト/ドメインサービスのクライアントはアプリケーションサービスですが、弊社ではユースケース層として同じ責務を定義しています。

*2:社内メンバーに説明するためでもあります。

*3:文中ではドメインサービスという言葉を使いますが、書籍ではサービスとして定義されています。