Slide 1

Slide 1 text

APIによる レガシーシステムの改善 2023/6/4 JJUG CCC 2023 Spring パーソルキャリア株式会社 齋藤 悠太

Slide 2

Slide 2 text

2020年9月 パーソルキャリア株式会社に中途入社 dodaのバックエンド開発を中心に担当 Java, Spring, AWSが好き 齋藤 悠太 自己紹介 2 今回の内容に関しまして、私たちの会社ではこうしている、こうした ほうがより良いのでは などありましたら是非Twitterでコメントいた だけますと嬉しいです。

Slide 3

Slide 3 text

アジェンダ 3 1. dodaについて 2. リビルドプロジェクト 3. API開発の工夫 4. 技術負債を生まないための仕組みづくり

Slide 4

Slide 4 text

dodaについて 4 • 累計会員数、約750万人の転職支援サービス • 10年以上運用されているシステム • 2018年から開発チームの内製化を開始

Slide 5

Slide 5 text

リビルドプロジェクト

Slide 6

Slide 6 text

dodaのシステム概要図 6 フロントエンド JavaScript, jQuery, Vue.js(一部画面のみ) サーバサイド Java, Spring, Seasar2, C# インフラ EC2, Apache, nginx, Tomcat, Solr, PostgreSQL, Oracle オンプレ ALB Java Java 内部API C# 内部API Solr PostgreSQL Oracle CMS Java 主な技術スタック

Slide 7

Slide 7 text

フロントエンドの課題 7 フレームワークを使わないフロントエンドの限界 • スタイルの変更が様々な画面に影響を及ぼす • 画面内の複雑な変化をjQuery/jsで行うことが難しい CLS指標の悪化 • クライアントサイドでレンダリング(CSR)によって画面のがたつきが発生 • Google上での検索順位が低下する可能性があった お気に入り 修正したい画面 別の画面 お気に入り 別の画面では他のスタイルと 衝突して画面崩れを起こす この画面はうまく 表示されるが Pagespeed Insights (2021/3)

Slide 8

Slide 8 text

サーバサイドの課題 8 • テストコードが書けていない/書きにくい • ライブラリのバージョンアップに時間がかかる • 可読性が低い • 静的コード解析での大量のエラーやフォーマットが統一されていない @Controller @RequestMapping(“/”) public class HelloController extends AbstractController { @GetMapping public String index(HelloForm form) { this.commonValidate.convertForm(form); super.createLinkUrl(this.getClass().getName()); return “home”; } } public abstract class AbstractController { @Autowired protected CommonValidate commonValidate; protected String createLinkUrl(String className) { if (PropConstants.PROP_URL_F101001.equals(className)) { return ResourceProperty.getLinkUrl(className); } else { return null; } } } コード例 ・親クラス側でAutowiredでDIしているものを子クラス側で呼び出し ・親クラス側で子クラスの名前による分岐 ・staticメソッドの呼び出し

Slide 9

Slide 9 text

インフラの課題 9 • 変更に時間がかかる(クラウドリフトしただけ) • スケールしにくい • ミドルウェアのバージョンアップに時間がかかる • 設定がコード管理されていないため状態を追うのが難しい EC2 ミドルウェア v1 アプリケーション v1 EC2 ミドルウェア v2 アプリケーション v1 変更 EC2 ミドルウェア v2 アプリケーション v2 変更 状態をコード管理できておらず現在の状態を再現するのが難しい

Slide 10

Slide 10 text

リビルドプロジェクトでの取り組み 10 フロントエンド • React/ Next.jsの導入 → コンポーネント化されることで意図しないスタイルが当たるのを防ぐ、 画面の複雑な状態の変化を簡単に対応、SSRによりCLS指標の悪化を防ぐ サーバサイド • APIのために新しくシステムを作成 • 画面と切り離されることで意図しない変更が減る → 大規模な変更(ライブラリのバージョン アップなど)をしやすい状態を作る • 負債を生まないための仕組みづくりを行う → テストを充実させて変更に強いシステムを作る インフラ • コンテナ(ECS)の利用 → イミュータブルになり、スケールや障害時の切り戻しを素早く実施 • IaC(CloudFormation)によるインフラの作成 → インフラの見える化・変更を素早く実施

Slide 11

Slide 11 text

プロジェクトでの取り組み 11 フロントエンド • React/ Next.jsの導入 → コンポーネント化されることで意図しないスタイルが当たるのを防ぐ、 画面の複雑な状態の変化を簡単に対応、SSRによりCLS指標の悪化を防ぐ サーバサイド • APIのために新しくシステムを作成 • 画面と切り離されることで意図しない変更が減る → 大規模な変更(ライブラリのバージョン アップなど)をしやすい状態を作る • 負債を生まないための仕組みづくりを行う → テストを充実させて変更に強いシステムを作る インフラ • コンテナ(ECS)の利用 → イミュータブルになり、スケールや障害時の切り戻しを素早く実施 • IaC(CloudFormation)によるインフラの作成 → インフラの見える化・変更を素早く実施 ここを中心にお話しします。

Slide 12

Slide 12 text

新しいAPIシステムの概要図 12 AZ-a AWS Cloud ECS S3 Oracle Database ログ出力 ALB APP sidecar Kinesis DB接続 AZ-c 上記同様のため省略 CloudWatch Solr Solr検索

Slide 13

Slide 13 text

取り組みについて話すこと 13 API開発の工夫 • 画面とは切り離されるようになったが、代わりにフロントエンドとの連携や使いやすいAPIを設計する ことが重要となった • 効率よくAPI設計やフロントエンドの連携を行う方法、API設計で気を付けている点をご紹介 負債を生まないための仕組みづくり • APIにより大規模な修正がしやすくなったが、修正後にIFの状態が同じであることの担保が必要 • 内部の状態(コードの品質やセキュリティ)についても、一定の状態を保てないと開発速度が落ちる • 負債を生まないためにテストを中心にいくつかの仕組みを作ったのでご紹介

Slide 14

Slide 14 text

API開発の工夫

Slide 15

Slide 15 text

API開発の流れ 15 IF定義作成 実装 実装 OpenAPI モック生成 テスト サーバサイド フロントエンド 認識合わせ 認識合わせ

Slide 16

Slide 16 text

API開発の流れ 16 IF定義作成 実装 実装 OpenAPI モック生成 テスト サーバサイド フロントエンド 認識合わせ 認識合わせ • OpenAPIが共通の指針となるため重要 • OpenAPIと実装が乖離しないようにする必要がある(フロント・サーバサイドどちらも)

Slide 17

Slide 17 text

OpenAPIの作成 17 springdoc-openapiでIF定義の作成 • コントローラからOpenAPIを生成 + swagger-uiでHTMLで閲覧可能 • 日本語の説明など追加情報を設定する @RestController @RequestMapping("/api/v1/events") @Tag(name = "event", description = "イベント") public class EventsController { @GetMapping @Operation(summary = "イベント一覧取得", description = "イベント情報を取得する") public List index( @Parameter(description = "カテゴリID", example = "1") @RequestParam("categoryId") int categoryId) { return Collections.emptyList(); } } コントローラ /swagger-ui/index.htmlを表示

Slide 18

Slide 18 text

OpenAPIの作成 18 springdoc-openapiでIF定義の作成 • リクエストやレスポンスのBeanについても@Schemaで情報を付与できる • コードからOpenAPIを生成するため乖離が発生せず、慣れたJavaのコードで型安全にOpenAPIを 生成できるため効率もよい。 @Schema(description = "イベント") public record EventResponse( @Schema(title = "イベント名", example = "JJUG CCC") String name, @Schema(title = "開催日", example = "2023-06-04") LocalDate date, @Schema(title = "開催場所", example = "野村コンファレンスプラザ新宿") String place) {} Bean レスポンスのスキーマ情報

Slide 19

Slide 19 text

フロントエンドの開発 19 OpenAPIからHTTP Client用のTypeScriptのコードを生成する • aspidaを利用することで型安全にHTTP通信を行うことが出来るようになる。 • OpenAPIから型の定義がされたtsファイルを自動生成することが出来る。 OpenAPI aspida ts ファイル const events = await apiClient.api.v1.events.$get({ query: { categoryId: 1 } }); aspidaでOpenAPIから型情報を生成 型安全にHTTP通信のコードを書ける

Slide 20

Slide 20 text

フロントエンドの開発 20 OpenAPIからモックサーバを作成する • Prismを利用しOpenAPIからモックサーバを作成 • APIの開発が終わっていなくてもモックサーバを利用しフロントエンドの開発を可能にする OpenAPI Prism PrismでOpenAPIからモックサーバを起動 フロントエンド の開発 API呼び出し 起動とcurlでの確認 prism mock -p 8080 ./mockApi/api-docs.yaml [9:40:46] » [CLI] ... awaiting Starting Prism… [9:40:46] » [CLI] i info GET http://127.0.0.1:8080/api/v1/events?categoryId=1 [9:40:46] » [CLI] ► start Prism is listening on http://127.0.0.1:8080 curl http://localhost:8080/api/v1/events?categoryId=1 [{"name":"JJUG CCC","date":"2023-06-04T00:00:00.000Z","place":"野村コ ンファレンスプラザ新宿"}]

Slide 21

Slide 21 text

API設計で気を付けていること 21 • 命名を気をつける。 • ~flag という名前にしない • DBのカラム名をそのままレスポンスに利用しない (既にある実装をベースに移行するため意識しないと発生しやすい) • 後方互換性を保つ。保てないなら新しいバージョンで作る。 • 既存の仕様が良くない場合は、そのままAPIにするのではなく可能 な範囲で再定義する(詳細は後述) • 画面に依存したAPIを作らない(詳細は後述) https://www.oreilly.co.jp/books/9784873116860/ Web API: The Good Parts を参考にAPIを設計する

Slide 22

Slide 22 text

API設計で気を付けていること 22 既存の仕様が良くない場合は、そのままAPIにするのではなく可能な範囲で再定義する • 10年運用されているシステムなので、途中で要件が変わる過程でおかしい仕様のものもある • せっかく画面とAPIを作り直すので、おかしな仕様があれば要件の再定義を可能なら実施する • スケジュールもあるので可能な範囲を見極めて実施 一覧画面 既存仕様がよくない例 明細1 明細2を削除 明細2 明細3 No 内容 1 明細1 2 明細2 2 明細3 並び順に利用するNoを 手動で更新 一覧画面 明細1 明細3 No 内容 1 明細1 2 明細3 3 明細4 3 明細5 内容 明細4 内容 明細5 同時にinsertするとNoが重複 明細4 明細5 Noが3のものが 複数作成されてしまう 可能性がある No.3 -> No.2 にupdate

Slide 23

Slide 23 text

API設計で気を付けていること 23 画面に依存したAPIを作らない • 特定の画面のみで利用される文言などをAPIで返さない • 複数カラムで返した方が汎用的に利用できるものは無理にAPIで加工して返さない – (例: 住所の都道府県、市区町村、ビル名などを持っている場合にそれらを結合して返すと、別の画面では都 道府県のみ表示したいなどの要件に適用できなくなってしまう) • SEO対策のみのカラム値を返さない(返すとしても他の用途で利用可能なカラム名にする) コンテンツ タイトル

responseDto.setTitle( ResourceProperty.get( "title“)); title=タイトル 画面 jsp Java properties 元々の実装 { "title": "タイトル“ ... } よくない APIの作成例 タイトル 画面A タイトル 画面B API response タイトルが異なるので そのまま利用できない

Slide 24

Slide 24 text

技術負債を生まないための仕組みづくり

Slide 25

Slide 25 text

コンテナ(ECS)の利用 25 イミュータブルなリソース管理 • AutoScalingによりコスト削減やアプリが落ちても自動復旧 • Blue/Greenデプロイにより問題発生時に一瞬で切り戻す 脆弱性へ即時に対応 • ECRのイメージスキャンやAmazon Inspectorでイメージの脆弱性チェック • Dockerfileを変更するだけでバージョンアップ可能 Blue/Greenデプロイ コンテナイメージの脆弱性診断レポート

Slide 26

Slide 26 text

ECSでJavaアプリケーションを動かす際に発生した課題 26 EFSが使えない状況でのJFR/HeapDump出力 • ジャンボフレームの影響でFargate1.4が利用できなかった。 Fargate1.4.0でないとEFSも利用できない • サイドカーでinotifyを利用しJFRやHeapDumpの出力先をマウン トし、ファイルが配置されたらs3に出力するように設定 “containerDefinitions”: [ { “mountPoints” : [ { “sourceVolume” : “jvmlogs_volume”, “containerPath” : “/logs/jvmlogs/” } ], “name”: “app”, // 省略 }, { "mountPoints" : [ { "sourceVolume" : "jvmlogs_volume", "containerPath" : "/logs/jvmlogs/" } ], "name": "inotify", // 省略 } ] taskdef.json Java inotify s3 ECS Task /logs マウント マウント&監視 ファイル出力

Slide 27

Slide 27 text

各種テストについて 27 技術負債を生まないためのテストと種類 • 静的コード解析 : コードにバグがないか、フォーマットが適切かをチェックする • 単体テスト : 対象のクラス/メソッドに対してのテスト • インテグレーションテスト : API単位でのテスト Database 外部API APIサーバ コント ローラ サービス リポジト リ 静的コード解析/単体テスト インテグレーションテスト

Slide 28

Slide 28 text

静的コード解析 28 ビルド時に静的コード解析を実行 • CIで実行しエラー発生時にはデプロイさせない • 現在はコード解析はSpotBugsで, フォーマッタはSpotless(google-java-format)で実施 SpotBugsのエラー例 Spotlessのエラー例 FAILURE: Build failed with an exception. * What went wrong: Execution failed for task ':spotlessJavaCheck'. > The following files had format violations: src¥main¥java¥xx¥v1¥controller¥AbstractController.java @@ -3,14 +3,13 @@ import·org.springframework.beans.factory.annotation.Autowired; public·abstract·class·AbstractController·{ -····@Autowired -····protected·CommonValidate·commonValidate; +··@Autowired·protected·CommonValidate·commonValidate;

Slide 29

Slide 29 text

静的コード解析 29 ArchUnitによるアーキテクチャテスト • ArchUnitを利用してプロジェクトのルールを守らないコードがある場合テストでエラーにする • 以下作成したルールの例 • 依存関係などのパッケージルール(ポピュラーなArchUnitの使い方) • ライブラリの使い方に関するルール • Springに関するルール(@Transactionを付与してよい場所の指定など) • OpenAPIの入力必須化(springdoc-openapiのアノテーション付与必須) @ArchTest void Transactionアノテーションはinteractorにのみ設定可能() { methods() .that() .areDeclaredInClassesThat() .resideOutsideOfPackage(“XXX.interactor..") .should() .notBeAnnotatedWith(Transactional.class); } サンプルのテストコード Architecture Violation [Priority: MEDIUM] - Rule 'methods that are declared in classes that reside outside of package xxxx.interactor..' should not be annotated with @Transactional' was violated (1 times): Method is annotated with @Transactional in (HelloController.java:18) java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'methods that are declared in classes that reside outside of package ‘xxx.interactor..' should not be annotated with @Transactional' was violated (1 times): エラーの例

Slide 30

Slide 30 text

単体テストをしやすくする工夫 30 staticなメソッドの呼び出しをDIに置き換える • 日付取得やログ出力処理などのstaticなメソッドの呼び出しはテストが面倒になる。 • Bean化してDIすることでテストをしやすくする @Component public class LocalDateTimeService { public LocalDateTime now() { return LocalDateTime.now(); } } public class JobInteractor { private JobRepository jobRepository; private LocalDateTimeService localDateTimeService; public JobInteractor( JobRepository jobRepository, LocalDateTimeService localDateTimeService) { this.jobRepository = jobRepository; this.localDateTimeService = localDateTimeService; } public boolean isPublic(String id) { Job job = jobRepository.find(id).get(); // 公開終了日時 > 現在日時 return job.getPublicationEndDate().isAfter( localDateTimeService.now()); } } @Test @DisplayName("公開終了日時 > 現在日時 のときtrue") void 公開終了日時の前ならtrueを返す() { String id = "123"; Job job = new Job(); job.setPublicationEndDate( LocalDateTime.of(2023, 6, 4, 16, 00, 01)); given(jobRepository.find(id)).willReturn(job); given(localDateTimeService.now()) .willReturn(LocalDateTime.of(2023, 6, 4, 16, 00, 00)); boolean actual = jobInteractor.isPublic(id); assertTrue(actual); } テスト対象コード テストコード LocalDateTimeのBean

Slide 31

Slide 31 text

APIのインテグレーションテスト 31 全体像 プロダクションコード コント ローラ サービス リポジト リ APIごと のテスト コード 4. @SpringBootTestで Springの起動 モック用 のコード 5. Beanを置き換え TestContainers WireMock Flyway インテグレーションコード applicati on.yaml 8. TestWebClientで API呼び出しと アサーション 1. TestContainers実行 2. コンテナ起動 3. application.yamlの上書き 6. Flyway実行 7. マイグレーション 9. 作成されたコンテナへ通信

Slide 32

Slide 32 text

APIのインテグレーションテスト 32 通信先インスタンスの作成 • DBや外部APIなどの各種通信先はTestContainersを利用してDockerコンテナを実行します。 • 外部APIはWireMockを1つ起動してエンドポイントで振り分け public abstract class AbstractContainerBaseTest { static final PostgreSQLContainer postgres; static { postgres = new PostgreSQLContainer<>(DockerImageName.parse(“postgres:12”)); postgres.start(); } // application.ymlの設定を起動したコンテナ情報で上書きする @DynamicPropertySource static void setup(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); } } build.graldeでのソースセット作成 applicati on.yaml application.yamlの上書き コンテナの起動

Slide 33

Slide 33 text

APIのインテグレーションテスト 33 DBのマイグレーション • テスト用のDBマイグレーションにはFlywayを利用 • テストで使う可能性のある初期データも事前にFlywayでinsertする(例えばユーザー情報など各テ ストで用意するのは大変なもの) resourcesに配置したマイグレーションファイル Flyway Create Table SQL Insert SQL

Slide 34

Slide 34 text

APIのインテグレーションテスト 34 Springの起動~APIの呼び出しテスト • @SpringBootTest を付けることで実際にSpringを起動しテストが実行できる • インテグレーションテストではAPIの呼び出し~DBや外部APIの呼び出しまでを通しでテストする • WebTestClientを利用することでAPI呼び出しのメソッドチェーンでそのままアサーション @SpringBootTest public class EventIntegrationTest { @Autowired private WebTestClient webTestClient; @Test void データが取得可能() { webTestClient .get() .uri("/api/v1/events") .accept(MediaType.APPLICATION_JSON) .exchange() .expectStatus().isOk() .expectHeader().contentType(MediaType.APPLICATION_JSON) .expectBody().jsonPath("$.id").isEqualTo(1); } プロダクションコード コント ローラ サービス リポジト リ アプリケーション の起動 APIの呼び出しと アサーション

Slide 35

Slide 35 text

APIのインテグレーションテスト 35 テスト用Beanの作成 • dodaではDBにOracleを利用しているが、コンテナでの利用に課題がある(起動が遅いなど)ため PostgreSQLを代わりに利用している。一部Oracle独自の関数を利用しているものもあるためそう いった箇所はBeanを利用してモック化している。 • 既存のコードではテストしにくいもの(例えば共通エラー系のテスト)をテストするために Controllerをテスト用に作成してテストを実施 @Dao @ConfigAutowireable public interface FunctionDao { @Select @Sql("select now()") LocalDate getDate(); } @Dao @ConfigAutowireable public interface FunctionDao { @Select @Sql("select sysdate from dual") LocalDate getDate(); } 本来のコード テスト用に置き換え 補足情報:Oracleのコンテナはgvenzl/oracle-xeを利用すると起動が速いようです プロダクションコード リポジト リ Dao Dao テストコード テスト用 Beanを利用

Slide 36

Slide 36 text

ライブラリの脆弱性チェック 36 ライブラリの脆弱性を定期診断する • OWASP Dependency-Checkのプラグインを利用することでライブラリの脆弱性をチェック • 日次でdependencyCheckのタスクを実行するようなパイプラインを作成 • 一定以上のCVSSスコアのものを検知した場合は、スコアに応じてスケジューリングし対応 • テストを充実させたことでバージョンアップを安心して実施できるようになった 脆弱性チェックで検知した例

Slide 37

Slide 37 text

まとめ 37 • API開発の工夫 • OpenAPIを利用してサーバサイドとフロントエンドが効率よく分業できるようになった • springdoc-openapiを利用してOpenAPIを簡単に作成出来るようになった • 特定の画面に依存したAPIを作らないようにする • 技術負債を生まないための仕組みづくり • テストを書いてデグレを検知できる仕組みを作った • 脆弱性チェックにより問題が見つかってもすぐにバージョンアップできるようになった

Slide 38

Slide 38 text

ご清聴ありがとうございました 38