$30 off During Our Annual Pro Sale. View Details »

Effectで作る堅牢でスケーラブルなAPIゲートウェイ / Robust and Scala...

Effectで作る堅牢でスケーラブルなAPIゲートウェイ / Robust and Scalable API Gateway Built on Effect

May 11, 2024 @ TSKaigi

Yuichi Goto

May 11, 2024
Tweet

More Decks by Yuichi Goto

Other Decks in Programming

Transcript

  1. 自己紹介  Yuichi Goto  @_yasaichi  @yasaichi  株式会社EARTHBRAIN

    シニアエンジニア  「パーフェクトRuby on Rails」共著者  texta.fm (ex-host) 2 2
  2. とは何か 2024年4月に安定版のv3に到達した TypeScriptの新興ライブラリで, Effect System(※)を実装したもの [1] [2] 実体験から Effect Systemの詳細に立ち入らずとも,ソフトウェア開発の

    実務で利用できると感じている (ただし個人差あり,後述) Option/Either,不変データ構造,パターンマッチング,DI,Telemetry等を 提供する標準ライブラリ的な側面もあるが,本発表では言及しない ※ プログラムのEffect(例: 副作用)を説明する形式システムで [3],コンピュータサイエンスにおける研究テーマの1つ 6 6
  3. Effect型の値の合成と実行 合成方法: pipe と yield* によってEffect型の値を別の処理の中で参照 することで実現(Promiseの then と await

    に“見かけ上”似ている) 実行方法(Promiseと異なり,明示的に実行する必要がある) Effect.run(Sync|Promise) : 成功時は値を(非)同期的に返し, 失敗 時はエラーを投げる Effect.run(Sync|Promise)Exit : 成功時は同上で,失敗時はエラーを 値として(非)同期的に返す 8 8
  4. import { Effect } from 'effect'; const remainder = (n:

    number, d: number): Effect.Effect<number, Error> => !isFinite(n) || !isFinite(d) || d === 0 ? Effect.fail(new Error('Cannot calculate the remainder')) : Effect.succeed(n % d); const isEven = (n: number): Effect.Effect<boolean, Error> => 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
  5. export class UsersService { constructor( private readonly postApiService: PostApiService, private

    readonly userApiService: UserApiService, ) {} async findOne(id: number): Promise<FindUserResponseDto> { 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
  6. // 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
  7. // 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
  8. async findOne(id: number): Promise<FindUserResponseDto> { 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
  9. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { 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
  10. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { 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
  11. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { .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
  12. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { return Effect.all( [ ], {

    concurrency: 'inherit' }, ).pipe( ); } 副次的な関心事2: APIリクエストの並行化 20 20
  13. 導入結果と得られた学び 結果: 前述のコードに対して他のメンバーから「読みづらい」「難しい」という 反応を受けたため,現在はNestJSの一部レイヤーへの導入に留めている 学び: Effectの理解に加えて, pipe を使ったプログラミングスタイルの 導入も想定より障壁が高いこと 今後:

    約2週間前にもう一方のGeneratorを使ったスタイルで素晴らしい 改善(※)が入ったため,こちらを使って全体導入に再挑戦したい ※ 以前は yield* _(effect) のようにEffect型の値にアダプターをかます必要があったが,v3.0.4で不要になった [4] 21 21
  14. // 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
  15. findOne(id: number): Effect.Effect<FindUserResponseDto, NotFoundException> { 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
  16. 参考文献 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