プロダクトのスケールを見据えてServer-Driven UIを採用してみる
この記事はUbie Engineering Advent Calendar 2023の12日目の記事です。よろしくお願いします。
はじめに
Ubieでソフトウェアエンジニアをやっているisseiです。
2021年の3月に入社してから2年程症状検索エンジン「ユビー」の開発をしていましたが、ここ1年くらい新規のプロダクトの検証とそのスケールのための開発をしています。
2023年の前半は、新規のプロダクトの検証を出来るだけ素早く行うため、実装範囲を小さくし、バックエンドの実装も最小限にとどめて、ほとんどの機能をフロントのコードで完結させていたのですが、後半は、前半に検証が終えられたプロダクトのスケールに向けた設計変更と実装の置き換えを行っています。
この記事では、後半で行った設計変更の中で採用した SDUI(Server-Driven UI) がとても良かったので、採用に至った経緯と、実装の概要、実際に採用してみて感じたメリット/デメリットについて紹介します。
Server-Driven UI(SDUI)とは
Airbnbが開発した、サーバーからデータと一緒にUIのロジックをレスポンスとして返却し、クライアント側ではそのレスポンスをUIに変換して表示する設計です。
SDUIを採用する利点は、複雑な画面の出し分けなどもバックエンドで制御しやすいということ、ネイティブアプリやwebフロントエンドのリリースをしなくても、バックエンド側のレスポンスを変更するだけでUIの変更が出来ること、などが挙げられます。
Ubieのプロダクトでは、ペイシェントジャーニー上で意思決定を支援するために適切なタイミングで適切なコンテンツを出し分ける事が重要であり、検証を回すスピートも速いことから、これらのメリットはプロダクトと非常に相性が良いです。
プロダクト概要
今回SDUIを採用した開発中のプロダクトは、疾患を治療中の方を対象にしていて、
ユーザーは治療中の疾患に関する質問に答えていくと、最後に表示される結果画面で自分の回答に応じた治療に有益なコンテンツが見られる、というものです。
技術スタック
技術スタックはフロントがNext.jsとTypeScriptでバックエンドはKotlinとSpring Boot(Node.jsへの移行中)APIのクエリ言語としてGraphQLを採用しています。
スケールに向けた設計変更
今回の設計変更のポイントは2つあります。
- 結果画面のUIをサーバーサイドから変更するためのGraphQLスキーマの設計
- ユーザーの属性や回答に応じて動的にコンテンツをフィルタリングするための条件の設計
どちらもボリュームの大きいテーマなので、今回の記事では前者の結果画面のUIをサーバーサイドから変更するためのGraphQLスキーマの設計に絞って話します。
SDUIを採用した経緯
今回は結果的にSDUIを採用しましたが、最初からSDUIを採用することが決まっていたわけではありません。
当初はSDUIのことは知らずに普通にTypeScriptとGraphQLでやりたいことを実現するための設計を考え始めました。
まずやりたいことを考えるのですが、スケールを見据えた設計ということで、将来的な要件が曖昧なため、自分なりに様々なユースケースを想定してビジネス側からの要求に柔軟に対応できる仕様を考え、次のような満たしたい条件を抽出しました。
- サーバーのレスポンス変更だけでコンテンツの追加や見た目、順番を変更できる
- 返却するコンテンツの追加、変更のコストを出来るだけ小さくしたい(理想的には管理画面からエンジニア以外のメンバーでも簡単に変更ができるようにしたい)
- 日時をトリガーとしたコンテンツの切り替えを自動でできるようにしたい
その後、これらの条件を満たすようなGraphQLスキーマの実装を始めたのですが、自分のGraphQL力では実現したいことをうまく表現できず、同僚のゆーくに相談した所、そこで初めて自分が作ろうとしていたものがいわゆるSDUIと呼ばれるものだと知り、SDUIを採用することになりました。
つまり、SDUIがプロダクトにフィットするか検討して採用を決めたわけではなく、プロダクトに必要なものを作っていたら自然とSDUIに行き着いていたということです。
実装方法
SDUIの実装のポイントは大きく分けて次の2つです。
- バックエンドから返すレスポンスのGraphQLスキーマ設計
- フロントエンドでの受け取ったレスポンスのコンポーネントへの変換
それぞれ簡単に説明していきます。
GraphQLのスキーマ設計
GraphQLのスキーマを以下に記載します。(読みやすいように簡略化しています。)
type ResultScreen {
id: ID!
contents: [ResultContent!]!
}
interface ResultContentBase {
id: ID!
}
union ResultContent = ResultHeader
| ResultCards
| ResultDisclaimer
| ResultNps
| ResultQuestionnaireLink
type ResultHeader implements ResultContentBase {
id: ID!
title: String!
description: String
}
type ResultCards implements ResultContentBase {
id: ID!
title: String!
cards: [ResultMarkdownCard!]!
description: String
badgeText: String
}
type ResultMarkdownCard {
id: ID!
title: String!
markdownText: String!
isCloseable: Boolean!
isInitiallyClosed: Boolean!
}
type ResultDisclaimer implements ResultContentBase {
id: ID!
title: String!
body: String!
}
type TreatmentGuideResultNps implements ResultContentBase {
id: ID!
title: String!
}
type ResultQuestionnaireLink implements ResultContentBase {
id: ID!
title: String!
linkUrl: String!
}
ポイントは全てのコンテンツをResultContentのunionで返していることです。
type ResultScreen {
id: ID!
contents: [ResultContent!]!
}
union ResultContent = ResultHeader
| ResultCards
| ResultDisclaimer
| ResultNps
| ResultQuestionnaireLink
こうすることでコンテンツごとにorderのようなプロパティを持たずにコンテンツの順番を表現できます。また、今後コンテンツの種類が増えたとしてもResultContentBaseを継承したtypeを末尾に追加して行くだけで済みます。
レスポンスをコンポーネントに変換する
フロント側では次のようなコンポーネントを実装することでResultContentFragmentを各コンポーネントに変換しています。
export interface Props {
sessionKey: string,
resultContent: ResultContentFragment,
}
export const ResultContent: FC<Props>=({ sessionKey, resultContent }) => {
switch (resultContent.__typename) {
case 'ResultHeader':
return <ResultHeader key={resultContent.id} resultContent={resultContent} />;
case 'ResultCards':
return <ResultCards key={resultContent.id} resultContent={resultContent} onClickDisclosure={onExpandCard} />;
case 'ResultDisclaimer':
return <ResultDisclaimer key={resultContent.id} resultContent={resultContent} />;
case 'ResultQuestionnaireLink':
return (
<ResultQuestionnaireLink
key={resultContent.id}
resultContent={resultContent}
sessionKey={sessionKey}
onClick={onClickQuestionnaireLink}
/>
);
case 'ResultNps':
return <ResultNps key={resultContent.id} resultContent={resultContent} onRegister={onRegisterNps} />;
}
};
switch文に __typename を渡すことで case 文の中で resultContent.xx の補完が効くようになります。
<ResultContent/> の使用箇所のコードはこんな感じになります。
// 省略
const ResultPage: NextPage = () => {
const [sessionKey, setSessionKey] = useState<string>();
// 省略
return (
<>
<div className={styles.wrapper}>
{resultScreen.contents
.map((content) => {
<ResultContent sessionKey={sessionKey} resultContent={content}/>;
})
.filter((component) => component != null)}
</div>
</>
);
};
export default ResultPage;
フロントのコードはレスポンスのコンポーネントへの変換と各コンポーネントのスタイリングが主な責務になりました。出し分けのロジックがないのでとてもシンプルですね。
実装してみてどうだったか?
まだ管理画面は実装できておらず、コンテンツの変更がある場合にはバックエンドのレスポンスをエンジニアである自分が変更するという形式で対応していますが、いくつか良いことがあったので紹介します。
開発生産性、開発者体験が向上した
最初の設計には頭を使いましたが、フロントのコードの責務がシンプルになったことで、フロント、バックエンドどちらのコードも実装時に考える事が少なくなり、レビューもしてもらいやすくなったと感じています。これらは開発期間に対して複利で効いてくるので非常に大きいメリットだと思います。
思考コストを先に支払うことでコミュニケーションがスムーズになった
SDUIの場合、先に許容するUI、しないUIをデザイナーと一緒にすり合わせる必要があるのですが、最初にUIのルールを決めることで、実装時に設計に戻って考えたりすることが減ったように感じます。チームのメンバーやチーム外から仕様に関する会話をスムーズになりました。
ビジネスロジックに集中できる
管理画面でコンテンツの入稿をすることを目指す場合、それぞれのコンテンツに対して管理画面上で出し分けの条件を設定したくなると思います。SDUIの場合はコンテンツの出し分けをバックエンドのみで行うため設計がしやすく、ビジネスロジックを考えることに集中出来ています。出し分けの部分の設計については、また機会があれば書きます。
おわりに
SDUIは、プロダクトに複雑な画面の出し分けがあったり、少ないコストで表示しているコンテンツを切り替えたい場合には非常に強力な武器です。設計に多少難しさはありますが、Ubieでは限られた人的リソースでプロダクトをデリバリーするための高速道路になってくれると感じています。フロントエンドとバックエンドを一気通貫で設計できる場合には一度検討してみてはいかがでしょうか。
Ubieでは絶賛採用中ですので、このようなシステムの設計から関わりたいという方やUbieに興味があって話を聞いてみたい方がいましたら、自分のXにDMを送ってもらうか、採用サイトからご連絡ください。まずはカジュアルにお話しましょう!
Discussion