Next.jsとFirebase HostingでSWR。ついでにGraphQLとCloud Run
概要
先日Next.jsをFirebase Hosting + Cloud Functions For Firebaseにデプロイしてみましたが
しばらくアクセスないタイミングでアクセスするとかなり重くて厳しかったので
Firebase Hosting + SWRでサーバーサイドの処理なく開発できないか遊んでみた
ついでに裏側のAPIをGraphQL(gqlgen)で作成してCloud RunにTerraform経由でデプロイ
全部書くと長くなりそうなので、後で振り返れるようにポイントポイントをメモしておく
ローカル環境
これまで業務ではフロントエンド、バックエンド(API)どちらも
同じリポジトリに格納されているケースしか経験が無かったため
今回は実験的に別リポジトリにして、それぞれ独立させてみた
バックエンド(API)
GraphQL(gqlgen)の作成
業務ではRESTしか触ってないが、個人的にはGraphQL知見高めたいのでそれでいく
色々ぐぐるとgqlgenが便利そうなのでこいつを使って適当なAPIを作成
あまり細かく記載しませんが、ざっくりこんな感じでスケルトンを作成し
go mod init github.com/shintaro-uchiyama/xxx
go get github.com/99designs/gqlgen
go run github.com/99designs/gqlgen init
schema.graphqlsのスキーマを自身の欲しいものに変更
gqlgenでコードを再生成
go run github.com/99designs/gqlgen generate
resolver.goにレスポンスのstructを指定
schema.resolvers.goでダミーのレスポンスを書いておく
※後々はFirestoreあたりのDBから取れるようにする
docker-composeでローカル環境作成
GraphQLを動かすためのgolang Docker準備
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でこいつを起こしてあげる
version: '3.8'
services:
  api:
    build:
      context: ./build/api
      target: local
    ports:
      - "8080:8080"
    volumes:
      - ./:/go/src/app
http://localohst:8080にアクセスしてこんな感じでGraphQLのコンソールが出てればOK!
クエリを実行して結果が返ってきたら、とりあえず動作はしている感じ🧔
完全に雑談(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
    }
  }
}
フロントエンド
docker-composeでローカル環境作成
nextを動かすコンテナの作成
今回は本番でFirebase Hosting使うので、本来であればnode.jsのコンテナではなく
静的な出力ファイルを返却するだけのコンテナを用意してあげるべきな気がしますが
とりあえずnext devでさくっと開発したいのでこっちで行く。
FROM node:15.4.0-alpine3.10
WORKDIR /usr/src/app
作成したDockerfileをdocker-composeから呼び出す
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環境がないので断念
module.exports = {
  async rewrites() {
    return [
      {
        source: "/api/proxy/:path*",
        destination: `${process.env.API_URL}/:path*`,
      },
    ];
  },
};
こないな感じでclientサイドでも読み込める環境変数に設定
module.exports = {
  env: {
    API_URL: process.env.API_URL,
  }
};
API_URLは以下の通り設定
API_URL=http://localhost:8080
npx devすると勝手に.env.developmentが読み込まれるので
swr実行
こんな感じでHooksディレクトリに分離してswr実行
商品一覧をとってきてます
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の型は別ファイルで定義
export interface Product {
  janCode: string;
  name: string;
  price: number;
  registeredAt: string;
}
hooksをcomponentから呼び出す
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で絞ってください🙇♂️
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
自分の場合こんな感じ
本番デプロイ
バックエンド(API)
Cloud Runにデプロイしていく
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  /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作成
ちょっと検証なので誰でも見れるように認証外してます
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は変数として設定
variable "project" {
  description = "gcpの対象project id"
  type = string
}
本番環境用に今回対象のproject_idを指定する
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にスクリプト追加
{
  ...
  "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の設定を記載。(他に手はないのかしら🤔
{
  "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が環境変数としてみられるみたい
API_URL=https://cloudrun-srv-xxx.run.app
デプロイして出力されたURLにアクセスすればローカルで見たようにページ表示されるはず
npm run deploy
SWRのおかげかとりあえずは早そう
まとめ
完全にFirebase HostingのみでNode.js使わないようにやってみたけど
なんとかこねくりまわして実現してる感が否めない and Next.jsの機能を十分に使えてない感がありよりなので
次は大人しくVercelにデプロイ作戦にしようと思う😫





Discussion
とても参考になりました!
ありがとうございます。
ここなのですが、
firebase.jsonファイルでcleanUrlsをtrueにしたところリロードしてもちゃんと表示されました!参考: https://bunchan.dev/posts/nextjs-blog/
ただ、このやり方だとダイナミックルーティングをデプロイしたときに正しく動かすことができませんでした。。。
やり方を探したのですが分からずやはりvercelにデプロイするのが無難ですね(汗)
コメントありがとうございます!!!
ダイナミックルーティングではダメなのですね・・・
勉強になりました!!🙇♂️