Slide 1

Slide 1 text

Confidential © 2023 Leverages Co., Ltd. Effect-TSを利用した 副作用を分離する設計について 2024/03/13 レバレジーズ株式会社 テクノロジー戦略室室長 竹下義晃

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

© 2023 Leverages Co., Ltd. 目次 1. 発表のスコープ 2. Effect-TSの紹介 3. 副作用とは? 4. オブジェクト指向における副作用の分離方法 ○ レイヤードアーキテクチャー ○ Dependency Injection 5. Effect-TSでのDIの実現 3

Slide 4

Slide 4 text

© 2023 Leverages Co., Ltd. ● 主にバックエンドに関係する内容です ○ フロントエンドでも使うことは出来ますが、恩恵は少ないかも ● 関数型言語のエッセンスが含まれています ○ 理論的なところの説明はほぼしていません ● オブジェクト指向の典型的な設計パターンの説明から入り、Effect-TSの導入 効果を説明しています ● IOに関する副作用に焦点を当てて説明しています ○ 参照透過の説明はいれてませんが、コードは参照透過になってます ● 本日のソースコードは こちらのレポジトリにあります 発表のスコープ 4

Slide 5

Slide 5 text

© 2023 Leverages Co., Ltd. Effect-TSの紹介 01 5

Slide 6

Slide 6 text

© 2023 Leverages Co., Ltd. 正式名称 Effect ですが、Googlabilityが無さすぎるので、スライドではEffect-TSと させて貰っています ScalaのZIOに影響を受けTypeScriptにポートしてきたライブラリ この資料作っている時に気付いたのですが、Effect Daysというイベントが開催されるぐらい海外で は使われていそう ZIO, Effの登場背景はモナドトランスフォーマーなどの歴史があるのですが、今回は説明しません。 Effect-TSとは 6

Slide 7

Slide 7 text

© 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

Slide 8

Slide 8 text

© 2023 Leverages Co., Ltd. 副作用とは 02 8

Slide 9

Slide 9 text

© 2023 Leverages Co., Ltd. Wikipedia: 副作用 (プログラム) 引用: 評価値を得ることが主たる作用とされ、それ以外のコンピュータの論理的状 態(ローカル環境以外の状態変数の値)を変化させる作用を副作用という 副作用がない場合、引数で同じ値を入力すると常に同じ結果が与えられます 副作用 - Side Effect 9

Slide 10

Slide 10 text

© 2023 Leverages Co., Ltd. ● クラスのフィールドを変更するメソッド ● 引数に与えられたオブジェクトのフィールドを代入して上書きする ● IO処理 ○ ファイル入出力 ○ DBへの読み書き など ● 外部APIの利用 ● 現在時刻の取得 ● コンソール出力 実際の実装における副作用 10

Slide 11

Slide 11 text

© 2023 Leverages Co., Ltd. 副作用があると基本的には、状態管理のコードが複雑化し、テスト困難になりバグ が起きやすくなる なので、なるべく副作用をなくしつつ、どうしても必要な副作用は局所化するのが最 近のセオリー キーワード: Immutable, 関数型, DI, 冪等性 副作用が無いことがなぜ重要か? 11

Slide 12

Slide 12 text

© 2023 Leverages Co., Ltd. オブジェクト指向における 副作用の分離方法 03 12

Slide 13

Slide 13 text

© 2023 Leverages Co., Ltd. DDDでも提唱されている概念 DDDの場合は、ドメインを中心に据えドメインルールをコードで表現(ただし、Entity をMutableに実装した場合は副作用ばりばり) 副作用(主にDBの読み書き) => Repositoryパターンを利用し、実装を infrastructure層に移譲している レイヤードアーキテクチャーの種類としては ● クリーンアーキテクチャー ● オニオンアーキテクチャー ● ヘキサゴナルアーキテクチャーなど レイヤードアーキテクチャー 13

Slide 14

Slide 14 text

© 2023 Leverages Co., Ltd. 制御の反転(Inversion of Control)を行うことで、依存の方向性を逆転させる。 IoCにより、ドメインが基底となった一方通行の依存を実現出来る IoCの実現方法として依存性の注入(Dependency Injection)があり、DIするのを助 けるためにDI Containerライブラリが存在する ● Java界隈では20年前くらい(正確な年数は調べてません)には既にあった技術 ● Angular使った事ある人も、DIというのを聞いたことあるはず ● TypeScriptにも tsyringe , InversifyJS などライブラリあり Dependency Injection 14

Slide 15

Slide 15 text

© 2023 Leverages Co., Ltd. コード - 制御の反転せずに実装 15 // DB読み書きのライブラリのダミー class DBConnection { executeQuery(query: string): Promise { … } } // センサー、エアコンのリモコンのライブラリのダミー class RemoteController { getTemperature(): Promise { … } turnOnAC(): Promise { … } } async autoTurnOnAC(userId: number): Promise { 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; }

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

© 2023 Leverages Co., Ltd. ClassDiagram 17

Slide 18

Slide 18 text

© 2023 Leverages Co., Ltd. コード - IoC適用 18 // DB読み書きのライブラリのダミー interface DBConnection { executeQuery(query: string): Promise; } // センサー、エアコンのリモコンのライブラリのダ ミー interface RemoteController { getTemperature(): Promise; turnOnAC(): Promise; } 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 { 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; } }

Slide 19

Slide 19 text

© 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テストが可能

Slide 20

Slide 20 text

© 2023 Leverages Co., Ltd. ClassDiagram 20

Slide 21

Slide 21 text

© 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 { 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; } }

Slide 22

Slide 22 text

© 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するクラスが増えてくると管理や 初期化が大変になってくるのを助け てくれる

Slide 23

Slide 23 text

© 2023 Leverages Co., Ltd. Effect-TSでDIの実現 04 23

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

© 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を渡している

Slide 27

Slide 27 text

© 2023 Leverages Co., Ltd. Class Diagram 27

Slide 28

Slide 28 text

© 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); }); }

Slide 29

Slide 29 text

© 2023 Leverages Co., Ltd. 処理を分割して合成 29 function getUserSetting( userId: number ): Effect.Effect { return Effect.gen(function* (_) { const dbConn = yield* _(DBConnection); return yield* _( dbConn.executeQuery(`SELECT * FROM UserSetting WHERE userId = ${userId}`) ); }); } function checkTemperature( userSetting: UserSetting ): Effect.Effect { ... } function checkWakeUpTime( userSetting: UserSetting ): Effect.Effect { ... } 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) ) ); } 型推論されるの で明示不要

Slide 30

Slide 30 text

© 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を利 用すると関数単位に容易に分割 可能

Slide 31

Slide 31 text

© 2023 Leverages Co., Ltd. Class Diagram 31

Slide 32

Slide 32 text

© 2023 Leverages Co., Ltd. 余談 32 ● 最近はユニットテストライブラリの充実で、DIしなくてもMock/Stubテストを書く ことが簡単になってきている ○ 特にJS,TSはやりやすい印象 ○ 他の言語も、実行時にByteCodeを改変によって実現出来てたりする ● そのため、Mock/Stubテスト目的のDI使用の意味は薄れてきている ● が、テストする範囲を狭めること、副作用を分離した設計にすることは依然とし てユニットテストを書きやすく出来るので有効

Slide 33

Slide 33 text

© 2023 Leverages Co., Ltd. ご清聴ありがとうございました 33