kaggleライクなシステムを作ったという話(nextjsを使って)
概要
kaggleライクなシステムを作成してgithubに挙げました。この記事はそのシステムのお話です。
waiwaiと名付けています。
kaggleとは
Kaggleとは企業・政府・教育等の機関と共に機械学習やデータサイエンスに携わっているエンジニアのプラットフォーム
引用: https://udemy.benesse.co.jp/data-science/what-is-kaggle.html
参加者は与えられた問題に対して各自で機械学習のモデルを構築し推定した値が正解データとどれぐらい一致するかを競います。そんなシステムのコピー品を作成しました。
対象読者
- フロントの技術に興味ある人
- 自社でコンペティションを開催したい人
- cloud技術に興味ある人
- githubに
GCPを元にどのような操作をすれば諸々ホスティングできるか記述しています。
- githubに
誰かの参考になれば幸いです。
作ったシステムの良さ
元来企業がデータサイエンスのコンペティションを開催する点で、一番めんどいのがデータポリシーです。Kaggleでコンペを開催する場合会社によって別サービスにデータを挙げることになるので社内の法務相談が必須です。データの場合それが顕著です。
waiwaiは公開しているので、自社のクラウド環境でホスティングすればデータポリシーは最低限のチェックで済むはずです。
使った技術
- フロント, バックエンドapi:
next.js - 評価API:
fastapi
Kaggleは前述した通り、機械学習のモデルを構築し推定した値が正解データとどれぐらい一致するかを競います。そこで、機械学習の推定した値と正解データとで一致率を計算するAPIが必要になります。それをfastapiを利用しています。フロントやバックエンドの部分はnext.jsを利用して構築しています。
フロントやバックエンドで利用しているライブラリはこちら のpackage.jsonをみてください。以下主要ライブラリをまとめます。
| ライブラリ | バージョン | 目的 |
|---|---|---|
| next.js | 14.2.7 | 骨格 |
| conform | 1.1.5 | フォーム |
| prisma | 5.19.0 | データベース操作 |
| next-auth | 4.24.7 | 認証 |
| radix-ui,shadcn | xxx | レイアウト |
| pino | 9.5.0 | ログ |
こんな感じのものを利用しました。これらを使った設計と感想を話します。
設計
データ取得
next.jsを使ってる以上、サーバサイドでデータ取得します。今回、next.js1つでほぼほぼ全てやっているので、fetchを用いたキャッシュは登場しません。
単純にprismaを使ってデータを取得しています。ただし、not foundやエラーの処理は毎回書くのだがダルイので共通化しています。
ユーザのroleのuserとadminがあるので、呼び出し関数に対してユーザがその権限を持っているかをチェックできるようにしています。
エラーは throw new NotFound()の形で独自のExceptionを作成し共通関数でキャッチする流れを構築しています。NotFoundについてはnext.jsが用意しているnotFound()の関数が呼び出される流れにしています。
一方、throw errorの現状は適当に作成しているerror.tsxに飛びます。エラーのステータスやエラーに応じた文言が表示されることはしていません。
サーバアクション
next.jsを使っているバージョンが14なので、サーバアクションをベッコリ使っています。こちらもデータ取得同様に共通の関数を置いて同じような感じでコードを書いています。throw BadExeptionなどのエラーハンドリングを独自に作成し、キャッチするような流れにしています。
ハンドリングしたレスポンスはconform触ってみたかったのでconform使ってハンドリングしています。まず、zodライブラリを使ってスキーマのバリデーションを行い、問題なければサーバアクションの実行に移ります。結果を共通関数で受け取り、conformのデータの型に沿ってフロントでデータを受け取るという流れです。
conformの感想は便利だとは思いました。サーバーアクションにおいてはFormDataを作成する必要があるのですが、このライブラリ使えば基本的に必要ありません。実行したときにフロントで使いやすいレスポンスで操作できるのは、よいところかなと思いました。
実際に共通関数を利用して、アクションを構築しているのは以下の感じです。
コンポーネント
shadcn/uiを初めて使いました。便利ですね。個人開発はこれ使えばもういいな、というぐらいにuiのレイアウトに違和感がなかった。インストールするだけで、ここまで使い勝手がいいものを使えるのはよき。
ただ、tableに関してはtanstack table使ったのですが、必要なかったかなーと思う次第です。ドキュメントを見た時に、tanstack tableいいのかなーと思って安易に手を出したのですが、複雑でした。。。自分が機能を使いこなせていない。あまり直感的なライブラリじゃない気がした。
今であれば使っていないと思う。
ログ
ログは先ほど記述したデータの取得やサーバアクションの共通関数にて設置しています。問い合わせが来たときはどんな感じでログ調査するかな〜〜と想像しながら作っていました
問い合わせが起きたらよく、APIのパスでログを調べることがよくあるかなと思います。サーバアクションはその当たり直感的にAPIのパスで調べるのむずいかなと思ってログには関数のnameを残すようにしています。あと、userIdも(これは企業のカンパニーのIDでもいいですが、要は誰が何をしてきたかが明確にわかるようにしています)。
問い合わせ以外であれば、sentryなどのsaasで検知して気づいた時かなと思います。そんな時は以下かなと。
- 期待している以外の
Errorが起きる - ログに
digestが残る.sentryなどで送る. -
sentry.send(e)みたいな?
という感じで、digestと比較して、誰が何をしたかがわかるようになれば良いかなと思っています。
ちなみにこちら のサンプルで構築したシステムはgoogle cloud runを利用してホスティングしています。cloud runは以下のような形でログが表示されます。

jsonPayloadでnameで検索すれば、ログを調べることができるようになります。
データベース操作
prismaを使っています。prismaは直感的にデータベースを操作できるので使いやすいですね(自分は実務でprismaを使ったことはありません)。とはいえ、transactionは自分がやりたかったディレクトリ設計では少し使いにくかったです。例えば以下のような感じ
hogeService() => {
prismaClient.$transaction(tx, async () => {
await hogeRepository(tx)
await hugaRepository(tx)
})
}
Repositoyという感じで各種のドメインを分けたいときに、引数にtransactionのclientが必要になります。なんか微妙だなと思ったので以下のような感じでtransactionを定義しています。
const prisma = getPrisma()
// doTransactionでtransactionの範囲を指定できるようにする
await doTransaction(async () => {
await editUserRepository.editUserById(
mockUser1.user.id,
"transactiontest"
)
return await editUserRepository.editUserById(
mockUser1.user.id,
"transactiontest2"
)
})
実現方法は以下のコードを参考にしてください。
試すべきこと
私がめんどくさくてあまり試していないことを書き出します。
auth.js
nextauthの後継のauth.jsがあります。もうnextauthではなくauth.jsを使っても良かったかもしれません。ドキュメントも豊富そうでしたし。ただ、調査するのが面倒だったので使わなかったです。いつか変更したいです。
データ取得のerror.tsxの設計
前述していた通りerror.tsxには現状エラーのステータスやエラー文言の表示をちゃんとしてません。
もし、エラーのステータスやエラー文言の表示をちゃんとやるのであれば以下2点ができるのかなと思っています。
- https://github.com/vercel/next.js/discussions/49506
- https://github.com/vercel/next.js/discussions/62681
(https://zenn.dev/nabeliwo/articles/02f8cfcc596bb9 の記事が参考になりました!!)
githubで議論されていたアプローチを参考にするとこんな感じで書くとコードの記述が減りつつステータスやExceptionの情報を取得できるかなとは思いました。
export const RootContainer = async ({ queryParameter }: RootContainerProps) => {
const result = await serverComponentErrorBoundary(async () => {
return await Promise.all([
getCompetitionClientService.getCompetitions(queryParameter.page)
]);
});
if (result instanceof ErrorBoundaryException) {
return <ClientError error={result}>errorClientErrorp>;
}
const competitions = result[0];
return (
<div>success</div>
)
}
export const serverComponentErrorBoundary = async <T,>(
fn: () => Promise<T>
): Promise<T | BadException> => {
try {
return await fn();
} catch (error) {
if (error instanceof BadException) {
return new ErrorBoundaryException(400,'error message')
}
if (error instanceof Error) {
return new ErrorBoundaryException(500, 'error message');
}
//このthrowは`error instanceof Error`で吸収されるはずなので実行はしないと思う
throw error;
}
};
みたいな感じで、サーバでコンポーネントをレンダリングさせて、クライアントに飛ばすというやり方で問題ないかなと思っています。
最後に
自分でシステムをちゃんと作ると学びが多いですね。
ちなみにkaggleはexpertです😀
Discussion