# 20241203_hasura v2のすゝめ (例:20210901_LT会を開催しました) ###### tags: `ブログ記事` - [ ] 公開(ブログ公開担当者がいじるやつ) 太字斜体で書いてある内容を埋めて行ってください. 文章,画像は太字斜体の下の行に入れてください. 最初に書く時はREADMEを読んだら読むといいと思います. <br> ## 表示されない情報 ***書いた人の名前(自己紹介文と同じ名前)*** { 比嘉華 } ***記事の簡単な説明(検索した時にタイトルの下に出てくる文章)*** { 皆様こんにちは。NUTMEG三年目の比嘉華です。アドカレ3日目になります。まだ序の章でありますが是非ご一読ください。 私たちがBingoプロダクトの運用を始めて2年が経ちました。コードに関してはある程度の完成系が見えており、これからはインフラ関連で安定したサービスを目指しています。本ブログでは、Bingoプロダクトで使用しているバックエンドツールであるHasura Engine v2の概要と構成の一例を共有します。Hasura自体の日本語記事が少ない + 私自身のHasuraの復習も兼ねて書いています。この構成が絶対的な正解というわけではありませんので、より良い方法があればぜひ教えてください。 } <br> ## 表示される部分 ***サムネイル画像*** { ![hikahana_blog_thumbnailadokare2](https://hackmd.io/_uploads/B1A1QhFXyx.png) } ***カテゴリ*** 以下の中から該当しそうなカテゴリを選択してください ※一つだけ選択してください - [ ] 対外活動 - [x] 活動の様子 - [x] メンバーの趣味 - [ ] 実務訓練体験記 - [ ] NUTMEG Advent Calendar 2023 - [x] NUTMEG Advent Calendar 2024 ***タグ*** 以下の中から該当しそうなカテゴリを選択してください.当てはまる物がない場合は適宜追加してください. 言語 - [ ] HTML - [ ] CSS - [ ] Python - [ ] Go - [ ] Ruby - [ ] JavaScript - [x] TypeScript - [ ] Dart - [ ] Rust - [ ] Kotlin - [ ] Swift フレームワーク・ライブラリ - [ ] Ruby on rails - [ ] Vue.js - [ ] Nuxt.js - [ ] React.js - [x] Next.js - [ ] Gin - [ ] Flluter ツール - [ ] GitHub - [ ] ターミナル - [ ] WSL - [ ] Ubuntu - [x] Docker - [ ] Raspberry Pi - [ ] Figma - [x] Hasura 分野 - [ ] チームづくり - [ ] フロントエンド - [x] バックエンド - [ ] インフラ - [ ] Web-design - [x] API関係 --- ***以下に本文を記載してください*** ## はじめに 皆様こんにちは。NUTMEG 3年目の比嘉華です。アドカレ3日目になります。まだ序の章でありますが是非ご一読ください。 私たちがBingoプロダクトの運用を始めて2年が経ちました。コードに関してはある程度の完成系が見えており、これからはインフラ関連で安定したサービスを目指しています。本ブログでは、Bingoプロダクトで使用しているバックエンドツールであるHasura Engine v2の概要と構成の一例を共有します。**Hasura自体の日本語記事が少ない + 私自身のHasuraの復習も兼ねて書いています。この構成が絶対的な正解というわけではありませんので、より良い方法があればぜひ教えてください。** ## Hasura GraphQL Engineとは 以下は[公式ドキュメント](https://hasura.io/docs/2.0/index/)の翻訳です。 Hasura GraphQL Engineは、データを即座にGraphQL APIとして利用可能にし、モダンで高性能なアプリケーションやAPIを従来の10倍の速さで構築・提供できるオープンソースのエンジンです。Hasuraはデータベース、RESTおよびGraphQLエンドポイント、サードパーティAPIに接続し、すべてのデータに対して統合されたリアルタイムでセキュアなGraphQL APIを提供します。Hasuraのコア機能はオープンソースで開発されており、誰でも無料で使用できます。 要するに、Hasura Engine(以下、Hasura)は、PostgreSQLサーバーから自動的にGraphQLサーバーを構築できるツールです。Hasuraを使用する際は、HasuraとPostgreSQLを組み合わせて利用するようにしましょう。なお、***「Hasura」は「阿修羅(Ashura)」とHaskellを組み合わせた造語です。*** Hasuraが自動でGraphQLサーバーとして動作するため、GraphQLサーバーを実装する手間が省けます。Hasuraで設定すれば、すぐにQuery、Mutation、Subscriptionを使えるため、高い開発効率で開発を進めることができます。Hasuraのコンソールでテーブルを作成し、クエリに沿ったコードをGraphQL Code Generatorで自動生成できるので、バックエンドの実装を簡単に行うことができます。 また、HasuraではAuth0などの認証プロバイダと通信し、JWTを利用して、各テーブルやカラムに対して、ユーザーのロールやIDに基づく詳細なアクセス権限を設定できます。JWTに含まれる `X-Hasura-User-Id` を使用して、特定のユーザーが自身のデータのみを取得・操作できるように制限することが可能です。 ## Hasuraの環境構築 Docker環境でのHasuraの環境構築を紹介します。この手順を踏むことで、バックエンドをすべてHasuraに一任できるでしょう。 Bingoプロダクトから必要なものだけを抽出して簡素化しているため、フロントエンドはあまり参考にしないでください。本来は、App Routerの使用やpnpmでの管理などを行った方が良いと思います! 本構成の特徴は、`docker compose build`が不要になることです。ただし、使い勝手は正直あまり良くありません。起動のたびに`npm ci、npm run`が実行されるため、コンテナ起動から実際のアプリケーション起動まで約1分かかります。 ### 最終的なディレクトリ構成 ※appは触った部分のみを掲載 ``` 📁api 📁metadata 📁migrations/default/hoge_auto 📄up.sql 📁seed 📄config.yaml 📁app 📁src 📁gql 📄users.gql 📁pages 📄_app.tsx 📄index.tsx 📁type 📄graphql.ts 📄.eslintrc.json 📄next.config.ts 📄codegen.ts 📁settings 📄.env 📄compose.yaml 📄.gitignore 📄Makefile ``` [Githubに挙げたものはこちらです。](https://github.com/hikahana/hasura_v3) GitHub上のリポジトリ作成とfirst commitは完了している前提で進めます。リポジトリの作成について不明な方は、[公式ドキュメント](https://docs.github.com/ja/repositories/creating-and-managing-repositories/creating-a-new-repository)を参照してください。 ### 1. compose upするための準備 1.1 まず、compose.yamlを作成し、使用するコンテナの情報を定義します。フロントエンドはNextjs、バックエンドはHasura、データベースはPostgreSQLを使用するため、これらを定義します。 (以下、compose.yamlの内容) ``` services: db: image: postgres:12 container_name: "db" ports: - "5432:5432" volumes: - db-data:/var/lib/postgresql/data environment: POSTGRES_DB: db POSTGRES_USER: user POSTGRES_PASSWORD: password POSTGRES_HOST_AUTH_METHOD: trust healthcheck: test: - "CMD-SHELL" - "pg_isready" interval: 10s timeout: 5s retries: 5 api: # hasura image: hasura/graphql-engine:v2.36.6@sha256:3fc234510962e66d5ca7db16734b8796a16fb729953915861953e974f976f30f container_name: "api" ports: - "8080:8080" volumes: - ./api:/hasura/api working_dir: /hasura/api env_file: - ./settings/.env entrypoint: > sh -c " curl -L https://github.com/hasura/graphql-engine/raw/stable/cli/get.sh | bash && graphql-engine serve " app: image: node:20-alpine@sha256:b5b9467fe7b33aad47f1ec3f6e0646a658f85f05c18d4243024212a91f3b7554 container_name: "app" volumes: - ./app:/app working_dir: /app command: sh -c "npm ci && npm run dev" ports: - "3000:3000" env_file: - ./settings/.env stdin_open: true tty: true volumes: db-data: ``` --- 1.2 環境変数を配置するため、設定を行います。compose.yamlを確認すると、apiとappの環境変数は`settings/.env`を参照していることがわかります。 ```bash # ルートディレクトリ直下で mkdir settings cd settings touch .env ``` .envファイルの内容は以下の通りです。 (以下、.envの内容) ``` NEXT_PUBLIC_CSR_API_URL="http://localhost:8080" NEXT_PUBLIC_SSR_API_URL="http://api:8080" NEXT_PUBLIC_BASE_URL="http://localhost:8080" API_URI="http://localhost:8080" WATCHPACK_POLLING="true" WS_API_URL="ws://localhost:8080" HASURA_GRAPHQL_ENABLE_CONSOLE="true" # "postgres://{db_user_name}:{db_user_password}@{seivice_name}:{db_port}/{db_name}" HASURA_GRAPHQL_DATABASE_URL="postgres://user:password@db:5432/db" HASURA_GRAPHQL_DEV_MODE="true" HASURA_GRAPHQL_ENABLED_LOG_TYPES="startup, http-log, webhook-log, websocket-log, query-log" HASURA_GRAPHQL_EXPERIMENTAL_FEATURES="naming_convention" HASURA_GRAPHQL_CLI_ENVIRONMENT="default" HASURA_GRAPHQL_ADMIN_SECRET="hogehoge" ``` ※注意点 **HASURA_GRAPHQL_DATABASE_URL**はcompose.yamlのdbのenviromentで定義したものと同一にしてください。そうでないとdbへのアクセスができません。 **HASURA_GRAPHQL_ADMIN_SECRET**は適宜修正してください。 --- 1.3 gitに環境変数ファイルをアップロードしないよう、.gitignoreファイルを作成します。 (以下、.gitignoreの内容) ``` settings/ ``` --- 1.4 Nextjsの環境構築を行います。以下の手順に従ってください。 (以下、Next.jsセットアップの対話型インストール手順) ```bash npx create-next-app@latest app Need to install the following packages: create-next-app@15.0.3 Ok to proceed? (y) y ✔ Would you like to use TypeScript? … No / Yes ✔ Would you like to use ESLint? … No / Yes ✔ Would you like to use Tailwind CSS? … No / Yes ✔ Would you like your code inside a `src/` directory? … No / Yes ✔ Would you like to use App Router? (recommended) … No / Yes ✔ Would you like to use Turbopack for next dev? … No / Yes ✔ Would you like to customize the import alias (@/* by default)? … No / Yes ✔ What import alias would you like configured? … @/* ``` --- 1.5 最後に、Makefileを作成します。これは後のHasura操作で使用します。 (以下、Makefileの内容) ``` run: docker compose up -d sleep 10 make db-apply down: docker compose down db/apply: docker compose exec api hasura metadata apply docker compose exec api hasura migrate apply --database-name default docker compose exec api hasura metadata reload db/export: docker compose exec api hasura metadata export docker compose exec api hasura migrate create "auto" --from-server --database-name default db/seed: docker compose exec api hasura seed apply codegen: docker compose run --rm app npm run codegen ``` ### 2. Hasuraの起動 2.1 compose up前に必要な準備が整ったので、実際にコンテナを立ち上げ、Hasuraの初期起動を行います。 Hasura CLIを使用してHasuraの環境を作成します。今回はコンテナ内でインストールするため、`docker compose run --rm`ではうまく動作しません。 `hasura init --directory .`で初期化の場所を指定できます。今回はcompose upの際にapiフォルダが作成されているため、そこに初期化させます。 ```bash docker compose up -d docker compose exec api hasura init --directory . ``` --- 2.2 初期化が成功したら、`localhost:8080/console`にアクセスしてusersスキーマを作成します。アクセスする際は、.envファイルで設定したADMIN_SECRETが適用されます。 画面上部のヘッダーから**DATA**をクリックし、その後「Create table」をクリックします。以下の画像を参考に、usersスキーマを作成してください。created_atとupdated_atは、「Frequently used columns」から選択できます。 ![image (2)](https://hackmd.io/_uploads/HyzL_ntXJg.png) ![Add Table - Data _ Hasura_page-0001](https://hackmd.io/_uploads/BkO8d2KX1g.jpg) ![image (3)](https://hackmd.io/_uploads/SJ0LOnYXJe.png) --- 2.3 スキーマの作成が完了したら、データベースの移行と現在のスキーマのエクスポートを行います。先ほど作成したMakeコマンドを実行します。 ```bash make db/export make db/apply ``` ### 3. GraphQL Code Generatorの導入 3.1 Hasuraと Next.js の環境が整ったので、最後にCode Generatorで自動生成できるように設定します。 必要なプラグインをインストールします。 ```bash docker compose run --rm app npm install --save-dev \ @graphql-codegen/cli \ graphql \ @graphql-codegen/client-preset \ @graphql-codegen/typescript \ @graphql-codegen/typescript-graphql-request \ @graphql-codegen/typescript-operations \ @graphql-codegen/typescript-react-apollo \ prettier ``` --- 3.2 次に、Code Generatorの設定ファイルを作成します。対話型のセットアップ手順に従い、以下のように設定ファイルを修正します。 ```bash docker compose run app npx graphql-codegen init Welcome to GraphQL Code Generator! Answer few questions and we will setup everything for you. ? What type of application are you building? Application built with React ? Where is your schema?: (path or url) http:localhost:8080 ? Where are your operations and fragments?: src/gql/*.gql ? Where to write the output: type/ ? Do you want to generate an introspection file? Yes ? How to name the config file? codegen.ts ? What script in package.json should run the codegen? codegen Fetching latest versions of selected plugins... ``` init後、以下のように修正してください。 (以下、codegen.tsの内容) ``` import { config as dotenvConfig } from "dotenv"; import { CodegenConfig } from "@graphql-codegen/cli"; dotenvConfig(); const config: CodegenConfig = { overwrite: true, schema: [ { "http://api:8080/v1/graphql": { headers: { "x-hasura-admin-secret": process.env.HASURA_GRAPHQL_ADMIN_SECRET || "", }, }, }, ], documents: "src/gql/*.gql", hooks: { afterAllFileWrite: ["prettier --write"], }, generates: { "./src/type/graphql.ts": { plugins: [ "typescript", "typescript-operations", "typescript-react-apollo", ], config: { namingConvention: { typeNames: "change-case-all#pascalCase", enumValues: "change-case-all#camelCase", fieldNames: "change-case-all#camelCase", }, transformUnderscore: "true", gqlImport: "@apollo/client#gql", withHooks: false, withHOC: false, withComponent: false, }, }, }, }; export default config; ``` --- 3.3 GraphQLクエリファイルを作成します。 ```bash mkdir gql cd gql touch users.gql ``` (以下、users.gqlの内容) ``` query GetUsers { users { id name created_at updated_at } } mutation CreateUser($name: String!) { insert_users_one(object: {name: $name}) { id name } } mutation UpdateUser($id: Int!, $name: String!) { update_users_by_pk(pk_columns: {id: $id}, _set: {name: $name}) { id name } } mutation DeleteUser($id: Int!) { delete_users_by_pk(id: $id) { id name } } ``` --- 3.4 ESLintの設定を修正します。 (以下、.eslintrc.jsonの内容) ``` { "extends": ["next/core-web-vitals", "next/typescript"], "overrides": [ { "files": ["app/src/type/*"], "rules": { "no-unused-vars": "off", "other-rule": "off" } } ] } ``` --- 3.5 最後に、型定義とカスタムフックを自動生成します。 ```bash make codegen ``` ### 4. 動作確認 4.1 src/pagesにある_app.tsxとindex.tsxを修正します。app.tsxでApolloProviderで囲まないとエラーが返ってきてしまうので忘れずに行いましょう。 (以下、_app.tsxの内容) ``` import "@/styles/globals.css"; import type { AppProps } from "next/app"; import { ApolloProvider, ApolloClient, InMemoryCache } from "@apollo/client"; import { GraphQLWsLink } from "@apollo/client/link/subscriptions"; import { createClient } from "graphql-ws"; const wsClient = createClient({ url: process.env.WS_API_URL + "/v1/graphql", connectionParams: { headers: { "x-hasura-admin-secret": process.env.HASURA_GRAPHQL_ADMIN_SECRET, }, }, }); // ヘッダーを含んだ websocket リンクを作成 const wsLink = new GraphQLWsLink(wsClient); // Apollo client を作成 const client = new ApolloClient({ link: wsLink, cache: new InMemoryCache(), }); export default function App({ Component, pageProps }: AppProps) { return ( <ApolloProvider client={client}> <Component {...pageProps} /> </ApolloProvider> ); } ``` (以下、index.tsxの内容) ``` import { useSubscription, useMutation } from "@apollo/client"; import { SubscriptionUsersSubscription, CreateUserMutation, UpdateUserMutation, DeleteUserMutation, SubscriptionUsersDocument, CreateUserDocument, UpdateUserDocument, DeleteUserDocument, } from "@/type/graphql"; import { useState } from "react"; const Page = () => { const { data, loading, error } = useSubscription<SubscriptionUsersSubscription>(SubscriptionUsersDocument); const [createUser] = useMutation<CreateUserMutation>(CreateUserDocument); const [updateUser] = useMutation<UpdateUserMutation>(UpdateUserDocument); const [deleteUser] = useMutation<DeleteUserMutation>(DeleteUserDocument); const [newName, setNewName] = useState<string>(""); const [editId, setEditId] = useState<number | null>(null); const [editName, setEditName] = useState<string>(""); const handleCreateUser = async () => { if (!newName) return; try { await createUser({ variables: { name: newName } }); setNewName(""); } catch (err) { console.error("Error creating user:", err); } }; const handleEdit = (id: number, name: string) => { setEditId(id); setEditName(name); }; const handleUpdateUser = async () => { if (editId === null || !editName) return; try { await updateUser({ variables: { id: editId, name: editName } }); setEditId(null); setEditName(""); } catch (err) { console.error("Error updating user:", err); } }; const handleDeleteUser = async (id: number) => { try { await deleteUser({ variables: { id } }); } catch (err) { console.error("Error deleting user:", err); } }; if (loading) return <>loading...</>; if (error) return <p>Error: {error.message}</p>; return ( <div> <h1>Users</h1> <div> <input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Enter name" /> <button onClick={handleCreateUser}>Create User</button> </div> <ul> {data?.users.map((user) => ( <li key={user.id}> {editId === user.id ? ( <div> <input type="text" value={editName} onChange={(e) => setEditName(e.target.value)} placeholder="Edit name" /> <button onClick={handleUpdateUser}>Save</button> <button onClick={() => setEditId(null)}>Cancel</button> </div> ) : ( <div> <span>{user.name}</span> <button onClick={() => handleEdit(user.id, user.name)}> Edit </button> <button onClick={() => handleDeleteUser(user.id)}> Delete </button> </div> )} </li> ))} </ul> </div> ); }; export default Page; ``` --- 4.2 上記のように修正したら`localhost:3000` にアクセスして動作確認していきます。 cssを書いていないのでとても簡素なものになっていますがユーザの作成、編集、削除を行うとそれが即座に反映されていたらwebsocketが動いているので成功になります。 ![image (4)](https://hackmd.io/_uploads/Hy7-52Ym1e.png) --- ### 環境構築中に発生したエラーについて 自動生成したファイルの権限がrootになっていました。 その場合は以下のように権限を変更してあげてください。 ```bash sudo chown -R {name}:{name} {folder_name} sudo chmod 755 {file_name} ``` ## 余談 BINGOプロダクトを初めて2年が経ちますがcode geneaterを導入したのは今年からでした。なのでせっかくなので導入前と後の比較を行い、自動生成は使うべきという教訓を皆様に共有します。 正直今もGraphql APIとREST APIの違いは分からずに使っていますが初期の頃はapi_methodsを作成していました。pageでは関数だけ呼び出して使いたいためここでPromiseの処理を書いたり、型定義をしたりと書いていました。 しかし、generaterを導入してからはクエリを定義するだけTSに必要な型定義やapollo clientによるカスタムフックが生成されるためせっかくAPIをhasuraが簡単に実装してくれるのにフロントエンドへの繋ぎ込みで時間がかかる問題を解決するに至りました。 導入前のAP /src/utils/api_methods.ts ``` import { ApolloClient, InMemoryCache, gql} from "@apollo/client"; import next from "next/types"; import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; import { createClient } from "graphql-ws"; import { userAgent } from "next/server"; const wsLink = new GraphQLWsLink(createClient({ url: process.env.WS_API_URL + "/v1/graphql", // 認証関連はここに書く })); const client = new ApolloClient({ link: wsLink, cache: new InMemoryCache(), }); export interface BingoNumber { id: number; data: number; } // GraphQLクエリを実行 export async function getBingoNumber(): Promise<BingoNumber[]> { try { const response = await client.query({ query: gql` query MyQuery { bingo_number { data id } } `, }); return response.data.bingo_number; } catch (error) { console.error("Error fetching data:", error); return [] } } // websocket通信でBingoNumberを取得 export async function subscriptionBingoNumber(): Promise<BingoNumber[]> { try { const response = await client.subscribe({ query: gql` subscription MySubscription { bingo_number { data id } } `, }); return new Promise<BingoNumber[]>((resolve, reject) => { response.subscribe({ next: data => resolve(data.data.bingo_number), error: error => { console.error('Subscription error:', error); reject(error); }, }); }); } catch (error) { console.error('Error fetching data:', error); return []; } } export async function createBingoNumber( data: number ): Promise<BingoNumber[]> { try { const response = await client.mutate({ mutation: gql` mutation MyMutation($data: Int!) { insert_bingo_number_one(object: { data: $data }) { id } } `, variables: { data }, }); return response.data.insert_bingo_number_one; } catch (error) { console.error("Error creating bingo number:", error); return [] } } export async function deleteBingoNumber( data: number ): Promise<BingoNumber[]> { try { const response = await client.mutate({ mutation: gql` mutation MyMutation($data: Int!) { delete_bingo_number(where: { data: { _eq: $data } }) { affected_rows } } `, variables: { data }, }); return response.data.delete_bingo_number; } catch (error) { console.error("Error deleteing bingo number:", error); return [] } } ``` 導入後のAPI src/gql/numbers.gql ``` # 番号の取得(Get) query GetListNumbers { numbers { id number createdAt updatedAt } } # 番号の追加(Create) mutation CreateOneNumber($number: Int!) { insertNumbersOne(object: { number: $number }) { id } } # 番号の削除(Delete) mutation DeleteOneNumber($number: Int!) { deleteNumbers(where: { number: { _eq: $number } }) { affectedRows } } # 番号の継続取得(Subscription) subscription SubscribeListNumbers { numbers { id number createdAt updatedAt } } ``` ## おわりに ここまでご一読いただきありがとうございました。誰かの開発のヒントになっていれば幸いです。 私事になりますが、NUTMEGは今年も多くの新入生に恵まれ活気のある団体になりました。他局との信頼関係も一層強まりヒアリングに対して真摯に対応してくださるおかげで沢山の修正事項や改善事項を頂きました。今の新入生の勢いに負けないように寄りよりプロダクトを実現・提供できるように活動していきたいと思います。 最後に言いたかったこととして、皆さん是非Generaterによる自動生成をうまく活用して良い開発ライフをお送りください。今の時代、活用したもの勝ちです。