🍑

Next.js & supabase & LangChainでAIがsakeをおすすめする『sake ai』作ってみた備忘録(前編)

2023/06/14に公開

概要

表題の通りで、日本酒が好きなので勉強がてら作ってみましたという話です。
いたってシンプルな構成のアプリケーションです。

色々と学びがあったので、備忘録として残しておこうと思います!

https://github.com/moepyxxx/sake_ai

sake aiでできること

ざっと画像のような、こんな感じです。シンプルです。

  1. サインアップ&サインアウト
  2. 自分の投稿したsakeレビューが見れる
  3. sakeレビューを投稿できる
  4. これまで投稿したsakeレビューから次におすすめのsakeをAIが教えてくれる

sake aiの概要

利用技術

利用してみた技術は以下です。理由と感想を簡単に。

Next.js

最近触っていない間にめちゃくちゃ変わっていたNext.jsで色々と遊びたかったため、フロントエンド全般をこちらで実装しました。また、インストール時にNext.jsがおすすめしてくれたtailwindcssを何気にはじめて使いました(流石に単体ではどうしようもなかったところがあったためtailwind-variantsも併用)。

Next.jsの13については、びっくりするほどchatGPTくんが流行に乗り遅れていて、役に立ちませんでした…新しいので仕方ないですね。公式サイトを読みながら、かなり手探り状態で色々と動かすことになりました…。

https://nextjs.org/

supabase

Firebaseを押せ押せいけいけドンドンなBaasツールとして最近名前を聞いたので、こちらもちょっと遊んでみたくて使ってみました。ドキュメントも見やすく、小さいアプリケーションをつくるにはかなり良かったです。

今回はAuthとDatabase機能を使ってみました。
データベースはPostgreSQLなのですが、素敵で惚れました。詳しくは後述します。

https://supabase.com/

FastAPI & Langchain

普段バックエンドはGolangで書いており、Pythonは慣れていなかったのでわからないなと思っていたのですが、LangChainを利用してみたかったので使ったという感じでした。APIサーバーにしたかったので、サクッとFastAPIを利用することにしました。たしかに早いです。

https://langchain.com/

その他補足

基本的にFastAPIはLangChainを使って何かしたものを返して欲しかっただけでした。
そのため、肝心のデータの永続化や取り出しについてはすべてNext.js⇄supabase間で行うことにしました。

AIに読み込ませる情報については、Next.jsがsupabaseから引っ張ってきたデータをAPIリクエスト時にbodyに詰めて渡すようにしています。

実装の学び

各ツールを使って今回小さなアプリを作りながら、技術について思ったことをつぶやきます。

supabase × Next.js Server Actionsを利用した認証・認可

まさかのNext.js用のサンプルが転がっていたので、こちらを参考に実装しました。

https://supabase.com/docs/guides/auth/auth-helpers/nextjs

Client Componentのやり方等も色々と書かれていましたが、自分はServer Actionsする方法を選びました。また、ファイル内の定義ではなくactions.tsにまとめて関数化しておき、コンポーネントから呼び出す形です。

  • actions.ts
"use server";
import { Database } from "@/types/schema";
import { cookies } from "next/headers";
import { createServerActionClient } from "@supabase/auth-helpers-nextjs";

export const signUpAction = async (authData: TAuthForm) => {
  const supabase = createServerActionClient<Database>({ cookies });
  await supabase.auth.signUp({
    email: authData.email,
    password: authData.password,
    options: {
      emailRedirectTo: "http://localhost:3000/success",
    },
  });
  return true;
};
  • Parent.tsx (Server Component)
<SignUpForm signUpAction={signUpAction} />
  • Child.tsx (Client Component)
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { authSchema } from "./SignInForm";

export type TAuthForm = {
  email: string;
  password: string;
};

type Props = {
  signUpAction: (data: TAuthForm) => Promise<boolean>;
};

export const SignUpForm: React.FC<Props> = ({ signUpAction }) => {
  const [isSuccess, setIsSuccess] = useState<boolean | null>(null);
  const router = useRouter();
  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<TAuthForm>({
    resolver: yupResolver(authSchema),
  });

  useEffect(() => {
    if (isSuccess === true) {
      alert(
        "お使いのメールアドレス当てに確認メールを送信しました。メール確認後、ログイン画面からログインしてください"
      );
      router.push("/signin");
    }
    if (isSuccess === false) {
      alert("サインアップに失敗しました");
      reset();
    }
  }, [isSuccess, router, reset]);

  const onSubmit = handleSubmit(async (data) => {
    const result = await signUpAction(data);
    setIsSuccess(result);
  });

  return (
    <>
      <form onSubmit={onSubmit}>
        <div className="mb-6">
          <label className="text-base mb-3 block">user id ( email )</label>
          <input className="w-full p-4 rounded-lg" {...register("email")} />
          <p className="text-sm text-rose-600 mt-2">{errors.email?.message}</p>
        </div>
        <div className="mb-6">
          <label className="text-base mb-3 block">password</label>
          <input
            className="w-full p-4 rounded-lg bg-white"
            type="password"
            {...register("password")}
          />
          <p className="text-sm text-rose-600 mt-2">
            {errors.password?.message}
          </p>
        </div>
        <div className="text-center">
          <input
            className="inline-block w-60 py-3 px-4 bg-cyan-600 rounded-lg text-white"
            type="submit"
            value="signup"
          />
        </div>
      </form>
    </>
  );
};

このような構成にした理由は、主に3つありました。

  • Next.jsがおすすめする『Moving Client Components to the Leaves』のパターンを考え、親から子にActionを渡して子であるClient Componentがフォーム操作を担当する。その子がフォーム送信時にActionを投げるという方法をとってみたかった
  • supabaseのAuth機能はcookieを利用するが、サーバー側での処理しかnext/headersのcookiesを扱えなかった。next/headersのcookiesを利用する方法が、一番想像しやすくわかりやすかった
  • 単純に認証・認可部分をサーバー側の処理にすることにより、クライアントからは操作が隠蔽された状態になり、セキュリティ的に良いかもと思った

supabaseのDatabase機能 postgreSQLのRLS

supabaseは、GUI上でテーブルを作成できたり、create tableの構成をパッとみれたりと、SQLをさわったことがある人であればすぐに勘所がわかる形で良かったです。supabaseのデータベースは、中身はpostgreSQLです。Firebaseの場合はNoSQLだったはずなので、ここは少し異なる点です。普段SQLで作業することが多い私にとってはポイント高かったです。

また、AuthとDatabaseの機能がきちんと連携してくれているため、たとえばcreate_userというカラムを作ってそこに認証済みのuser_idを入れる、といったテーブルもサクッと実現できました。

手動で簡単にDBを作れますし、CRUDのライブラリも直感的です。あまり複雑なクエリは注文できなさそうですが…。

https://supabase.com/docs/reference/javascript/select

私は今回postgreSQLにはじめて触れたのですが、恥ずかしながらここで初めてRow Level Securityという概念を知りました。

通常SQLではgrant/revoke等でテーブル単位での権限制御をできますが、RLSは行レベルでポリシーを作れる、という感じです。

https://supabase.com/docs/guides/auth/row-level-security

sake aiは今のところ他のユーザーのデータを見ることはできません。
そのため、自分が投稿したレビューデータだけを見られるようにしたかったです。

これはselectクエリを書けば簡単に実現できることですが、RLSを導入することでアプリケーション上の実装より一段上の階層で重大なセキュリティインシデントを防げるような印象を受けました。

supabaseの管理画面 > Authentication > Policyから、記述方法を知らなくても直感的に操作できます。

supabase

今回私は、以下のようなポリシーを適用しています。もっと練ればきっと色々なポリシーが追加できます。

  • sake_reviews: 認証されたユーザーがinsert可能であり、作成者しかaccessができない
  • sakes: 全ユーザーのinsertとaccessを可能にする

また、supabaseクライアントがこのポリシをきちんと考慮してくれるため、sake_reviewsのselect文は以下だけですみます。

  const { data: rawReviews } = await supabase
    .from("sake_reviews")
    .select()
    .order("created_at", { ascending: false });

特にuser_idカラムをwhereで条件判定として入れていませんが、RLSうまくポリシーに準拠したデータだけを返してくれます。

いいなあ〜postgreSQL〜すごいな〜。

Databaseといえば、もうひとつ。const supabase = createServerActionClient<Database>({ cookies })という感じで呪文のように至るところでクライアントを呼び出し使っていたのですが、この<Database>という型部分は以下の記事を参考にさせていただき、自動生成できました。TypeScriptに優しいのもうれしいと思いました。

https://zenn.dev/k_kind/articles/supabase-type-generate

へルパCSS的な利用ができそうと感じた tailwindcss

私は元々Webデザインやコーダー上がりなので、比較的CSSが得意です。そういう人にとってはtailwindcssはへルパcssとしてサクッと組み込めるので頭空っぽにできてかなり良いなと思いました。

https://tailwindcss.com/

tailwindcssは、たとえばMUIやElementといった、いわゆるUIライブラリ的な立ち位置よりも、階層としてはひとつ下で、1クラス1CSSでclassNameの中にぺこぺこ追加していけるタイプのものです。

個人的にUIライブラリは楽ができて大好きなのですが、機能+UI的なイメージがあったりします(ライブラリによりますが、validationを設定できたりとか)。UIライブラリの機能的側面はとても楽できますが、私のようなUIにはちょっとこだわりたいみたいなタイプにとっては、CSSを直接書いて調整したいことも結構でてきたりします。

そういうときに、機能をUIライブラリ+調整CSSをtailwindcssというのは良い選択かもと思いました。UIライブラリにもへルパCSSあったりしますが、tailwindcssのクラス数はそれよりもはるかに多いので。

しかしMUIのsxなど、UIライブラリ内に強力なCSS調整系の機能があったりするので、ここは相変わらずやっぱり迷うところではありそうです💦

tailwindcssで感じたデメリットは以下です。単体で使い切るには難しいかもしれません。

  • フォームなどの決まりきったUIをゼロから整えるのは面倒くさい
    • そういうキットを使わせてくれるマーケット的なのはあるらしい
  • className=の中身が異様に長くなる→tailwind-valiantsで解消
  • 隣接セレクタやアニメーションは難しそう(ちょっと最後まで方法がわかりませんでした)

まとめ

思ったより長くなったので、ここで切ります。後半戦では、LangChainや、Next.jsのClient/Server/Actions周辺について、色々と所感があったので備忘録として残そうと思います。

https://zenn.dev/moepyxxx/articles/3486fe54cd8ce2

Discussion