Slide 1

Slide 1 text

Effectで作る 堅牢でスケーラブルなAPIゲートウェイ Yuichi Goto (@_yasaichi) May 11,2024 @ TSKaigi

Slide 2

Slide 2 text

自己紹介  Yuichi Goto  @_yasaichi  @yasaichi  株式会社EARTHBRAIN シニアエンジニア  「パーフェクトRuby on Rails」共著者  texta.fm (ex-host) 2 2

Slide 3

Slide 3 text

[PR] EARTHBRAINは「建設現場のデジタル革命」に挑む企業です 技術的には「ハードとソフトの 高度な融合」に挑んでいる 画像出典: https://www.earthbrain.com/smartconstruction/ 3 3

Slide 4

Slide 4 text

本発表の背景と目的 背景: あるAPIゲートウェイのTypeScript,Deno,NestJSによるリプレース プロジェクトでの話(リプレース後の実装は検証環境で稼働している) 目的: 本プロジェクトで導入した「Effect」というライブラリを紹介すること 本ライブラリを導入した目的,結果,得られた学びを共有すること 4 4

Slide 5

Slide 5 text

Agenda 1. Effectの紹介 2. Effectの導入目的と結果 3. まとめ 5 5

Slide 6

Slide 6 text

とは何か 2024年4月に安定版のv3に到達した TypeScriptの新興ライブラリで, Effect System(※)を実装したもの [1] [2] 実体験から Effect Systemの詳細に立ち入らずとも,ソフトウェア開発の 実務で利用できると感じている (ただし個人差あり,後述) Option/Either,不変データ構造,パターンマッチング,DI,Telemetry等を 提供する標準ライブラリ的な側面もあるが,本発表では言及しない ※ プログラムのEffect(例: 副作用)を説明する形式システムで [3],コンピュータサイエンスにおける研究テーマの1つ 6 6

Slide 7

Slide 7 text

Effect型の値の定義と生成 型定義: Effect 第2,3引数はそれぞれ「失敗時のエラーの型」「依存の型」を表す 型の観点では 成功時以外の型も表現できるPromise という理解でよい 生成方法(一部抜粋): 関数から: Effect.sync , Effect.tryPromise , Effect.gen それ以外の値から: Effect.succeed , Effect.fail 7 7

Slide 8

Slide 8 text

Effect型の値の合成と実行 合成方法: pipe と yield* によってEffect型の値を別の処理の中で参照 することで実現(Promiseの then と await に“見かけ上”似ている) 実行方法(Promiseと異なり,明示的に実行する必要がある) Effect.run(Sync|Promise) : 成功時は値を(非)同期的に返し, 失敗 時はエラーを投げる Effect.run(Sync|Promise)Exit : 成功時は同上で,失敗時はエラーを 値として(非)同期的に返す 8 8

Slide 9

Slide 9 text

import { Effect } from 'effect'; const remainder = (n: number, d: number): Effect.Effect => !isFinite(n) || !isFinite(d) || d === 0 ? Effect.fail(new Error('Cannot calculate the remainder')) : Effect.succeed(n % d); const isEven = (n: number): Effect.Effect => remainder(n, 2).pipe(Effect.andThen((r) => r === 0)); Effect.runSync(isEven(42)); // true Effect.runSync(isEven(NaN)); // Error: Cannot calculate the remainder Effectを使った同期関数の実装と実行例: 剰余演算と偶数判定 コンパイラがisEvenのエラー型を remainderから推論できている 9 9

Slide 10

Slide 10 text

Agenda 1. Effectの紹介 2. Effectの導入目的と結果 3. まとめ 10 10

Slide 11

Slide 11 text

導入目的: ネットワークI/O起因のトレードオフへの対処 Effectを導入したのは,APIゲートウェイの実装に必ず含まれるネットワーク I/Oを伴う処理から生じる次のトレードオフに対処するため。 堅牢性とスケーラビリティ: 主要な関心事(サービス群へのAPIリクエスト, レスポンスの取捨選択と合成)を素直に実装すると犠牲になりがち コードの保守性: 堅牢性やスケーラビリティを考慮して注意深く実装すると 犠牲になりがち 11 11

Slide 12

Slide 12 text

export class UsersService { constructor( private readonly postApiService: PostApiService, private readonly userApiService: UserApiService, ) {} async findOne(id: number): Promise { try { const user = await this.userApiService.getUserById({ userId: id }); const posts = await this.postApiService.getPosts({ userId: id, limit: 5 }); return { id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), }; } catch (error) { if (error instanceof ApiException && error.code === 404) { throw new NotFoundException('User not found', { cause: error }); } throw error; } } } 素直な実装の例: ユーザーとその最新の投稿を取得し,結果を合成する 素直な実装のため保守性は高いが, 前述の観点で改善できる点がある ※ 実際のコードの理解には建設業のドメイン知識が必要になるため,「ユーザー」と「投稿」に置き換えている 12 12

Slide 13

Slide 13 text

// Before const user = await this.userApiService.getUserById({ userId: id }); // After const user = await retry( async () => { try { return await this.userApiService.getUserById({ userId: id }); } catch (error) { if (error instanceof ApiException && error.code === 404) { throw new AbortError( new NotFoundException('User not found', { cause: error }), ); } throw error; } }, { retries: 3 }, ); 堅牢性の観点: APIリクエストのリトライによる堅牢性向上の余地がある 13 13

Slide 14

Slide 14 text

// Before const user = await this.userApiService.getUserById({ userId: id }); const posts = await this.postApiService.getPosts({  userId: id, limit: 5 }); // After const [user, posts] = await Promise.all([ this.userApiService.getUserById({ userId: id }), this.postApiService.getPosts({  userId: id, limit: 5 }), ]); スケーラビリティの観点: APIリクエストの並行化による高速化の余地がある ユーザーIDが判明しているので,2つの APIリクエストを並行処理できる 14 14

Slide 15

Slide 15 text

async findOne(id: number): Promise { const [user, posts] = await Promise.all([ retry( async () => { try { return await this.userApiService.getUserById({ userId: id }); } catch (error) { if (error instanceof ApiException && error.code === 404) { throw new AbortError( new NotFoundException('User not found', { cause: error }), ); } throw error; } }, { retries: 3 }, ), retry( () => this.postApiService.getPosts({ userId: id, limit: 5 }), { retries: 3 }, ), ]); return { id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), }; } 改善後のコードは,素直な実装と比べて保守性が低下してしまう 1つの関数の中に複数の関心事が 入り組んでしまっていることが原因 15 15

Slide 16

Slide 16 text

Effectを導入するとどうなるか 16 16

Slide 17

Slide 17 text

findOne(id: number): Effect.Effect { return Effect.all( [ Effect .tryPromise(() => this.userApiService.getUserById({ userId: id })) .pipe(Effect.retry({ until: (error) => error instanceof ApiException && error.code === 404, times: 3, })), Effect .tryPromise(() => this.postApiService.getPosts({ userId: id, limit: 5 })) .pipe(Effect.retry({ times: 3 })), ], { concurrency: 'inherit' }, ).pipe( Effect.catchAll(({ error }) => error instanceof ApiException && error.code === 404 ? Effect.fail(new NotFoundException('User not found', { cause: error })) : Effect.die(error) ), Effect.andThen(([user, posts]) => ({ id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), })), ); } 複数の関心事を対応するコードブロックに分解でき,保守性が向上する 17 17

Slide 18

Slide 18 text

findOne(id: number): Effect.Effect { Effect .tryPromise(() => this.userApiService.getUserById({ userId: id })) Effect .tryPromise(() => this.postApiService.getPosts({ userId: id, limit: 5 })) Effect.andThen(([user, posts]) => ({ id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), })), } 主要な関心事: APIリクエスト,レスポンスの取捨選択と合成 18 18

Slide 19

Slide 19 text

findOne(id: number): Effect.Effect { .pipe(Effect.retry({ until: (error) => error instanceof ApiException && error.code === 404, times: 3, })), .pipe(Effect.retry({ times: 3 })), Effect.catchAll(({ error }) => error instanceof ApiException && error.code === 404 ? Effect.fail(new NotFoundException('User not found', { cause: error })) : Effect.die(error) } 副次的な関心事1: APIリクエストのリトライ,エラーハンドリング 19 19

Slide 20

Slide 20 text

findOne(id: number): Effect.Effect { return Effect.all( [ ], { concurrency: 'inherit' }, ).pipe( ); } 副次的な関心事2: APIリクエストの並行化 20 20

Slide 21

Slide 21 text

導入結果と得られた学び 結果: 前述のコードに対して他のメンバーから「読みづらい」「難しい」という 反応を受けたため,現在はNestJSの一部レイヤーへの導入に留めている 学び: Effectの理解に加えて, pipe を使ったプログラミングスタイルの 導入も想定より障壁が高いこと 今後: 約2週間前にもう一方のGeneratorを使ったスタイルで素晴らしい 改善(※)が入ったため,こちらを使って全体導入に再挑戦したい ※ 以前は yield* _(effect) のようにEffect型の値にアダプターをかます必要があったが,v3.0.4で不要になった [4] 21 21

Slide 22

Slide 22 text

// async/await const add = async () => { const x = await Promise.resolve(1); const y = await Promise.resolve(2); return x + y; }; // function*/yield* const add = Effect.gen(function* () { const x = yield* Effect.succeed(1); const y = yield* Effect.succeed(2); return x + y; }); Generatorを使うとasync/awaitとほぼ同じメンタルモデルで実装できる 22 22

Slide 23

Slide 23 text

findOne(id: number): Effect.Effect { return Effect.gen(this, function* () { const [user, posts] = yield* Effect.all( [ Effect.retry( Effect.tryPromise(() => this.userApiService.getUserById({ userId: id })), { until: (error) => error instanceof ApiException && error.code === 404, times: 3, }, ), Effect.retry( Effect.tryPromise(() => this.postApiService.getPosts({ userId: id, limit: 5 })), { times: 3 }, ), ], { concurrency: 'inherit' }, ); return { id: user.id, username: user.username, latestPosts: posts.map((post) => ({ id: post.id, title: post.title })), }; }).pipe( Effect.catchAll(({ error }) => error instanceof ApiException && error.code === 404 ? Effect.fail(new NotFoundException('User not found', { cause: error })) : Effect.die(error) ), ); } 前述のServiceに適用するとmap/andThenがなくなりEasyな見た目に pipeを完全に使わない=Generator内でエラーを扱う ためには,Either型を導入する必要がある(次の論点) 23 23

Slide 24

Slide 24 text

Agenda 1. Effectの紹介 2. Effectの導入目的と結果 3. まとめ 24 24

Slide 25

Slide 25 text

まとめ EffectはTypeScriptの新興ライブラリで,開発者はPromiseに似たより型 安全な値を pipe や yield* で合成することで任意の処理を実装する 発表者はあるAPIゲートウェイの開発において,「堅牢性とスケーラビリティ」 「コードの保守性」のトレードオフを解決するため,本ライブラリを導入した 一定の成果が挙げられたが, pipe を使った実装スタイルの障壁が高いと 判明したため,全体導入には至っていない(Generatorスタイルで再挑戦) 25 25

Slide 26

Slide 26 text

おわりに: Michael Arnaldi氏(BDFL of Effect)曰く 出典: https://x.com/MichaelArnaldi/status/1661478108447535105 26 26

Slide 27

Slide 27 text

ご清聴ありがとうございました This presentation is created by Marp. Great thanks @yhatt! 27 27

Slide 28

Slide 28 text

参考文献 1. Effect 3.0 – Effect Blog,URL: https://effect.website/blog/effect-3.0 2. effect/README.md at e9875da3732bc67bb62789f2850d34abe7eb873d · Effect-TS/effect,URL: https://g ithub.com/Effect-TS/effect/blob/e9875da3732bc67bb62789f2850d34abe7eb873d/README.md#effect 3. Nielson, F., & Nielson, H.R. (1999). Type and Effect Systems. Correct System Design. 4. Release [email protected] · Effect-TS/effect,URL: https://github.com/Effect-TS/effect/releases/tag/effect% 403.0.4 28 28