Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Effect-TSを利用した副作用を分離する設計について

 Effect-TSを利用した副作用を分離する設計について

2024/2/28開催した、レバレジーズxビザスク合同勉強会の発表資料です。

Tech Leverages

March 21, 2024
Tweet

More Decks by Tech Leverages

Other Decks in Technology

Transcript

  1. © 2023 Leverages Co., Ltd. レバレジーズ株式会社 竹下 義晃 テクノロジー戦略室室長 一般社団法人TSKaigi

    Association 代表理事、一般社団法人 Japan Scala Association理事兼任 2009年に東京大学大学院農学生命科学科を修了 芸者東京を経て 2020年にレバレジーズに入社 フルスタックの技術力を背景に、レバレジーズ社の技術の向上とエンジニア組織文化の構築に取り組む。また、 ScalaMatsuriやTSKaigiの運営にも関わり、技術コミュニティを盛り上げる活動も行っている。
  2. © 2023 Leverages Co., Ltd. 目次 1. 発表のスコープ 2. Effect-TSの紹介

    3. 副作用とは? 4. オブジェクト指向における副作用の分離方法 ◦ レイヤードアーキテクチャー ◦ Dependency Injection 5. Effect-TSでのDIの実現 3
  3. © 2023 Leverages Co., Ltd. • 主にバックエンドに関係する内容です ◦ フロントエンドでも使うことは出来ますが、恩恵は少ないかも •

    関数型言語のエッセンスが含まれています ◦ 理論的なところの説明はほぼしていません • オブジェクト指向の典型的な設計パターンの説明から入り、Effect-TSの導入 効果を説明しています • IOに関する副作用に焦点を当てて説明しています ◦ 参照透過の説明はいれてませんが、コードは参照透過になってます • 本日のソースコードは こちらのレポジトリにあります 発表のスコープ 4
  4. © 2023 Leverages Co., Ltd. 正式名称 Effect ですが、Googlabilityが無さすぎるので、スライドではEffect-TSと させて貰っています ScalaのZIOに影響を受けTypeScriptにポートしてきたライブラリ

    この資料作っている時に気付いたのですが、Effect Daysというイベントが開催されるぐらい海外で は使われていそう ZIO, Effの登場背景はモナドトランスフォーマーなどの歴史があるのですが、今回は説明しません。 Effect-TSとは 6
  5. © 2023 Leverages Co., Ltd. Effect is a powerful TypeScript

    library designed to help developers easily create complex, synchronous, and asynchronous programs. 最近流行りのResult型やEither型の機能を内包しています。 ついでにIOモナド、Taskモナド、Collectionモナドの機能も内包した便利なライブラ リです。 今回は主にIOモナドの特性の話になります Effect-TSとは 7
  6. © 2023 Leverages Co., Ltd. Wikipedia: 副作用 (プログラム) 引用: 評価値を得ることが主たる作用とされ、それ以外のコンピュータの論理的状

    態(ローカル環境以外の状態変数の値)を変化させる作用を副作用という 副作用がない場合、引数で同じ値を入力すると常に同じ結果が与えられます 副作用 - Side Effect 9
  7. © 2023 Leverages Co., Ltd. • クラスのフィールドを変更するメソッド • 引数に与えられたオブジェクトのフィールドを代入して上書きする •

    IO処理 ◦ ファイル入出力 ◦ DBへの読み書き など • 外部APIの利用 • 現在時刻の取得 • コンソール出力 実際の実装における副作用 10
  8. © 2023 Leverages Co., Ltd. DDDでも提唱されている概念 DDDの場合は、ドメインを中心に据えドメインルールをコードで表現(ただし、Entity をMutableに実装した場合は副作用ばりばり) 副作用(主にDBの読み書き) =>

    Repositoryパターンを利用し、実装を infrastructure層に移譲している レイヤードアーキテクチャーの種類としては • クリーンアーキテクチャー • オニオンアーキテクチャー • ヘキサゴナルアーキテクチャーなど レイヤードアーキテクチャー 13
  9. © 2023 Leverages Co., Ltd. 制御の反転(Inversion of Control)を行うことで、依存の方向性を逆転させる。 IoCにより、ドメインが基底となった一方通行の依存を実現出来る IoCの実現方法として依存性の注入(Dependency

    Injection)があり、DIするのを助 けるためにDI Containerライブラリが存在する • Java界隈では20年前くらい(正確な年数は調べてません)には既にあった技術 • Angular使った事ある人も、DIというのを聞いたことあるはず • TypeScriptにも tsyringe , InversifyJS などライブラリあり Dependency Injection 14
  10. © 2023 Leverages Co., Ltd. コード - 制御の反転せずに実装 15 //

    DB読み書きのライブラリのダミー class DBConnection { executeQuery(query: string): Promise<any> { … } } // センサー、エアコンのリモコンのライブラリのダミー class RemoteController { getTemperature(): Promise<number> { … } turnOnAC(): Promise<void> { … } } async autoTurnOnAC(userId: number): Promise<boolean> { const dbConnection = new DBConnection(); const remoteController = new RemoteController(); const [{ temperatureThreshold, wakeUpHour }] = await dbConnection.executeQuery( `SELECT * FROM UserSetting WHERE userId = ${userId}` ); const temperature = await remoteController.getTemperature(); if (temperature > temperatureThreshold) { console.log("設定温度より高いのでエアコンを付けない "); return false; } const currentTime = new Date(); if ( !( currentTime.getHours() >= wakeUpHour - 1 && currentTime.getHours() < wakeUpHour ) ) { console.log("起床時間の1時間前ではないのでエアコンを付けない "); return false; } try { console.log("エアコンをつける "); await remoteController.turnOnAC(); } catch (e: unknown) { console.error(e); return false; } return true; }
  11. © 2023 Leverages Co., Ltd. コード - 制御の反転せずに実装 16 async

    function test() { // 実行する時間帯や季節で結果が変わる // 物理のDBやエアコンを用意しないとテスト出来ない const autoACController = new AutoACController(); autoACController.autoTurnOnAC(1).then((result) => { assert(result === true); }); }
  12. © 2023 Leverages Co., Ltd. コード - IoC適用 18 //

    DB読み書きのライブラリのダミー interface DBConnection { executeQuery(query: string): Promise<any>; } // センサー、エアコンのリモコンのライブラリのダ ミー interface RemoteController { getTemperature(): Promise<number>; turnOnAC(): Promise<void>; } interface Clock { getNow(): Date; } export class AutoACController { constructor( private readonly dbConnection: DBConnection, private readonly remoteController: RemoteController, private readonly clock: Clock ) {} async autoTurnOnAC(userId: number): Promise<boolean> { const [{ temperatureThreshold, wakeUpHour }] = await this.dbConnection.executeQuery( `SELECT * FROM UserSetting WHERE userId = ${userId}` ); const temperature = await this.remoteController.getTemperature(); ... const currentTime = new Date(); ... try { console.log("エアコンをつける"); await this.remoteController.turnOnAC(); } catch (e: unknown) { console.error(e); return false; } return true; } }
  13. © 2023 Leverages Co., Ltd. コード - IoC適用 19 async

    function test() { const autoACController = new AutoACController( { executeQuery: (query: string) => Promise.resolve([{ temperatureThreshold: 20, wakeUpHour: 8 }]), }, { getTemperature: () => Promise.resolve(20), turnOnAC: () => Promise.resolve(), }, { getNow: () => new Date("2021-01-01T07:00:00Z"), } ); autoACController.autoTurnOnAC(1).then((result) => { assert(result === true); }); } • interfaceを使用する形に変更し、実 行時に具象クラスを渡す • 副作用のある処理を切り替えられる ようになった • ユニットテストが格段に書きやすく なっている ◦ Mock, Stubテストが可能
  14. © 2023 Leverages Co., Ltd. コード - tsyringe使用 21 @injectable()

    export class AutoACController { constructor( @inject("Database") private readonly dbConnection: DBConnection, @inject("RemoteController") private readonly remoteController: RemoteController, @inject("Clock") private readonly clock: Clock ) {} async autoTurnOnAC(userId: number): Promise<boolean> { const [{ temperatureThreshold, wakeUpHour }] = await this.dbConnection.executeQuery( `SELECT * FROM UserSetting WHERE userId = ${userId}` ); ... try { console.log("エアコンをつける"); await this.remoteController.turnOnAC(); } catch (e: unknown) { console.error(e); return false; } return true; } }
  15. © 2023 Leverages Co., Ltd. コード - tsyringe使用 22 function

    initInfrastructureLayer() { // 依存しているobjectの初期化の場所を変えられる container.register("Database", { useValue: { executeQuery: () => Promise.resolve([{ temperatureThreshold: 20, wakeUpHour: 8 }]), }, }); container.register("RemoteController", { useValue: { getTemperature: () => Promise.resolve(20), turnOnAC: () => Promise.resolve(), }, }); container.register("Clock", { useValue: { getNow: () => new Date("2021-01-01T07:00:00Z"), }, }); } function test() { const autoACController = container.resolve(AutoACController); autoACController.autoTurnOnAC(1).then((result) => { assert(result === true); }); } • DIするクラスが増えてくると管理や 初期化が大変になってくるのを助け てくれる
  16. © 2023 Leverages Co., Ltd. Effect<Success, Error, Requirement> の型定義を持つモナド Effect

    24 createUser( props: {email: string, password: string}): Effect<User, DuplicateEmailError | PasswordTooShortError, UserTable> Success = User作成成功したのでUserのデータが返ってくる Error = 処理中のエラー。同じEMailが存在、パスワード短すぎ Requirement => DBのUser Tableへのアクセスを要求 Requirementが副作用のある処理を示す(+グローバルな値)
  17. © 2023 Leverages Co., Ltd. DIの代替 25 class DBConnection extends

    Context.Tag("Database")< DBConnection, { executeQuery(query: string): Effect.Effect<any>; } >() {} class RemoteController extends Context.Tag("RemoteController")< RemoteController, { getTemperature(): Effect.Effect<number>; turnOnAC(): Effect.Effect<void>; } >() {} class Clock extends Context.Tag("Clock")< Clock, { getNow(): Effect.Effect<Date>; } >() {} function autoTurnOnAC( userId: number ): Effect.Effect< "TurnOn" | "EnoughTemperature" | "NotWakeUpTime", "ACNetworkError", DBConnection | RemoteController | Clock > { … } • 戻り値がすべてEffectになってい る • EffectはSuccess以外の型を省 略可能 • ADT化して処理をわかりやすく型 で表現しました
  18. © 2023 Leverages Co., Ltd. DIの代替 26 async function test()

    { /* Compile error const runnable = autoTurnOnAC(1); await Effect.runPromise(runnable); */ const runnable = Effect.provideService( Effect.provideService( Effect.provideService(autoTurnOnAC(1), DBConnection, { executeQuery: (query: string) => Effect.succeed([{ temperatureThreshold: 20, wakeUpHour: 8 }]), }), RemoteController, { getTemperature: () => Effect.succeed(20), turnOnAC: () => Effect.succeed(undefined), } ), Clock, { getNow: () => Effect.succeed(new Date("2021-01-01T07:00:00Z")), } ); const r = await Effect.runPromise(runnable); assert(r === "TurnOn"); } • provideServiceで実際の実行に 使われるObjectを渡している
  19. © 2023 Leverages Co., Ltd. 今さっきのDIのコードとそんなに変わらないのでは? 28 async function test()

    { const runnable = Effect.provideService( Effect.provideService( Effect.provideService(autoTurnOnAC(1), DBConnection, { executeQuery: (query: string) => Effect.succeed([{ temperatureThreshold: 20, wakeUpHour: 8 }]), }), RemoteController, { getTemperature: () => Effect.succeed(20), turnOnAC: () => Effect.succeed(undefined), } ), Clock, { getNow: () => Effect.succeed(new Date("2021-01-01T07:00:00Z")), } ); const r = await Effect.runPromise(runnable); assert(r === "TurnOn"); } function test() { container.register("Database", { useValue: { executeQuery: () => Promise.resolve([{ temperatureThreshold: 20, wakeUpHour: 8 }]), }, }); container.register("RemoteController", { useValue: { getTemperature: () => Promise.resolve(20), turnOnAC: () => Promise.resolve(), }, }); container.register("Clock", { useValue: { getNow: () => new Date("2021-01-01T07:00:00Z"), }, }); const autoACController = container.resolve(AutoACController); autoACController.autoTurnOnAC(1).then((result) => { assert(result === true); }); }
  20. © 2023 Leverages Co., Ltd. 処理を分割して合成 29 function getUserSetting( userId:

    number ): Effect.Effect<UserSetting, never, DBConnection> { return Effect.gen(function* (_) { const dbConn = yield* _(DBConnection); return yield* _( dbConn.executeQuery(`SELECT * FROM UserSetting WHERE userId = ${userId}`) ); }); } function checkTemperature( userSetting: UserSetting ): Effect.Effect<void, "EnoughTemperature", RemoteController> { ... } function checkWakeUpTime( userSetting: UserSetting ): Effect.Effect<void, "NotWakeUpTime", Clock> { ... } function autoTurnOnAC( userId: number ): Effect.Effect< "TurnOn" | "EnoughTemperature" | "NotWakeUpTime", "ACNetworkError", DBConnection | RemoteController | Clock > { return Effect.gen(function* (_) { const userSetting = yield* _(getUserSetting(userId)); yield* _(checkTemperature(userSetting)); yield* _(checkWakeUpTime(userSetting)); return yield* _(turnOnAC()); }).pipe( Effect.catchIf( (v): v is "EnoughTemperature" | "NotWakeUpTime" => v === "NotWakeUpTime" || v === "EnoughTemperature", (v) => Effect.succeed(v) ) ); } 型推論されるの で明示不要
  21. © 2023 Leverages Co., Ltd. 処理を分割して合成 30 async function testGetUserSetting()

    { await Effect.runPromise( Effect.provideService(getUserSetting(1), DBConnection, { executeQuery: (query: string) => Effect.succeed([{ temperatureThreshold: 20, wakeUpHour: 8 }]), }) ); } async function testCheckTemperature() { ... } async function testCheckWakeUpTime() { ... } • 処理を分割することで、テストの スコープも狭く出来る ◦ テストのパターンを減らせる • DIだと、クラス単位での依存性の 注入になりがちだが、Effectを利 用すると関数単位に容易に分割 可能
  22. © 2023 Leverages Co., Ltd. 余談 32 • 最近はユニットテストライブラリの充実で、DIしなくてもMock/Stubテストを書く ことが簡単になってきている

    ◦ 特にJS,TSはやりやすい印象 ◦ 他の言語も、実行時にByteCodeを改変によって実現出来てたりする • そのため、Mock/Stubテスト目的のDI使用の意味は薄れてきている • が、テストする範囲を狭めること、副作用を分離した設計にすることは依然とし てユニットテストを書きやすく出来るので有効