private: true
はじめに
NBAのトレード速報サイトを個人開発しているのだが、フロントエンドに Kotlin Compose for Web、バックエンドに Rust (async-graphql) という構成を採用している。
フロントからバックのGraphQL APIを叩く部分で、Ktor Client を使った実装がうまくハマったので共有する。
📌 この記事で扱うこと:
- Compose for Web で Ktor Client を使う初期設定
- GraphQL リクエスト/レスポンスのシリアライズ設計
- Composable から非同期でデータ取得するパターン
- 実際にハマったポイントと解決策
環境
| 項目 | バージョン |
|---|---|
| Kotlin | 1.9.22 |
| Compose for Web | 1.5.12 |
| Ktor Client | 2.3.7 |
| kotlinx-serialization | 1.6.2 |
| kotlinx-coroutines | 1.7.3 |
実装
1. build.gradle.kts の依存関係
まず必要な依存を追加する。Compose for Web は Kotlin/JS (IR) ターゲットで動くので、Ktor の ktor-client-js エンジンを使う。
kotlin {
js(IR) {
browser {
commonWebpackConfig {
cssSupport { enabled.set(true) }
}
}
binaries.executable()
}
sourceSets {
val jsMain by getting {
dependencies {
implementation(compose.html.core)
implementation(compose.runtime)
// Ktor Client (JS エンジン)
implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-js:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
// シリアライズ
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
}
}
}
}
ポイントは ktor-client-js を指定すること。ktor-client-cio 等を入れるとJS環境では動かないので注意。
2. GraphQL クライアントの実装
GraphQLのリクエスト/レスポンスは専用のデータクラスで型安全に扱う。
@Serializable
data class GraphQLRequest(
val query: String,
val variables: Map<String, String?> = emptyMap()
)
@Serializable
data class GraphQLResponse<T>(
val data: T? = null,
val errors: List<GraphQLError>? = null
)
@Serializable
data class GraphQLError(
val message: String
)
そしてクライアント本体。HttpClient(Js) でブラウザのFetch APIをエンジンとして使う。
class GraphQLClient : TradeNewsApiClient {
private val client: HttpClient
private val endpoint: String
constructor(
client: HttpClient = createDefaultClient(),
endpoint: String = ApiConfig.apiEndpoint
) {
this.client = client
this.endpoint = endpoint
}
override suspend fun fetchNewsItems(category: String?): List<NewsItem> {
return if (category == null) {
fetchAllTradeNews()
} else {
fetchTradeNewsByCategory(category)
}
}
private suspend fun fetchAllTradeNews(): List<NewsItem> {
val query = """
query GetAllTradeNews {
tradeNews {
id
title
description
link
source
publishedAt
category
titleJa
descriptionJa
translationStatus
translatedAt
}
}
""".trimIndent()
val request = GraphQLRequest(query = query)
val response: GraphQLResponse<TradeNewsData> = try {
client.post(endpoint) {
contentType(ContentType.Application.Json)
setBody(request)
}.body()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
throw GraphQLClientException(
"Failed to fetch trade news: ${e.message ?: "unknown error"}", e
)
}
return when {
!response.errors.isNullOrEmpty() ->
throw GraphQLClientException(
response.errors.joinToString(", ") { it.message }
)
response.data != null -> response.data.tradeNews
else -> throw GraphQLClientException(
"GraphQL response did not contain trade news data"
)
}
}
companion object {
private fun createDefaultClient(): HttpClient {
return HttpClient(Js) {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
}
}
}
ignoreUnknownKeys = true がかなり重要で、バックエンド側のスキーマが変わっても(フィールドが増えても)フロントが壊れないようにしている。
3. Composable からデータを取得する
Compose for Web では LaunchedEffect を使ってコルーチンを起動し、非同期でAPIを叩く。
@Composable
fun NewsList(
selectedCategory: String?,
client: TradeNewsApiClient,
onFetchStateChanged: (Throwable?) -> Unit = {}
) {
var newsItems by remember { mutableStateOf<List<NewsItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var retryNonce by remember { mutableStateOf(0) }
LaunchedEffect(selectedCategory, retryNonce, client) {
isLoading = true
error = null
onFetchStateChanged(null)
try {
newsItems = client.fetchNewsItems(selectedCategory)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
newsItems = emptyList()
error = buildNewsFetchErrorMessage(e)
onFetchStateChanged(e)
} finally {
if (currentCoroutineContext().isActive) {
isLoading = false
}
}
}
// UI描画...
}
ここでのポイント:
-
LaunchedEffectのキーにselectedCategoryを入れる → カテゴリが変わったら自動的に再fetch -
retryNonceパターン → リトライボタン押下でretryNonce += 1するだけで再実行される -
CancellationExceptionは再throw → コルーチンのキャンセルを握り潰すと状態がおかしくなる -
currentCoroutineContext().isActiveチェック → キャンセル済みのコルーチンで state を更新しない
4. エンドポイントの動的解決
開発環境と本番環境でAPIのURLが異なる問題は、ApiConfig オブジェクトで解決した。
object ApiConfig {
val apiEndpoint: String
get() {
val hostname = window.location.hostname
return when {
hostname == "nba-iso-flow.com" ->
"https://nba-iso-flow.com/graphql"
hostname.contains("cloudfront.net") ->
"https://${hostname}/graphql"
hostname == "localhost" || hostname == "127.0.0.1" ->
"/graphql" // webpack-dev-server のプロキシ経由
else -> "/graphql"
}
}
}
ローカル開発では webpack-dev-server のプロキシを通して /graphql にアクセスし、本番ではドメイン名から自動判別する。環境変数を使わずにホスト名ベースで切り替えるので、ビルド成果物を環境ごとに分ける必要がない。
ハマったポイント
CancellationException を catch して握り潰すとUIがフリーズする
最初はこう書いていた:
// ❌ NG: CancellationException も catch してしまう
try {
newsItems = client.fetchNewsItems(selectedCategory)
} catch (e: Exception) {
error = e.message
}
これだと LaunchedEffect が再実行される際のキャンセル時にも error が設定されてしまい、一瞬エラー表示がチラつく。さらに悪いケースでは、コルーチンのキャンセル伝搬が止まってUIが更新されなくなる。
解決策: CancellationException を明示的に再throwする。
// ✅ OK: CancellationException は再throw
try {
newsItems = client.fetchNewsItems(selectedCategory)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
error = e.message
}
Kotlinのコルーチンを扱う限り、この CancellationException パターンは全ての try-catch に入れた方がいい。
Ktor Client の JS エンジンと TLS
HttpClient(Js) はブラウザの fetch() APIを内部で使っているため、TLS周りはブラウザに任せられる。一方でテスト環境(Node.js)では挙動が違うことがあり、ktor-client-mock を使ったテストの方が安定した。
ignoreUnknownKeys を忘れるとデシリアライズで死ぬ
バックエンド(Rust側)にフィールドを追加した瞬間、フロントがクラッシュする。Json の設定で ignoreUnknownKeys = true を入れておかないと、スキーマの進化に対応できない。GraphQLのintrospectionで型を同期する仕組みがない分、ここで柔軟性を持たせている。
まとめ
Kotlin Compose for Web × Ktor Client でGraphQL APIを叩く構成は、以下のメリットがある:
-
型安全:
@Serializableでリクエスト/レスポンスを型定義 -
リアクティブ:
LaunchedEffect+mutableStateOfでデータ変更に自動追従 -
テスタブル:
TradeNewsApiClientインターフェースで差し替え容易
Compose for Web はまだ日本語の情報が少ないが、Androidの Jetpack Compose を触ったことがある人なら違和感なく書ける。バックエンドにRustを置いてGraphQLで繋ぐ構成は、パフォーマンスと型安全性の両方を得られるのでおすすめ。
🏀 NBA Trade Tracker: https://www.nba-iso-flow.com/