宣言型プログラミングを活用して保守可能なWebアプリを作成する

公開: 2022-03-11

この記事では、宣言型プログラミング手法を慎重に採用することで、チームが拡張と保守が容易なWebアプリケーションを作成できることを示します。

「…宣言型プログラミングは、制御フローを記述せずに計算のロジックを表現するプログラミングパラダイムです。」 —Remo H. Jansen、 TypeScriptを使用したハンズオン機能プログラミング

ソフトウェアのほとんどの問題と同様に、アプリケーションで宣言型プログラミング手法を使用することを決定するには、トレードオフを慎重に評価する必要があります。 これらの詳細については、以前の記事の1つを確認してください。

ここでは、複数のパラダイムをサポートする言語であるJavaScriptで記述された新しいアプリケーションと既存のアプリケーションの両方に、宣言型プログラミングパターンを徐々に採用する方法に焦点を当てています。

最初に、バックエンドとフロントエンドの両方でTypeScriptを使用して、コードをより表現力豊かにし、変更に対して回復力を持たせる方法について説明します。 次に、有限状態マシン(FSM)を調査して、フロントエンド開発を合理化し、開発プロセスへの利害関係者の関与を増やします。

FSMは新しいテクノロジーではありません。 それらは約50年前に発見され、信号処理、航空学、金融など、ソフトウェアの正確性が重要になる可能性のある業界で人気があります。 また、複雑な非同期状態の更新やアニメーションの調整など、最新のWeb開発で頻繁に発生する問題のモデリングにも非常に適しています。

この利点は、状態の管理方法に制約があるために発生します。 ステートマシンは同時に1つの状態にしかなれず、外部イベント(マウスクリックやフェッチ応答など)に応答して遷移できる隣接状態が制限されています。 その結果、通常、不良率が大幅に低下します。 ただし、FSMアプローチは、大規模なアプリケーションで適切に機能するようにスケールアップするのが難しい場合があります。 ステートチャートと呼ばれるFSMの最近の拡張により、複雑なFSMを視覚化し、はるかに大規模なアプリケーションに拡張できます。これは、この記事で焦点を当てている有限状態マシンのフレーバーです。 デモンストレーションでは、JavaScriptのFSMとステートチャートに最適なソリューションの1つであるXStateライブラリを使用します。

Node.jsを使用したバックエンドでの宣言型

宣言型アプローチを使用したWebサーバーバックエンドのプログラミングは大きなトピックであり、通常、適切なサーバー側の関数型プログラミング言語を評価することから始める場合があります。 代わりに、バックエンドにNode.jsをすでに選択している(または検討している)ときにこれを読んでいると仮定しましょう。

このセクションでは、バックエンドでエンティティをモデリングするためのアプローチについて詳しく説明します。これには、次の利点があります。

  • コードの読みやすさの向上
  • より安全なリファクタリング
  • タイプモデリングが提供する保証により、パフォーマンスが向上する可能性

タイプモデリングによる動作保証

JavaScript

JavaScriptの電子メールアドレスを介して特定のユーザーを検索するタスクを考えてみましょう。

 function validateEmail(email) { if (typeof email !== "string") return false; return isWellFormedEmailAddress(email); } function lookupUser(validatedEmail) { // Assume a valid email is passed in. // Safe to pass this down to the database for a user lookup.. }

この関数は、電子メールアドレスを文字列として受け入れ、一致する場合はデータベースから対応するユーザーを返します。

lookupUser()は、基本的な検証が実行された後にのみ呼び出されると想定されています。 これは重要な前提です。 数週間後、リファクタリングが実行され、この仮定がもはや成り立たなくなった場合はどうなりますか? ユニットテストでバグが検出された、またはフィルタリングされていないテキストをデータベースに送信している可能性があることを確認しました。

TypeScript(最初の試み)

検証関数に相当するTypeScriptについて考えてみましょう。

 function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }

これはわずかな改善であり、TypeScriptコンパイラにより、ランタイム検証ステップを追加する必要がなくなりました。

強い型付けがもたらす安全性は、まだ実際には利用されていないことを保証します。 それを調べてみましょう。

TypeScript(2回目の試行)

型の安全性を改善し、未処理の文字列をlooukupUserへの入力として渡さないようにしましょう。

 type ValidEmail = { value: string }; function validateEmail(input: string): Email | null { if (!isWellFormedEmailAddress(input)) return null; return { value: email }; } function lookupUser(email: ValidEmail): User { // No need to perform validation. Compiler has already ensured only valid emails have been passed in. return lookupUserInDatabase(email.value); }

これは良いですが、面倒です。 ValidEmailを使用する場合はすべて、 ValidEmailを介して実際のアドレスにアクセスしemail.value 。 TypeScriptは、JavaやC#などの言語で採用されている名目上の型付けではなく、構造型の型付けを採用しています。

これは強力ですが、この署名に準拠する他のタイプは同等と見なされることを意味します。 たとえば、次のパスワードタイプは、コンパイラからの苦情なしにlookupUser()に渡すことができます。

 type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.

TypeScript(3回目の試行)

交差点を使用して、TypeScriptで名目上の型付けを実現できます。

 type ValidEmail = string & { _: "ValidEmail" }; function validateEmail(input: string): ValidEmail { // Perform email validation checks.. return input as ValidEmail; } type ValidPassword = string & { _: "ValidPassword" }; function validatePassword(input: string): ValidPassword { ... } lookupUser("[email protected]"); // Error: expected type ValidEmail. lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail. lookupUser(validateEmail("[email protected]")); // Ok.

これで、検証済みの電子メール文字列のみをlookupUser()に渡すことができるという目標を達成しました。

上級者向けのヒント:次のヘルパータイプを使用して、このパターンを簡単に適用できます。

 type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;

長所

ドメイン内のエンティティを強く入力することで、次のことができます。

  1. 実行時に実行する必要のあるチェックの数を減らします。これは、貴重なサーバーCPUサイクルを消費します(非常に少量ですが、1分あたり数千の要求を処理する場合は合計されます)。
  2. TypeScriptコンパイラが提供する保証により、維持する基本的なテストが少なくなります。
  3. エディターおよびコンパイラーを利用したリファクタリングを利用してください。
  4. 信号対雑音比を改善することにより、コードの可読性を向上させます。

短所

タイプモデリングには、考慮すべきいくつかのトレードオフがあります。

  1. TypeScriptを導入すると、通常、ツールチェーンが複雑になり、ビルドとテストスイートの実行時間が長くなります。
  2. 機能のプロトタイプを作成してできるだけ早くユーザーの手に渡らせることが目標である場合、型を明示的にモデル化してコードベース全体に伝播するために必要な追加の作業は、価値がない可能性があります。

サーバーまたは共有バックエンド/フロントエンド検証レイヤー上の既存のJavaScriptコードをタイプで拡張して、コードの可読性を向上させ、より安全なリファクタリングを可能にする方法を示しました。これはチームにとって重要な要件です。

宣言型ユーザーインターフェイス

宣言型プログラミング手法を使用して開発されたユーザーインターフェイスは、「方法」よりも「何」を記述することに重点を置いています。 Webの主要な3つの基本要素のうちの2つであるCSSとHTMLは、時間の試練と10億を超えるWebサイトに耐えてきた宣言型プログラミング言語です。

Webを動かす主な言語
Webを動かす主な言語。

Reactは2013年にFacebookによってオープンソース化され、フロントエンド開発のコースを大幅に変更しました。 私が最初にそれを使用したとき、アプリケーションの状態の関数としてGUIを宣言する方法が好きでした。 DOM操作の厄介な詳細を処理したり、ユーザーの操作に応じてアプリのどの部分を更新する必要があるかを追跡したりすることなく、小さなビルディングブロックから大きくて複雑なUIを作成できるようになりました。 UIを定義するときは時間の側面をほとんど無視して、アプリケーションが1つの状態から次の状態に正しく遷移することを確認することに集中できます。

フロントエンドJavaScriptの方法から内容への進化
フロントエンドJavaScriptの方法から内容への進化。

UIを開発するためのより簡単な方法を実現するために、Reactは開発者とマシン/ブラウザーの間に抽象化レイヤー(仮想DOM )を挿入しました。

他の最新のWebUIフレームワークも、さまざまな方法ではありますが、このギャップを埋めています。 たとえば、Vueは、JavaScriptゲッター/セッター(Vue 2)またはプロキシ(Vue 3)のいずれかを介して機能的な反応性を採用しています。 Svelteは、追加のソースコードコンパイルステップ(Svelte)を通じて反応性をもたらします。

これらの例は、開発者が宣言型アプローチを通じてアプリケーションの動作を表現するためのより優れた、よりシンプルなツールを提供したいという業界の大きな願望を示しているようです。

宣言型アプリケーションの状態とロジック

プレゼンテーション層は引き続き何らかの形式のHTML(たとえば、ReactのJSX、Vue、Angular、SvelteにあるHTMLベースのテンプレート)を中心に展開しますが、アプリケーションの状態を次のようにモデル化する方法の問題を想定しています。他の開発者が簡単に理解でき、アプリケーションの成長に合わせて保守できるのはまだ解決されていません。 これの証拠は、今日まで続く状態管理ライブラリとアプローチの急増を通じて見られます。

最新のWebアプリへの期待が高まっているため、状況は複雑になっています。 現代の状態管理アプローチがサポートしなければならないいくつかの新たな課題:

  • 高度なサブスクリプションおよびキャッシング技術を使用したオフラインファーストアプリケーション
  • 縮小し続けるバンドルサイズ要件に対応する簡潔なコードとコードの再利用
  • 忠実度の高いアニメーションとリアルタイムの更新を通じて、ますます洗練されたユーザーエクスペリエンスに対する需要

有限状態マシンとステートチャートの(再)出現

有限状態マシンは、航空や金融など、アプリケーションの堅牢性が重要な特定の業界でソフトウェア開発に広く使用されています。 また、優れたXStateライブラリなどを通じて、Webアプリのフロントエンド開発でも着実に人気が高まっています。

ウィキペディアでは、有限状態マシンを次のように定義しています。

いつでも有限数の状態の1つに正確に存在できる抽象マシン。 FSMは、いくつかの外部入力に応じて、ある状態から別の状態に変化する可能性があります。 ある状態から別の状態への変化は、遷移と呼ばれます。 FSMは、その状態、初期状態、および各遷移の条件のリストによって定義されます。

そしてさらに:

状態は、遷移の実行を待機しているシステムのステータスの説明です。

基本的な形式のFSMは、状態の爆発の問題のため、大規模なシステムにうまく拡張できません。 最近、UMLステートチャートが作成され、階層と同時実行性を備えたFSMが拡張されました。これにより、商用アプリケーションでFSMを幅広く使用できるようになります。

アプリケーションロジックを宣言する

まず、FSMはコードとしてどのように見えますか? JavaScriptで有限状態マシンを実装する方法はいくつかあります。

  • switchステートメントとしての有限状態マシン

これは、JavaScriptが存在する可能性のある状態を記述し、switchステートメントを使用して実装されたマシンです。

 const initialState = { type: 'idle', error: undefined, result: undefined }; function transition(state = initialState, action) { switch (action) { case 'invoke': return { type: 'pending' }; case 'resolve': return { type: 'completed', result: action.value }; case 'error': return { type: 'completed', error: action.error ; default: return state; } }

このスタイルのコードは、人気のあるRedux状態管理ライブラリを使用した開発者にはおなじみです。

  • JavaScriptオブジェクトとしての有限状態マシン

これは、JavaScriptXStateライブラリを使用してJavaScriptオブジェクトとして実装された同じマシンです。

 const promiseMachine = Machine({ id: "promise", initial: "idle", context: { result: undefined, error: undefined, }, states: { idle: { on: { INVOKE: "pending", }, }, pending: { on: { RESOLVE: "success", REJECT: "failure", }, }, success: { type: "final", actions: assign({ result: (context, event) => event.data, }), }, failure: { type: "final", actions: assign({ error: (context, event) => event.data, }), }, }, });

XStateバージョンはコンパクトではありませんが、オブジェクト表現にはいくつかの利点があります。

  1. ステートマシン自体は単純なJSONであり、簡単に永続化できます。
  2. 宣言型であるため、マシンを視覚化できます。
  3. TypeScriptを使用する場合、コンパイラは有効な状態遷移のみが実行されることを確認します。

XStateはステートチャートをサポートし、SCXML仕様を実装しているため、非常に大規模なアプリケーションでの使用に適しています。

約束のステートチャートの視覚化:

約束の有限状態マシン
約束の有限状態マシン。

XStateのベストプラクティス

以下は、プロジェクトを保守可能に保つためにXStateを使用するときに適用するいくつかのベストプラクティスです。

副作用をロジックから分離する

XStateを使用すると、副作用(ロギングやAPIリクエストなどのアクティビティを含む)をステートマシンのロジックから独立して指定できます。

これには次の利点があります。

  1. ステートマシンコードを可能な限りクリーンでシンプルに保つことにより、ロジックエラーの検出を支援します。
  2. 最初に余分なボイラープレートを取り外すことなく、ステートマシンを簡単に視覚化できます。
  3. モックサービスを注入することにより、ステートマシンのテストが容易になります。
 const fetchUsersMachine = Machine({ id: "fetchUsers", initial: "idle", context: { users: undefined, error: undefined, nextPage: 0, }, states: { idle: { on: { FETCH: "fetching", }, }, fetching: { invoke: { src: (context) => fetch(`url/to/users?page=${context.nextPage}`).then((response) => response.json() ), onDone: { target: "success", actions: assign({ users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users nextPage: (context) => context.nextPage + 1, }), }, onError: { target: "failure", error: (_, event) => event.data, // Data holds the error }, }, }, // success state.. // failure state.. }, });

まだ動作している間にこのようにステートマシンを作成することは魅力的ですが、副作用をオプションとして渡すことで、関心の分離をより適切に行うことができます。

 const services = { getUsers: (context) => fetch( `url/to/users?page=${context.nextPage}` ).then((response) => response.json()) } const fetchUsersMachine = Machine({ ... states: { ... fetching: { invoke: { // Invoke the side effect at key: 'getUsers' in the supplied services object. src: 'getUsers', } on: { RESOLVE: "success", REJECT: "failure", }, }, ... }, // Supply the side effects to be executed on state transitions. { services } });

これにより、ステートマシンの単体テストも簡単になり、ユーザーフェッチの明示的なモックが可能になります。

 async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });

大型機械の分割

開始時に、問題のあるドメインを適切な有限状態のマシン階層に構造化するのに最適な方法がすぐにわかるとは限りません。

ヒント: UIコンポーネントの階層を使用して、このプロセスをガイドします。 ステートマシンをUIコンポーネントにマップする方法については、次のセクションを参照してください。

ステートマシンを使用する主な利点は、アプリケーション内のすべての状態と状態間の遷移を明示的にモデル化して、結果の動作を明確に理解し、論理エラーやギャップを簡単に見つけられるようにすることです。

これがうまく機能するためには、機械を小さく簡潔に保つ必要があります。 幸い、ステートマシンを階層的に構成するのは簡単です。 信号機システムの標準的なステートチャートの例では、「赤」の状態自体が子ステートマシンになります。 親の「ライト」マシンは「赤」の内部状態を認識していませんが、「赤」に入るタイミングと、終了時の意図された動作を決定します。

ステートチャートを使用した信号機の例
ステートチャートを使用した信号機の例。

1-1ステートマシンのステートフルUIコンポーネントへのマッピング

たとえば、次のReactビューを持つ非常に単純化された架空のeコマースサイトを考えてみましょう。

 <App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>

上記のビューに対応するステートマシンを生成するプロセスは、Redux状態管理ライブラリを使用したことがある人にはおなじみかもしれません。

  1. コンポーネントには、モデル化する必要のある状態がありますか? たとえば、管理者/製品はそうではない場合があります。 サーバーへのページフェッチとキャッシュソリューション(SWRなど)で十分な場合があります。 一方、SignInFormやCartなどのコンポーネントには、通常、フィールドに入力されたデータや現在のカートの内容など、管理する必要のある状態が含まれています。
  2. 問題を捉えるには、ローカル状態の手法(ReactのsetState() / useState() )で十分ですか? カートポップアップモーダルが現在開いているかどうかを追跡するには、有限状態マシンを使用する必要はほとんどありません。
  3. 結果として得られるステートマシンは複雑すぎる可能性がありますか? その場合は、マシンをいくつかの小さなマシンに分割し、他の場所で再利用できる子マシンを作成する機会を特定します。 たとえば、SignInFormマシンとRegistrationFormマシンは、子textFieldMachineのインスタンスを呼び出して、ユーザーの電子メール、名前、およびパスワードフィールドの検証と状態をモデル化する場合があります。

有限状態マシンモデルを使用する場合

ステートチャートとFSMはいくつかの困難な問題をエレガントに解決できますが、特定のアプリケーションに使用する最適なツールとアプローチを決定することは、通常、いくつかの要因に依存します。

有限状態マシンの使用が光るいくつかの状況:

  • アプリケーションには、フィールドのアクセス可能性または可視性が複雑なルールによって管理されるかなりのデータ入力コンポーネントが含まれています。たとえば、保険金請求アプリのフォーム入力です。 ここで、FSMは、ビジネスルールが確実に実装されるようにするのに役立ちます。 さらに、ステートチャートの視覚化機能を使用して、技術者以外の利害関係者とのコラボレーションを強化し、開発の早い段階で詳細なビジネス要件を特定できます。
  • 低速の接続でより適切に機能し、ユーザーにより忠実なエクスペリエンスを提供するには、Webアプリはますます複雑になる非同期データフローを管理する必要があります。 FSMは、アプリケーションが存在する可能性のあるすべての状態を明示的にモデル化し、ステートチャートを視覚化して、非同期データの問題の診断と解決に役立てることができます。
  • 多くの洗練された状態ベースのアニメーションを必要とするアプリケーション。 複雑なアニメーションの場合、RxJSを使用してアニメーションをイベントストリームとしてモデル化する手法が一般的です。 多くのシナリオでは、これはうまく機能しますが、リッチアニメーションが複雑な一連の既知の状態と組み合わされると、FSMは、アニメーションがその間を流れる明確に定義された「レストポイント」を提供します。 RxJSと組み合わせたFSMは、忠実で表現力豊かなユーザーエクスペリエンスの次の波を提供するのに役立つ完璧な組み合わせのようです。
  • 写真やビデオの編集、図作成ツール、ゲームなど、ビジネスロジックの多くがクライアント側にあるリッチクライアントアプリケーション。 FSMは、本質的にUIフレームワークまたはライブラリから切り離されており、高品質のアプリケーションをすばやく反復して自信を持って出荷できるようにするためのテストを簡単に作成できます。

有限状態マシンの警告

  • XStateなどのステートチャートライブラリの一般的なアプローチ、ベストプラクティス、およびAPIは、ほとんどのフロントエンド開発者にとって斬新であり、特に経験の浅いチームにとって、生産性を高めるには時間とリソースの投資が必要になります。
  • 前の警告と同様に、XStateの人気は高まり続け、十分に文書化されていますが、Redux、MobX、React Contextなどの既存の状態管理ライブラリには、XStateがまだ一致していない豊富なオンライン情報を提供する膨大な数のフォロワーがいます。
  • より単純なCRUDモデルに従うアプリケーションの場合、SWRやReactQueryなどの優れたリソースキャッシングライブラリと組み合わせた既存の状態管理手法で十分です。 ここで、FSMが提供する追加の制約は、複雑なアプリでは非常に役立ちますが、開発が遅くなる可能性があります。
  • このツールは、他の状態管理ライブラリよりも成熟度が低く、TypeScriptサポートの改善とブラウザのdevtools拡張機能に関する作業がまだ進行中です。

まとめ

Web開発コミュニティでの宣言型プログラミングの人気と採用は増え続けています。

現代のWeb開発はますます複雑になっていますが、宣言型プログラミングを採用するライブラリとフレームワークは、ますます頻繁に登場しています。 その理由は明らかです。ソフトウェアを作成するためのよりシンプルでわかりやすいアプローチを作成する必要があります。

TypeScriptなどの強く型付けされた言語を使用すると、アプリケーションドメイン内のエンティティを簡潔かつ明示的にモデル化できるため、エラーが発生する可能性が低くなり、操作が必要なエラーが発生しやすいチェックコードの量が減ります。 フロントエンドに有限状態マシンとステートチャートを採用することで、開発者は状態遷移を通じてアプリケーションのビジネスロジックを宣言できるようになり、豊富な視覚化ツールの開発が可能になり、非開発者との緊密なコラボレーションの機会が増えます。

これを行うとき、私たちはアプリケーションがどのように機能するかという要点から、顧客のニーズにさらに焦点を合わせて永続的な価値を生み出すことを可能にするより高いレベルのビューに焦点を移します。