🤔

様々な言語やFWのAPIリクエストのデザインパターンごとの開発者体験を考察してみた

2024/02/24に公開

はじめに

昨今のソフトウェア開発において、Web APIリクエストは切っても切れない関係にあります。
特にフロントエンドにおいては、読み込み中やエラーなどのさまざまな状態の制御が求められることも少なくありません。

APIリクエストを手続き的に書く言語やフレームワークもあれば、非同期のコールバックやasync/awaitを利用したり、コルーチンのようなものを駆使するものもあります。
また、特にフロントエンド(Web/App)では状態が変わったら再度呼び出されるような宣言的な構成も多く見られるようになってきました。

さて、そういったさまざまなデザインパターンを用いつつ、どんな書き方だったらご機嫌な開発者体験(DX)で実装することができるのでしょうか?
プロダクションレベルのコードベースにおいて、フロントエンドからサーバーサイドまで10言語ほど利用したことのある筆者の視点から、現在デファクトとなっている様々な開発言語やフレームワークを利用したサンプルを示しながら考察してみたいと思います。
(あくまで筆者の意見ですので、違う意見もあると思いますしそういう議論が生まれるきっかけになれば幸いです🙂)

API リクエストのデザインパターン別考察

1. 手続き的に書くパターン

これは、サーバーサイドにおけるAPIリクエストのクライアント実装として比較的多いパターンかなと思います。
コードの上から順に処理し、最終的にはレスポンスを返却するといったとてもシンプルなパターンです。

エラーを返却(意識)しない場合

おそらく、最もシンプルです。これに関してはほとんど何も考えずに実装することができるので開発者体験としても、特にモヤがない状態なのかなと思います。

Javaで例を示します。

Java
public class ApiRequester {
  public Optional<Hoge> fetchHoge() {
    // ~~~APIリクエストの処理
    return Optional.of(Hoge()); // or Optional.empty();
  }
}

// 呼び出し側
ApiRequester().fetchHoge()
  .ifPresentOrElse((hoge) -> {
    // 値が存在した際の処理
  },
  () -> {
    // 値が存在しなかった際の処理
  });

このように、同期的な処理かつ値が取れたか取れてないかの2値で表現できるので、それに応じた処理を書けばOKです。
場合によっては常に値が取れている表現(デフォルトなど)の場合もあり、その場合はさらにシンプルになると思います。

エラーを返却(意識)する場合

さて、上記に加えてエラーを返却する場合はどうなるでしょうか?

Java
public class ApiRequester {
  public Optional<Hoge> fetchHoge() throws ApiException {
    // ~~~APIリクエストの処理
    return Optional.of(Hoge()); // or Optional.empty() or throw new Exception();
  }
}

// 呼び出し側
try {
  ApiRequester().fetchHoge()
    .ifPresentOrElse((hoge) -> {
      // 値が存在した際の処理
    },
    () -> {
      // 値が存在しなかった際の処理
    });
} catch (ApiException e) {
  // エラーが発生した際の処理
}

先ほどの値が存在するかしないかに加え、try-catchによってエラーが発生するパターンが増えました。
とはいえ、3つのロジックを制御すればいいのでまだまだシンプルですね。

Javaではthrowsという言語仕様があるので、静的型付けとしてエラーが起きうるインターフェースをコンパイラレベルで定義・検知することができるため、おそらくどんな開発者が実装してもこういう実装に落ち着くでしょう。

Go

ちなみに、Goのようにthrowsはないですが、値とerrをセットで返り値として返却するような設計思想もあります。

Go
type APIRequester struct {}

(*APIRequester a) func fetchHoge() (*Hoge, error) {
  return &Hoge{}, nil // or nil, err
}

// 呼び出し側
api := &APIRequester{};
hoge, err := api.fetchHoge();
if err != nil {
  // エラーの時の処理
  return;
}
if hoge == &Hoge{} {
  // 値が存在しなかった際の処理
  return;
}
// 値が存在した際の処理

TypeScript

では、TypeScriptなどのように、例外がすべて非検査例外として扱われ、型化されている返却値のような設計指針もあまりない場合はどうすればよいでしょうか?

この解のひとつはResult型です。関数型プログラミングでいうところのモナドのようなものです。

TypeScript
type Result<T, E extends Error> = Success<T> | Failure<E>

class Success<T> {
  readonly isFailure = false
  constructor(readonly value: T) {}
}

class Failure<E extends Error> {
  readonly isFailure = true
  constructor(readonly value: E) {}
}

このように、常に成功か失敗かの2値のどちらかを返すようなunionを用いた型を用意しておき、それを返り値に利用してあげることで、利用する開発者に対してインターフェースとその使用方法を明示することができます。
ちなみに、SwiftKotlinのような最近の言語には、Result型が標準で採用されていたりします。

それでは、他の言語のようにAPIリクエストのサンプルをTypeScriptでも示してみます。

TypeScript
class ApiRequester {
  fetchHoge(): Result<Hoge, ApiError> {
    return new Success(new Hoge()); // or new Failure(new ApiError());
  };
}

// 呼び出し側
const result = new ApiRequester().fetchHoge();
if (result.isFailure) {
  // 型推論されて、result.valueがApiErrorとして制御できる
  return;
}
// 型推論されて、result.valueがHogeとして制御できる

これで、JavaやGoのようなエラーハンドリングをそういった設計思想のない言語にも適用することが可能になりました 🎉
もちろん、unionを純粋に使うだけでも成功時と失敗時の2値を簡単に表現することは可能なので、状況に応じて使い分けると良いかと思います。

2. 宣言的に書くパターン

WebフロントエンドのReactなどの潮流によって、さまざまなフロントエンドで宣言的なUIを記述するようなフレームワークが増えてきました。
上述したサーバーサイドに多い手続き的のパターンに比べて、宣言的なUIでは非同期処理やさまざまな状況におけるUI表現が必要となることが多く、状態 (State) のパターンが多くなりがちです。

SwiftUI

まずは、比較的後発のiOS標準の宣言的UIライブラリであるSwiftUIでの設計パターンを見ていきます。

Swift
class ContentViewModel: ObservableObject {
  enum State {
    case initial // 初期状態(未リクエスト時)
    case loading // 読み込み中
    case data(Hoge?) // 読み込み完了
    case error(Error) // エラー発生
  }

  @Published private(set) var state: State = .initial

  func onAppear() {
    state = .loading
    Task {
      do {
        let hoge = try await ApiRequester().fetchHoge()
        self.state = .data(hoge)
      } catch {
        self.state = .error(error)
      }
    }
  }  
}

// View側
struct ContentView: View {
  @ObservedObject var viewModel = ContentViewModel()
  var body: some View {
    Group {
      switch viewModel.state {
        case .initial, .loading:
          // 読み込み中のUI
        case .data(let hoge):
          // 値が存在した/しなかった際のUI
        case .error(let error):
          // エラーが発生した際のUI
      }
    }
    .onAppear {
      viewModel.onAppear()
    }
  }
}

馴染みがない方には少々難しいかもしれませんが、やってることはいたってシンプルで、View側の状態を管理するObservableObject protocolに準拠したclassを作り、状態を表す4値(初期状態のようなものが不要な場合は3値)からなるstateを定義してそれぞれのUIパターンを定義しているというだけです。
@Publishedをつけた変数に値が再代入されると、依存するView側の再レンダリングが走ります。

Swiftの強力な言語仕様である値付きのEnum (Associated Value)を駆使することで、開発者は複雑な状態管理を比較的シンプルにswitch文一つで出し分けて組み立てることができます。
これはDX観点から見ると、とても素晴らしいことだと思います✨


早速、例を見ていきます。

Swift
class ContentViewModel: ObservableObject {
  @Published var isLoading = false
  @Published var data: Hoge?
  @Published var error: Error?

  func onAppear() {
    isLoading = true
    error = nil

    Task {
      do {
        let hoge = try await ApiRequester().fetchHoge()
        self.data = hoge
      } catch {
        self.data = nil
        self.error = error
      } finally {
        isLoading = false
      }
    }
  }  
}

// View側
struct ContentView: View {
  @ObservedObject var viewModel = ContentViewModel()
  var body: some View {
    Group {
      if let error = viewModel.error {
        // エラーが発生した際のUI
      } else if viewModel.loading {
        // 読み込み中のUI
      } else if let data = viewModel.data {
        // 値が存在した際のUI
      } else {
        // 値が存在しなかった際のUI
      }
    }
    .onAppear {
      viewModel.onAppear()
    }
  }
}

この場合、状態を表すプロパティが3種それぞれ独立して存在しています。
つまり、実際のUI Stateとして気にかけないといけない状態は厳密には 2 * 2 * 2 = 8通りに増えてしまいました。

さらに、View側の例では全てのパターンに考慮できているので良さそうですが、呼び出し側のViewの実装者によっては、その全てを考慮する実装を構築することができないかもしれません。

このように、UIを構築する上での状態が増えてしまうことと、言語仕様などを駆使して網羅的に書けない自由度の高い設計はチーム開発の中ではアンチパターンになり得るといえます。

Flutter + Riverpod

次は、同じく宣言的UIのマルチプラットフォーム向けのフレームワークであるFlutterと状態管理のメジャーなライブラリであるRiverpodを使った例で見ていきます。

Flutter

Future<Hoge?> fetchHoge() async {
  state = AsyncLoading();
  state = await AsyncValue.guard(() async {
    final hoge = await ApiRequester().fetchHoge();
    return [...state, ...extra];
  });
}

// View側
class Page extends ConsumerWidget {
  const Page({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncValue = ref.watch(fetchHogeProvider);
    return asyncValue.when(
      data: (hoge) => , // 値が存在した/しなかった際のUI
      loading: () => , // 読み込み中のUI
      error: (err, stack) => , // エラーが発生した際のUI
    );
  }
}

馴染みがない方でも、Flutterの例も先ほどのSwiftUIのenumを用いた例とほとんど変わらないことがわかるかと思います。
RiverpodのFutureProviderが返すstateには、AsyncValueという抽象型(Sealed Class)が採用されており、こちらも3つ(読み込み中、取得、エラー)の具体型の実装のいずれかからなるため、利用する側ではwhenを使ってそれぞれの状態に応じたUIを構築するといったことが可能になります。

Riverpodが実装の指針を示してくれる形となっているため、さまざまな開発者においてもほぼ同じような実装を心がけることができ、その恩恵は非常に高いと自分は考えています👏

React + Apollo Client (GraphQL)

さて、満を持してReactの登場ですが、GraphQL APIクライアントであるApollo(題材として興味深かったので) を用いたリクエストサンプルを見ていきたいと思います。

React
const FetchHogeQuery = gql`
  query FetchHoge {
    hoge {
      id
    }
  }
`;

export const View: React.FC = () => {
  const { data, loading, error } = useQuery(FetchHogeQuery, {});

  if (error) {
    return ; // エラーが発生した際のUI
  }

  if (!data) {
    return ; // 未リクエスト(初期)状態のUI
  }

  if (loading) {
    return ; // 読み込み中のUI
  }

  return ; // 値が存在した/しなかった際のUI
}

Apollo ClientライブラリのQueryをfetchするためのhooksであるuseQueryを用いることで、GraphQLサーバー (Apollo Server) へのQueryリクエストを非同期で容易に行うことができ、返り値として様々な状態に関するプロパティにアクセスすることができます。

しかし、ここまで読んでいただいた方はすでにお気づきだと思いますが、これはSwiftUIのところで紹介したアンチパターンに限りなく近いインターフェースとなっていることがわかると思います。

さらに、JavaScriptの分割代入 (Destructuring assignment)を用いて、必要なフィールドだけをとりだしていますが、どれがUIの構築に必要かを開発者がまず意識するというハードルも上がりますし、先述したのと同様にUI構築に必要な全てを考慮する実装ができるかは開発者次第であるといえます。

こうしたライブラリ側の用意されたI/FがUI構築のユースケースとして使いにくいなと感じた場合も、unionなどの言語仕様を使ってwrapしたものを使うような方針にするなどのアプローチをとれば改善は可能でしょう。

例えば下記のようなunionからなるstateを返すcustom hooksを自作してあげれば、SwiftUIのenumパターンのような開発者に考えさせられることの可能なインターフェースを実現できると思います。
(ただし、TypeScriptのswitchはSwiftに比べると貧弱なため、unionの中のオブジェクト型やジェネリクス型を網羅的に取り出す構文が現在のところないのでif else文を使用するしかなく、その分縛りは弱まります)

useStatefulQuery.ts
type QueryState<TData> = 'INITIAL' | 'LOADING' | TData | ApolloError;
type StatefulQueryResult<TData, TVariables extends OperationVariables = OperationVariables> = {
  state: QueryState<TData>;
  other: Omit<QueryResult<TData, TVariables>, 'data' | 'error' | 'loading'>;
};

function useStatefulQuery<TData, TVariables extends OperationVariables = OperationVariables>(
  query: DocumentNode | TypedDocumentNode<TData, TVariables>,
  options?: QueryHookOptions<NoInfer<TData>, NoInfer<TVariables>>
): StatefulQueryResult<TData, TVariables> {
  const { data, error, loading, ...other } = useQuery(query, options);
  const state: QueryState<TData> = (() => {
    // parseする順番も考慮する必要がある
    if (error) return error;
    if (!data) return 'INITIAL'; // 未リクエスト状態
    if (loading) return 'LOADING';
    return data;
  })();
  return {
    state,
    other: { ...other },
  };
}

// 呼び出し側
export const View: React.FC = () => {
  const { state } = useStatefulQuery(FetchHogeQuery, {});

  // useQueryに比べて、取り出さない限り型が確定しない & どの順番で制御してもよい
  if (state === 'INITIAL') {
    return ;  // 初期状態
  }

  if (state === 'LOADING') {
    return ;  // ローディング中
  }

  if (state instanceof ApolloError) {
    return ;  // エラー
  }

  return ;  // データ取得成功
};

このように、ハンドリングの部分を型化して、それを呼び出し側では使い回すようなコーディング規約さえ作っておければ(もしくはcustom lintで縛っておけば)、開発者側は機械的に正しく状態制御をすることができるのではないかと思います。

おわりに

いかがだったでしょうか?

ほとんどのサンプルが静的型付き言語(トランスパイルしているものも含む)でしたが、やはり機械が正しい書き方を検知・強制してくれるということはとても重要ということが再認識できましたし、型付きの言語やType HintingをTranspiler / Linterが担うようなものが昨今のトレンドになっている理由の一部が垣間見れたのかなと思います。

色々触ってきた自分としては、書き方を強制してくれるような言語やフレームワークを好む傾向が日に日に増してきています笑(Linterも重要とは思いつつ、コンパイラレベルで指摘してくれるようなプリミティブな思想の言語の方が好きです)

とはいえ、言語やフレームワークの思想をちゃんと理解した上で実装する責任がエンジニアにはあることが改めておわかりいただけたと思いますので、その辺りはしっかり勉強して使いこなしていく責務があると思うので、自分も含め日々勉強していかなければならないと思います。

以上です!

Discussion