Nutmeg advent calendar 2024 メンバーの趣味 活動の様子

hasura v2のすゝめ

hasura v2のすゝめ

はじめに

皆様こんにちは。NUTMEG 3年目の比嘉華です。アドカレ3日目になります。まだ序の章でありますが是非ご一読ください。 私たちがBingoプロダクトの運用を始めて2年が経ちました。コードに関してはある程度の完成系が見えており、これからはインフラ関連で安定したサービスを目指しています。本ブログでは、Bingoプロダクトで使用しているバックエンドツールであるHasura Engine v2の概要と構成の一例を共有します。Hasura自体の日本語記事が少ない + 私自身のHasuraの復習も兼ねて書いています。この構成が絶対的な正解というわけではありませんので、より良い方法があればぜひ教えてください。

Hasura GraphQL Engineとは

以下は公式ドキュメントの翻訳です。 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に挙げたものはこちらです。

GitHub上のリポジトリ作成とfirst commitは完了している前提で進めます。リポジトリの作成について不明な方は、公式ドキュメントを参照してください。

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を参照していることがわかります。

# ルートディレクトリ直下で
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. Hasuraの起動

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」から選択できます。 image (2) Add Table - Data _ Hasura_page-0001 image (3)


2.3 スキーマの作成が完了したら、データベースの移行と現在のスキーマのエクスポートを行います。先ほど作成したMakeコマンドを実行します。

make db/export
make db/apply

3. GraphQL Code Generatorの導入

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. 動作確認

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)


環境構築中に発生したエラーについて

自動生成したファイルの権限が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による自動生成をうまく活用して良い開発ライフをお送りください。今の時代、活用したもの勝ちです。