Next.jsとFirebase HostingでSWR。ついでにGraphQLとCloud Run

公開:2020/12/28
更新:2020/12/28
12 min読了の目安(約11300字TECH技術記事

概要

先日Next.jsをFirebase Hosting + Cloud Functions For Firebaseにデプロイしてみましたが
しばらくアクセスないタイミングでアクセスするとかなり重くて厳しかったので
Firebase Hosting + SWRでサーバーサイドの処理なく開発できないか遊んでみた

ついでに裏側のAPIをGraphQL(gqlgen)で作成してCloud RunにTerraform経由でデプロイ
swr

全部書くと長くなりそうなので、後で振り返れるようにポイントポイントをメモしておく

ローカル環境

これまで業務ではフロントエンド、バックエンド(API)どちらも
同じリポジトリに格納されているケースしか経験が無かったため
今回は実験的に別リポジトリにして、それぞれ独立させてみた

バックエンド(API)

GraphQL(gqlgen)の作成

業務ではRESTしか触ってないが、個人的にはGraphQL知見高めたいのでそれでいく
色々ぐぐるとgqlgenが便利そうなのでこいつを使って適当なAPIを作成

あまり細かく記載しませんが、ざっくりこんな感じでスケルトンを作成し

local
go mod init github.com/shintaro-uchiyama/xxx
go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init

schema.graphqlsのスキーマを自身の欲しいものに変更
gqlgenでコードを再生成

local
go run github.com/99designs/gqlgen generate

resolver.goにレスポンスのstructを指定
schema.resolvers.goでダミーのレスポンスを書いておく
※後々はFirestoreあたりのDBから取れるようにする

docker-composeでローカル環境作成

GraphQLを動かすためのgolang Docker準備

build/api/Dockerfile
FROM golang:1.15.6 AS local

WORKDIR /go/src/app

RUN go get github.com/cespare/reflex
CMD reflex -r '(\.go$|go\.mod)' -s go run server.go

docker-composeでこいつを起こしてあげる

docker-compose.yml
version: '3.8'
services:
  api:
    build:
      context: ./build/api
      target: local
    ports:
      - "8080:8080"
    volumes:
      - ./:/go/src/app

http://localohst:8080にアクセスしてこんな感じでGraphQLのコンソールが出てればOK!
クエリを実行して結果が返ってきたら、とりあえず動作はしている感じ🧔
graphql

完全に雑談(GraphQLでGroup By)

「先週のカテゴリー毎の売上数合計をグラフに表示したいな」と思った時に
GraphQLではSQLでいうところのGroup By(Aggregation)ってどう表現するんだろう?

こんな感じのGroup BY的な集約系クエリってどう書くんだろと1,2時間悩みましたが

SELECT category_id, date, SUM(quantity) FROM sales WHERE date > DATE_SUB(DATE_TRUNC(CURRENT_DATE('Asia/Tokyo'), week), INTERVAL 1 week) GROUP BY category_id, date 

issueにある通り複雑な集計をプログラム内の関数でやらずに
Schemaで表現することにしました。(意図とあってるのかな

query findCategories {
  categories {
    id
    name
    products {
      id
    }
    lastWeekSales{
      soldAt
      quantity
    }
  }
}

https://github.com/graphql/graphql-js/issues/855#issuecomment-302413633

フロントエンド

docker-composeでローカル環境作成

nextを動かすコンテナの作成
今回は本番でFirebase Hosting使うので、本来であればnode.jsのコンテナではなく
静的な出力ファイルを返却するだけのコンテナを用意してあげるべきな気がしますが
とりあえずnext devでさくっと開発したいのでこっちで行く。

docker/nextjs/Dockerfile
FROM node:15.4.0-alpine3.10

WORKDIR /usr/src/app

作成したDockerfileをdocker-composeから呼び出す

docker-compose.yml
version: '3.8'
services:
  nextjs:
    build: ./docker/nextjs
    container_name: nextjs
    volumes:
      - ./:/usr/src/app
    command: "npm run dev"
    ports:
      - "3000:3000"
    networks:
      - ucwork-api_default
networks:
  ucwork-api_default:
    external: true

ポイントとしてはフロントのdocker-composeからapiのdocker-composeにアクセスできるように
していること
docker network lsで出てくるAPIのネットワークを指定する
※ただ、今回は本番にNode.js環境がない(Firebase Hosting)ので実際はあまり関係ないw
※Proxyとかする場合はここやっといたほうが良さそう

$ docker network ls
NETWORK ID     NAME                      DRIVER    SCOPE
91bf2ecd2671   ucwork-api_default        bridge    local

SWRでAPIリクエスト

本当はこれしてみたかっただけなのにだいぶ遠回りした...

環境に応じてAPIのURL(Domain)変えたい

本当はこんな感じで/api/proxy/宛は環境別のURLにプロキシーしたかったけど
今回はNode.js環境がないので断念

next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: "/api/proxy/:path*",
        destination: `${process.env.API_URL}/:path*`,
      },
    ];
  },
};

こないな感じでclientサイドでも読み込める環境変数に設定

next.config.js
module.exports = {
  env: {
    API_URL: process.env.API_URL,
  }
};

API_URLは以下の通り設定

.env.development
API_URL=http://localhost:8080

npx devすると勝手に.env.developmentが読み込まれるので

swr実行

こんな感じでHooksディレクトリに分離してswr実行
商品一覧をとってきてます

hooks/useProducts.ts
import useSWR from "swr";
import { request } from "graphql-request";
import { Product } from "./useProduct";

interface Products {
  products: Product[];
  isLoading: boolean;
  error: any;
}

export const useProducts: () => Products = () => {
  const fetcher = (query) => request(`${process.env.API_URL}/query`, query);
  const { data, error } = useSWR(
    `{
      products {
        janCode
        name
        price
        registeredAt
      }
    }`,
    fetcher
  );

  return {
    products: data ? data.products : [],
    isLoading: !error && !data,
    error: error,
  };
}

productの型は別ファイルで定義

hooks/useProduct.ts
export interface Product {
  janCode: string;
  name: string;
  price: number;
  registeredAt: string;
}

hooksをcomponentから呼び出す

components/organisms/ProductTable.tsx
import { useProducts } from "../../hooks/useProducts";
const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    table: {
      minWidth: 650,
    },
  })
);

export const ProductTable: React.FC<{}> = () => {
  const classes = useStyles();
  const [t] = useTranslation();

  const { products, isLoading, error } = useProducts();
  if (isLoading) {
    return <CircularProgress />;
  } else if (error) {
    return <div>{t("products.table.errors.load")}</div>;
  } else {
    return (
      <TableContainer component={Paper}>
        ...
	  <TableBody>
            {products.map((row) => {
              const registeredAt = new Date(row.registeredAt);
              return (
                <TableRow key={row.name}>
                  <TableCell align="right">{row.name}</TableCell>

loadingとエラーとデータが返ってきたときを
すごいシンプルにかけるので良さそう。キャッシュされたものを使ってるからか早い(ように感じる

CORS対策

http://localhost:3000 のoriginからhttp://localhost:8080 にAPIリクエスト送るからかCORSで怒られた。
GraphQLのサーバーでCORS許可設定を追加したら上記貼り付けたように適切にAPIリクエストできた
※一旦全許可してますが、対象のURLで絞ってください🙇‍♂️

server.go
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/go-chi/chi"

	"github.com/rs/cors"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/shintaro-uchiyama/ucwork-api/graph"
	"github.com/shintaro-uchiyama/ucwork-api/graph/generated"
)

const defaultPort = "8080"

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = defaultPort
	}

	router := chi.NewRouter()
	cors := cors.New(cors.Options{
		// FIXME: strict to target urls
		AllowedOrigins:   []string{"*"},
		AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
		AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
		ExposedHeaders:   []string{"Link"},
		AllowCredentials: true,
		MaxAge:           300, // Maximum value not ignored by any of major browsers
	})
	router.Use(cors.Handler)

	srv := handler.NewDefaultServer(generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}}))

	router.Handle("/", playground.Handler("GraphQL playground", "/query"))
	router.Handle("/query", srv)

	log.Printf("connect to http://localhost:%s/ for GraphQL playground", port)
	log.Fatal(http.ListenAndServe(":"+port, router))
}

ブラウザで動作確認

あとはdocker-compose立ち上げてhttp://localhost:3000でページが表示されるはず!

docker-compose up -d --build

自分の場合こんな感じ
dashboard

本番デプロイ

バックエンド(API)

Cloud Runにデプロイしていく

Dockerfileの準備

マルチステージビルドでビルドして出力されたファイルを実行

build/api/Dockerfile
FROM golang:1.15.6 AS local

WORKDIR /go/src/app

RUN go get github.com/cespare/reflex
CMD reflex -r '(\.go$|go\.mod)' -s go run server.go

FROM golang:1.15.6 AS builder
WORKDIR /go/src/github.com/shintaro-uchiyama/xxx
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app server.go

FROM alpine:3.12.3 AS production
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /go/src/github.com/shintaro-uchiyama/xxx/app .
CMD ["/root/app"]

Container Registryにデプロイ

自分のGCPプロジェクトにデプロイ

docker build --target production -t ucwork-api -f build/api/Dockerfile .
docker tag ucwork-api gcr.io/[project_id]/ucwork-api:0.1.0
docker push gcr.io/[project_id]/ucwork-api:0.1.0

Cloud Runにデプロイ

Cloud Runのmodule作成
ちょっと検証なので誰でも見れるように認証外してます

modules/gcp/cloud_run/main.tf
resource "google_cloud_run_service" "default" {
  name     = "cloudrun-srv"
  location = "asia-northeast1"
  project = var.project

  template {
    spec {
      containers {
        image = "gcr.io/${var.project}/ucwork-api:0.1.0"
      }
    }
  }

  traffic {
    percent         = 100
    latest_revision = true
  }
}


data "google_iam_policy" "noauth" {
  binding {
    role = "roles/run.invoker"
    members = [
      "allUsers",
    ]
  }
}

resource "google_cloud_run_service_iam_policy" "noauth" {
  location    = google_cloud_run_service.default.location
  project     = google_cloud_run_service.default.project
  service     = google_cloud_run_service.default.name

  policy_data = data.google_iam_policy.noauth.policy_data
}

どのプロジェクトにも展開できるように
project_idは変数として設定

modules/gcp/cloud_run/variables.tf
variable "project" {
  description = "gcpの対象project id"
  type = string
}

本番環境用に今回対象のproject_idを指定する

environments/production/main.tf
terraform {
  required_version = "0.14.3"
}

module "cloud_run_api" {
  source = "../../modules/gcp/cloud_run"
  project = "[project_id]"
}

デプロイする

cd environments/production
terraform init
terraform validate
terraform plan
terraform apply

表示されたCloud RunのURLにアクセスして
GraphQLのコンソールが表示されたらうまくいってるはず

フロントエンド

package.jsonにスクリプト追加

package.json
{
  ...
  "scripts": {
    "login": "firebase login --no-localhost",
    "build": "next build",
    "export": "next export",
    "deploy": "firebase deploy --only hosting",

firebaseにログイン

以下実行して出力されるURLでGoogleアカウントにログイン
出てきたコードをターミナルに貼り付けてログイン成功!

npm run login

firebaseの設定

firebaseのdeploy前にbuild and export
outディレクトリに静的ファイルが出力されるのでそれをデプロイするようにし指定

ページリロードするとfirebase hostingで404になるので
rewritesの設定を記載。(他に手はないのかしら🤔

firebase.json
{
  "hosting": {
    "predeploy": "npm run build && npm run export",
    "public": "out",
    "rewrites": [
      {
        "source": "/dashboard",
        "destination": "/dashboard.html"
      },
      {
        "source": "/products",
        "destination": "/products.html"
      }
    ]
  }
}

実際にデプロイ

デプロイしたCloud RunのURLを.env.productionに指定
next startまたはnext exportすると.env.productionが環境変数としてみられるみたい

.env.production
API_URL=https://cloudrun-srv-xxx.run.app

デプロイして出力されたURLにアクセスすればローカルで見たようにページ表示されるはず

npm run deploy

SWRのおかげかとりあえずは早そう
lighthouse

まとめ

完全にFirebase HostingのみでNode.js使わないようにやってみたけど
なんとかこねくりまわして実現してる感が否めない and Next.jsの機能を十分に使えてない感がありよりなので
次は大人しくVercelにデプロイ作戦にしようと思う😫