🚀

supabase × Next × GraphQLを使ってサクッと個人サービスをリリースする

2022/01/31に公開

先日、個人で愚痴を投稿するサービス「愚痴トーク」をリリースしました。
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を利用すれば簡単にユーザー管理機能を実装することができます。
https://supabase.com/docs/guides/auth

// 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に、先ほど定義したスキーマの型情報が吐き出されました。

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に追加します。

src/pages/api/graphql.ts
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を、フロントから呼び出します。
まずはリストを取得する処理です。

useFeed.ts
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の処理の実装例も記載しておきます。

useFeed.ts
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;

次に、フロントからアクセスする実装です。

useNotifications
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を使い、定期的にポーリングするように実装しました。

useChatRooms.ts
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