皆様こんにちは。NUTMEG 3年目の比嘉華です。アドカレ3日目になります。まだ序の章でありますが是非ご一読ください。 私たちがBingoプロダクトの運用を始めて2年が経ちました。コードに関してはある程度の完成系が見えており、これからはインフラ関連で安定したサービスを目指しています。本ブログでは、Bingoプロダクトで使用しているバックエンドツールであるHasura Engine v2の概要と構成の一例を共有します。Hasura自体の日本語記事が少ない + 私自身のHasuraの復習も兼ねて書いています。この構成が絶対的な正解というわけではありませんので、より良い方法があればぜひ教えてください。
以下は公式ドキュメントの翻訳です。 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
を使用して、特定のユーザーが自身のデータのみを取得・操作できるように制限することが可能です。
Docker環境でのHasuraの環境構築を紹介します。この手順を踏むことで、バックエンドをすべてHasuraに一任できるでしょう。
Bingoプロダクトから必要なものだけを抽出して簡素化しているため、フロントエンドはあまり参考にしないでください。本来は、App Routerの使用やpnpmでの管理などを行った方が良いと思います!
本構成の特徴は、docker compose build
が不要になることです。ただし、使い勝手は正直あまり良くありません。起動のたびにnpm ci、npm run
が実行されるため、コンテナ起動から実際のアプリケーション起動まで約1分かかります。
📁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上のリポジトリ作成とfirst commitは完了している前提で進めます。リポジトリの作成について不明な方は、公式ドキュメントを参照してください。
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
を参照していることがわかります。
# ルートディレクトリ直下で
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セットアップの対話型インストール手順)
npx create-next-app@latest app
Need to install the following packages:
[email protected]
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.1 compose up前に必要な準備が整ったので、実際にコンテナを立ち上げ、Hasuraの初期起動を行います。
Hasura CLIを使用してHasuraの環境を作成します。今回はコンテナ内でインストールするため、docker compose run --rm
ではうまく動作しません。
hasura init --directory .
で初期化の場所を指定できます。今回はcompose upの際にapiフォルダが作成されているため、そこに初期化させます。
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」から選択できます。
2.3 スキーマの作成が完了したら、データベースの移行と現在のスキーマのエクスポートを行います。先ほど作成したMakeコマンドを実行します。
make db/export
make db/apply
3.1 HasuraとNext.jsの環境が整ったので、最後にCode Generatorで自動生成できるように設定します。
必要なプラグインをインストールします。
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の設定ファイルを作成します。対話型のセットアップ手順に従い、以下のように設定ファイルを修正します。
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クエリファイルを作成します。
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 最後に、型定義とカスタムフックを自動生成します。
make codegen
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が動いているので成功になります。
自動生成したファイルの権限がrootになっていました。 その場合は以下のように権限を変更してあげてください。
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による自動生成をうまく活用して良い開発ライフをお送りください。今の時代、活用したもの勝ちです。