supabase × Next × GraphQLを使ってサクッと個人サービスをリリースする
先日、個人で愚痴を投稿するサービス「愚痴トーク」をリリースしました。
supabaseを使い、サクッとリリースまで持っていくことができたので、実装と合わせて紹介できればなと思います。
作ったもの
愚痴でつながるSNS - 愚痴トーク
シンプルな愚痴投稿サービスです。カテゴリーを選び、愚痴を記入すればすぐに投稿することができます。匿名で投稿することもできるので、ぜひご活用ください。
使った技術・サービス
supabase
サービス開発に必要な機能を簡単に構築することができるサービスです。
詳細はこちらの記事が分かりやすいです。
特にデータの永続化に関しては、データストアにRDB(PostgreSQL)を使用できることもあり、RDBでのデータ設計に慣れている人にとっては非常に便利なサービスとなっています。
Next.js
フロントはNext.jsを使っています。Next.jsでは、SSGやSSRの機能が備わっていたり、画像の最適化の機能が備わっていたりなど、Reactを採用する上では使わない理由がないくらい、便利なフレームワークとなっています。
SSGやSSRの観点だと、VueのフレームワークであるNuxt.jsもあるのですが、後述するuseSWRやvercelとの相性を考慮してNext.jsを採用しました。(あとは個人的な趣味です)
Vercel
フロントのホスティング、APIサーバーとしてVercelを選びました。ホスティングやnodeのAPIサーバーとしての要件であれば、herokuも候補に上がったのですが、Preview環境の構築やデプロイまでの手軽さを考慮してVercelを選んでいます。
GraphQL
supabaseにはブラウザからDBにアクセスする機能も備わっていますが、匿名性をもった投稿データを扱うため、ユーザーから自由にテーブルにアクセスできるのは避けたいです。
PostgreSQLのRLSという、アクセス制限をかけることによりある程度解消はできるのですが、データの状態によってほしいデータが変わる今回のケースでは、バックエンドからデータを受け取る方が実装しやすいです。
GraphQLは、一つのエンドポイントに対してクエリと呼ばれるパラメータを切り替えることにより、様々なデータの操作を可能にしています。
まだGraphQLの経験があまりなかったので、今回はいい機会だと思い、取り入れてみました。
結果的にはすごく便利だなと感じたので、こちらも紹介したいなと思います。
SWR
React上でバックエンドからデータを取得する際に使用するライブラリです。vercel社が提供しているライブラリとなります。
useSWRでは、stale-while-revalidateという戦略を用いてデータの取得を行うことができます。ざっくり説明すると、リクエスト結果をブラウザでキャッシュし、再度リクエストがあった際に、キャッシュがあればキャッシュから値を返す、という内容です。
Reactを使うなら間違いなく採用したい!と思える内容だったので、こちらも紹介できればなと思います。
機能・実装紹介
今回紹介したいサービス、ライブラリを、実際の実装を踏まえて紹介できればなと思います。
ログイン / 新規会員登録 / パスワードリセット
supabaseでは、ユーザー管理機能が一通り提供されているので、提供されているAPIを利用すれば簡単にユーザー管理機能を実装することができます。
// supabaseクライアントの生成
import { createClient } from '@supabase/supabase-js'
// URL, KEYは環境変数として渡す
export const supabase = createClient(URL, KEY)
// signUpの実装例
const { user, error } = await supabase.auth.signUp({
email: 'hoge@example.com',
password: 'hogehoge'
}, { redirectTo: location.origin + '/profile/edit'}) // redirectToで、メール認証後に遷移させたいページを指定できる
// password resetの実装例
const { error } = await supabase.auth.api.resetPasswordForEmail(email, { redirectTo: `${location.origin}/password/edit`})
return {
error
}
// password updateの実装例
const { error } = await supabase.auth.update({ password })
return {
error
}
愚痴の投稿
バックエンドとの繋ぎ込みにはuseSWRを、バックエンドにはGraphQLを使用しました。
それぞれの詳しい説明は省略し、今回は実装例を紹介したいと思います。
バックエンド開発
GraphQLをTypeScriptのプロジェクトに導入する際に面倒なのが、クエリ言語で書かれたスキーマと、TypeScriptの型を合わせるところかと思います。
手動でやってもいいのですが、手間が非常にかかるので、自動型生成ライブラリのGraphQL Code Generatorを使用しました。
以下の設定ファイルを作成し、scriptを実行することでスキーマファイルからTypeScriptの型ファイルを生成できるようにしています。
overwrite: true
schema: "./src/apollo/typeDefs.ts"
generates:
src/types/type.d.ts:
config:
contextType: ./src/types/context#Context
plugins:
- "typescript"
- "typescript-resolvers"
準備ができたら、スキーマファイルを作成します。
今回は、src/apollo/typeDefs.tsというファイルにスキーマを追加していきます。
import { gql, Config } from "apollo-server-micro"
export const typeDefs: Config["typeDefs"] = gql`
type MutateResponse {
success: Boolean!
message: String
target_id: Int
}
// RestAPIでいうGet系の定義。
type Query {
// feedのリストを取得するための定義を記載。Feedの型は省略。
feeds(page: Int, category_id: Int, word: String, user_id: String): [Feed]
}
// RestAPIでいうPost系の定義。
type Mutation {
// feedを作成するための定義を記載
createFeed(body: String!, is_anonymous: Boolean, status: String!, category_id: Int!): MutateResponse!
}
`
スキーマの追記が終わったら、TypeScriptの型を生成します。
yarn run graphql-codegen --config codegen.yml
これにより、src/types/type.d.tsに、先ほど定義したスキーマの型情報が吐き出されました。
// 抜粋
export type QueryResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = {
feeds?: Resolver<Maybe<Array<Maybe<ResolversTypes['Feed']>>>, ParentType, ContextType, RequireFields<QueryFeedsArgs, never>>;
};
export type MutationResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = {
createFeed?: Resolver<ResolversTypes['MutateResponse'], ParentType, ContextType, RequireFields<MutationCreateFeedArgs, 'body' | 'category_id' | 'status'>>;
};
スキーマがかけたら、resolverと呼ばれるAPIの中身の実装に入ります。
resolverでは、リクエストの検証、DBへのアクセス、レスポンスの整形の3つの工程を行います。
// リスト取得の実装例
export const feeds: QueryResolvers['feeds'] = async (_, {page = 1, category_id, word, user_id }, context) => {
const currentUserId = context.currentUserId
// DBへのアクセス
const records = await search({
page, category_id, word, user_id
}, currentUserId)
// レスポンスの整形
const feeds = buildFeeds(records)
return feeds
}
// searchの実装例
const PER_COUNT = 20
export const search = async (params: SearchParams, currentUserId?: string | null) => {
if (!supabase) {
throw InitializeSupabaseError
}
const {
page, category_id, word, user_id
} = params
let query = supabase
.from('feeds')
.select(`
*,
category:category_id (*), // joinもできる
user:user_id (*),
comments (
*,
user:user_id (*)
),
`)
.match({
is_deleted: false,
status: 'public'
})
// クエリを連結できる
if (category_id) {
query = query.match({ category_id })
}
if (word) {
query = query.like('body', `%${word}%`)
}
const { data, error } = await query
.order('created_at', { ascending: false })
.range(0, (page || 1) * PER_COUNT)
if (error) {
console.error('error', error)
throw UnExpectedError
}
return data
}
// 投稿の実装例
export const createFeed: MutationResolvers['createFeed'] = async (_, args, context) => {
// リクエストの検証
const currentUserId = context.currentUserId
// userを検証、失敗したら throw AuthenticationError
try {
// DBへのアクセス
const record = await create({
...args, user_id: currentUserId
})
// レスポンスの整形
return {
success: true,
message: 'success!',
target_id: record.id
}
} catch(e) {
throw e
}
}
// createの実装例
export const create = async (params: CreateParams) => {
if (!supabase) {
throw InitializeSupabaseError
}
try {
const {
body, is_anonymous, status, category_id, user_id
} = params
const { data, error } = await supabase
.from('feeds')
.insert({
body,
is_anonymous,
status,
category_id,
user_id,
})
.single()
if (error) {
console.error('error', error)
throw UnExpectedError
}
if (!data) {
throw UnExpectedError
}
return data
} catch(e) {
throw e
}
}
これらの処理を、NextのAPI Routeに追加します。
import { NextApiRequest, NextApiResponse } from 'next'
import { ApolloServer } from "apollo-server-micro"
import { typeDefs } from '@/apollo/typeDefs'
import { resolvers } from '@/apollo/resolvers'
import { supabase } from '@/libs/serverSupabase'
export const config = {
api: {
bodyParser: false
}
}
const apolloServer = new ApolloServer({
typeDefs,
resolvers,
async context(args) { // 第三引数で共通の処理を登録することができます。
const { req } = args
// headerに追記したauth tokenを使ってuserを取得、検証します。
const authorization = req.headers.authorization
const token = authorization.split(' ')[1]
const user = await supabase?.auth.api.getUser(token)
return { currentUserId: user?.data?.id }
}
})
const startServer = apolloServer.start()
export default async (req: NextApiRequest, res: NextApiResponse) => {
res.setHeader('Access-Control-Allow-Credentials', 'true')
// methodの検証をしてもいいかもしれない
await startServer
await apolloServer.createHandler({
path: '/api/graphql',
})(req, res)
}
これで、バックエンド側の準備は完了しました。
フロント開発
先ほど作成したAPIを、フロントから呼び出します。
まずはリストを取得する処理です。
export const useFeeds = ({page, categoryId, word, userId }: {page?: number | null, categoryId?: number, word?: string | null, userId?: string | null}) => {
// clientを初期化
const client = getClient()
// スキーマで定義したクエリを指定
const query = gql`
query GetFeeds($page: Int, $categoryId: Int, $word: String, $userId: String) {
// ほしい項目を指定
feeds(page: $page, category_id: $categoryId, word: $word, user_id: $userId) {
id
body
status
created_at
is_anonymous
category {
id
name
label
}
user {
name
avatar_url
}
comments {
id
}
}
}
`
const params = {
page, categoryId, word, userId
}
// useSWRを使用。リクエストする際、ブラウザがキャッシュを持っていれば、キャッシュを返す。
// useSWRの第一引数で渡した値を、キャッシュの判定のキーに使用する。
// graphQLを使用する場合は、必要なパラメータも含めて配列で渡す。そうしないと、パラメータの変更で別のリクエストと判定できない。
// dataは、通信中はundefined、データを取得できたら取得した値が格納される。これによりloadingの判定にも使用することができる。
const { data, error: fetchError } = useSWR<{feeds: Feed[] | null}>([query, params], query => client.request(query, params))
const feeds = data?.feeds
return { feeds, fetchError }
}
個人的にすごく嬉しいポイントは、一度取得した値をブラウザでキャッシュしてくれるところです。
これにより、複数箇所で同じAPIを実行しても、不要な通信が発生しなくなります。
例えば、以下の二つのコンポーネントがあった際に、今までは親となるコンポーネントで取得した値をpropsで渡したり、reduxを使って値を渡していたかと思うのですが、useSWRを使うことにより、他のコンポーネントを意識せずとも値を取得することができるようになります。
- AppContainer
- HeaderContainer // user情報を表示させたい
- UserContainer // user情報を表示させたい
useSWRを使わない場合、AppContainerでuser情報を取得し、propsで各コンポーネントに渡す or Reduxを使ってstoreにuser情報を保存していた。
useSWRを使うことによって、各コンポーネントでuser情報を取得しても、複数回同じリクエストが送られることは無くなった。
次は、投稿する処理です。
mutationの処理の実装例も記載しておきます。
export const useCreateFeed = () => {
const client = getClient()
const create = useCallback(async (args: CreateType) => {
// スキーマのmutationで定義したcreateFeedを指定
const mutation = gql`
mutation CreateFeed($body: String!, $isAnonymous: Boolean, $status: String!, $categoryId: Int!){
createFeed(body: $body, is_anonymous: $isAnonymous, status: $status, category_id: $categoryId) {
success,
message,
target_id
}
}
`
const response = await client.request<{createFeed: MutateResponse}>(mutation, args)
return response.createFeed
}, [])
return { create }
}
通知周りの取得
愚痴トークでは、投稿にコメントがくると通知が飛ぶように実装されています。
リアルタイムで画面にも出したいと思い、supabaseのsubscribeの機能を利用しました。
対象のテーブルは以下のように作っています。
create table notifications (
id integer generated by default as identity primary key,
created_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at timestamp with time zone DEFAULT timezone('utc'::text, now()) NOT NULL,
user_id uuid references users NOT NULL,
title varchar(124) NOT NULL,
link text NOT NULL,
is_read boolean default false NOT NULL
);
alter table notifications enable row level security;
subscribeをフロントから利用するため、RLSを使ってアクセスできるレコードのルールを追加します。
create policy "notifications are viewable by target."
on notifications for select
using ( auth.uid() = user_id );
subscribeするために、テーブル定義も更新します。
alter table notifications replica identity full;
alter publication supabase_realtime add table notifications;
次に、フロントからアクセスする実装です。
export const useNotifications = () => {
// subscribeする箇所のみ抜粋
// これにより、ログインユーザーがアクセスできるレコードがInsertされたタイミングで、fetchNotificationsが実行される。
useEffect(() => {
const listener = supabase
.from('notifications')
.on('INSERT', () => {
fetchNotifications()
})
.subscribe()
return () => {
supabase?.removeSubscription(listener)
}
}, [])
}
あとは、取得したnotificationsをコンポーネントに渡してあげれば、リアルタイム通知機能は完成です。
チャット
愚痴トークでは、ユーザー間でのチャット機能もあります。
構造としては、roomsとusersの中間テーブルを作成し、roomに参加しているユーザーを管理、messagesでやりとり内容を管理するようにしています。
チャットの一覧ページでは、各roomの最新メッセージを表示させたいです。
これをRLSで対応するのは面倒でした。
今回は、useSWRのrefreshIntervalを使い、定期的にポーリングするように実装しました。
export const useJoinedChatRooms = () => {
const client = getClient()
const query = gql`
query {
joinedRooms {
id,
messages {
id
body
user {
id
}
created_at
updated_at
is_deleted
is_read
},
chat_rooms_users {
user {
id
name
avatar_url
}
}
}
}
`
// 10sごとに再取得を行う
const { data, error: fetchError } = useSWR<{joinedRooms: ChatRoom[] | null}>(query, query => client.request(query), { refreshInterval: 1000 * 10})
const chatRooms = data?.joinedRooms
return { chatRooms, fetchError }
}
optionで指定するだけでポーリング処理を実装できるのは便利ですね。
ポーリングは、ユーザー数が増えたらきついかもしれませんが、個人開発でそのレベルまで行くことはほぼないだろうと、今回はこの方法を取り入れています。
最後に
ここまで読んでいただき、ありがとうございました。個人開発を行う際に、少しでも参考にしていただけるとありがたいです。
Twitterもやっているので、よかったらフォローしてください。
Discussion