【NestJS + Next.js】尝试用TypeScript仅构建后端和前端【GraphQL / TypeORM / React】

做过的事情 de

我只用 TypeScript 几乎完全完成了后端和前端的开发。

完成的东西

技術堆栈

    • Backend

NestJS
TypeORM
GraphQL (Code first) / Apollo

Frontend

Next.js / React
GraphQL Code Generator / Apollo Client
emotion / Semantic UI

Other

yarn workspace
ESLint / Prettier / husky
Docker / Docker Compose
Vercel
GCP (CloudSQL / Cloud Run / Cloud Build)

后端

NestJS -> Nest框架

    • TypeScriptでスケーラブルなサーバーサイドアプリを構築するためのNode.jsフレームワークです

 

    • 型安全なので何か誤った記述があれば起動時にすぐに分かるのはもちろん、コードの補完もバッチリですし開発者にも優しいです

 

    • DI(依存性注入)にも対応しており、@Injectable()をclassに当てることで簡単に実現できます

 

    • 他にもclassにデコレーターを当てることで様々な機能(今回はTypeORMとGraphQL)に対応することができ、拡張性が高いのも魅力です

 

    • 英語ですがドキュメントに様々な例が載っているのでじっくり読めば大抵のことはできると思います

 

    次のようにCLIで新しいプロジェクトに必要なファイルを一通り作ってくれます
yarn global add @nestjs/cli
nest new project-name
    module/controller/serviceのような構成でディレクトリを分けることができ、今回は次のようにしました
src/
  modules/
  services/
  resolvers/
  models/
  dto/
  migrations/

TypeORM是一种用于Node.js和TypeScript的ORM(对象关系映射)库。

    • TypeScriptでDBの操作を行えるORMで、モデル定義からmigrationを自動生成することも可能です

 

    • MySQLをはじめ、sqlite/postgres/MongoDBなど様々なDBに対応しています

 

    次はOneToMany/ManyToManyといったリレーションも含めたモデルの例です(詳しくはGitHub参照)
import {
  Column,
  CreateDateColumn,
  Entity,
  JoinTable,
  ManyToMany,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from "typeorm";

import { CategoryModel } from "@/models/category.model";
import { TaskContentModel } from "@/models/taskContent.model";

@Entity("tasks")
export class TaskModel {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  title: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @OneToMany((type) => TaskContentModel, (taskContent) => taskContent.task)
  taskContents: TaskContentModel[];

  @ManyToMany((type) => CategoryModel, (category) => category.tasks)
  @JoinTable({ name: "tasks_categories" })
  categories: CategoryModel[];
}

    モデルを定義した後、 typeorm migration:generate -n AddTaskTable のようにコマンドで次のようなmigrationファイルを自動生成してくれます
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddTaskTable1603379738841 implements MigrationInterface {
  name = "AddTaskTable1603379738841";

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      "CREATE TABLE `tasks` (`id` int NOT NULL AUTO_INCREMENT, `title` varchar(255) NOT NULL, `createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`id`)) ENGINE=InnoDB",
    );
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query("DROP TABLE `tasks`");
  }
}

    • TypeORMではRepositoryを通じてモデルを操作することができ、NestJSと併用した場合には @InjectRepository で対象リポジトリを指定して使うことができるようになります

find save delete といったメソッドで直感的にDBを操作することができます
また、モデルに存在しないカラムを追加しようとすると型の時点でエラーになるなど型安全に開発できます

import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";

import { AddTaskInput } from "@/dto/task.dto";
import { TaskModel } from "@/models/task.model";
import { CategoryService } from "@/services/category.service";

@Injectable()
export class TaskService {
  constructor(
    @InjectRepository(TaskModel)
    private taskRepository: Repository<TaskModel>,
    private categoryService: CategoryService,
  ) {}

  async findOne(id: number) {
    return this.taskRepository.findOne(id, { relations: ["taskContents", "categories"] });
  }

  async findAll() {
    return this.taskRepository.find({ order: { createdAt: "ASC" }, relations: ["taskContents", "categories"] });
  }

  async save({ categoryIds, ...payload }: AddTaskInput) {
    const categories = await this.categoryService.findByIds(categoryIds);
    return await this.taskRepository.save({ ...payload, categories });
  }

  async delete(id: number) {
    await this.taskRepository.delete(id);
    return await this.findOne(id);
  }
}

    • ちなみに最近だとPrismaが注目されつつあるようですが、TypeORMは後述するGraphQLとモデルを共通化して書けるのに対し、Prismaを使う場合は別途Prisma用の定義が必要となり冗長になるため今回はやめました

NestJSにはPrismaのサンプルもあるためまた試してみようと思います

GraphQL(代码优先)/ Apollo

    • GraphQLを使うことでバックエンド/フロントエンド間の型安全なデータのやりとりが簡単に実現できます

 

    • RestfulAPIの場合もOpenAPIで定義したschemaからTypeScriptの型定義を生成することはできますが、GraphQLの場合は(Code firstの場合)書いたコードから自動でschemaを生成できる上、実際のコードとの一貫性も自動的に担保されるのが開発者にとって素晴らしい体験だと思います

 

    • 次は先ほどのTypeORMのDBモデルにGraphQLのschemaを適用した例です

プリミティブ型はそのまま変換されるので、ほぼ@Fieldを追加するだけOKです

@nestjs/graphql では内部的にApolloが使用されています

import { Field, ID, ObjectType } from "@nestjs/graphql";
import {
  Column,
  CreateDateColumn,
  Entity,
  JoinTable,
  ManyToMany,
  OneToMany,
  PrimaryGeneratedColumn,
  UpdateDateColumn,
} from "typeorm";

import { CategoryModel } from "@/models/category.model";
import { TaskContentModel } from "@/models/taskContent.model";

@ObjectType()
@Entity("tasks")
export class TaskModel {
  @Field((type) => ID)
  @PrimaryGeneratedColumn()
  id: number;

  @Field()
  @Column()
  title: string;

  @Field()
  @CreateDateColumn()
  createdAt: Date;

  @Field()
  @UpdateDateColumn()
  updatedAt: Date;

  @Field((type) => [TaskContentModel], { defaultValue: [] })
  @OneToMany((type) => TaskContentModel, (taskContent) => taskContent.task)
  taskContents: TaskContentModel[];

  @Field((type) => [CategoryModel], { defaultValue: [] })
  @ManyToMany((type) => CategoryModel, (category) => category.tasks)
  @JoinTable({ name: "tasks_categories" })
  categories: CategoryModel[];
}

    DBにEnum型がある場合は次のようにできます
import { Field, ID, ObjectType, registerEnumType } from "@nestjs/graphql";
import { Column, CreateDateColumn, Entity, ManyToMany, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm";

export enum Color {
  red = "red",
  blue = "blue",
  green = "green",
}

registerEnumType(Color, { name: "Color" });

@ObjectType()
@Entity("categories")
export class CategoryModel {

  // 省略

  @Field((type) => Color)
  @Column({ type: "enum", enum: Color })
  color: Color;
}

    • さらにResolverでGraphQLのIFと実際の処理を定義していきます

 

    今回の構成の場合、各serviceでDB操作を記述しているのでresolverからserviceを呼んでDB操作を行い、結果を返すようになっています
import { Inject } from "@nestjs/common";
import { Args, ID, Mutation, Query, Resolver } from "@nestjs/graphql";

import { AddTaskInput } from "@/dto/task.dto";
import { TaskModel } from "@/models/task.model";
import { TaskService } from "@/services/task.service";

@Resolver((of) => TaskModel)
export class TaskResolver {
  constructor(@Inject(TaskService) private taskService: TaskService) {}

  @Query((returns) => TaskModel, { nullable: true })
  async task(@Args("id", { type: () => ID }) id: number) {
    return await this.taskService.findOne(id);
  }

  @Query((returns) => [TaskModel])
  async tasks() {
    return await this.taskService.findAll();
  }

  @Mutation((returns) => TaskModel)
  async saveTask(@Args("task") task: AddTaskInput) {
    return await this.taskService.save(task);
  }

  @Mutation((returns) => TaskModel, { nullable: true })
  async deleteTask(@Args("id", { type: () => ID }) id: number) {
    return await this.taskService.delete(id);
  }
}

    複雑なクエリを受け取る場合は次のように@InputType()を定義して引数として使用できます
import { Field, ID, InputType } from "@nestjs/graphql";

@InputType()
export class AddTaskInput {
  @Field()
  title: string;

  @Field((type) => [ID])
  categoryIds: number[];
}

    • NestJSを起動すれば即座にGraphQLスキーマが生成されます

http://localhost:3300/graphql に接続すると次のようにGUIでクエリを試すことができます

localhost_3300_graphql(Laptop with MDPI screen) (3).png
localhost_3300_graphql(Laptop with MDPI screen) (2).png
    • また、GraphQLではデフォルトで Int/Float/String/Boolean/ID の型しか用意されていないため、その他の型やオリジナルの型が必要な場合はScalarを用意して変換処理を書く必要があります

 

    例えばDate型は次のようにします
import { CustomScalar, Scalar } from "@nestjs/graphql";
import { Kind, ValueNode } from "graphql";

@Scalar("Date", (type) => Date)
export class DateScalar implements CustomScalar<number, Date> {
  description = "Date custom scalar type";

  // value sent to the client
  serialize(value: Date): number {
    return value.getTime();
  }

  // value from the client
  parseValue(value: number): Date {
    return new Date(value); 
  }

  parseLiteral(ast: ValueNode): Date {
    if (ast.kind === Kind.INT) {
      return new Date(ast.value);
    }
    return null;
  }
}

    • Backend側の主な実装は以上ですが、実際には依存関係についてmoduleでいろいろ記述していたりするので詳しくはGitHubをご確認ください

https://github.com/mikan3rd/nest-next-sample

前端

Next.js 是一个基于 React 的 JavaScript 框架。

    • Reactでアプリを作成してOGP対応したい場合にはSSRやSSGなどの対応が必要となり、そのためのフレームワークとして人気のあるNext.jsを使いました

 

    • その他にも、ルーティングの仕組みがデフォルトで用意されておりbuild時のファイル分割にも対応しているなど様々な機能を使うことができ、アップデートも活発なのでSSRを使用しないケースでもおすすめです

 

    こちらも次のようにCLIでプロジェクトに必要なファイルを一通り生成できます
yarn create next-app

tsconfig.json を作成して起動するだけでTypeScriptを使う準備が整います

touch tsconfig.json
yarn dev

GraphQL 代码生成器 / Apollo 客户端

    • GraphQL Code Generatorを使うと.graphqlファイルからTypeScript型定義を自動生成することができます

 

    • さらにApolloのPluginを使うことでBackendにリクエストを投げるReact Hooksも自動生成することができます

 

    次のような設定ファイルを用意します
overwrite: true
schema: "../backend/schema.graphql"
documents:
  - ./graphql/queries/*.graphql
  - ./graphql/mutations/*.graphql
generates:
  graphql/generated.ts:
    plugins:
      - "typescript"
      - "typescript-operations"
      - "typescript-react-apollo"
    config:
      skipTypename: true
      scalars:
        Date: number
    hooks:
      afterOneFileWrite:
        - yarn lint --fix
    • React Hooksを使用するPluginを使うためにはoperation(実際に使用するGraphQLのクエリ)を用意する必要があります

 

    これらのファイルへのpathを先ほどの設定ファイルのdocumentsに指定します
query tasks {
  tasks {
    id
    title
    createdAt
    updatedAt
    taskContents {
      id
      checked
      title
      createdAt
      updatedAt
    }
    categories {
      id
      name
      color
    }
  }
}

mutation addTask($task: AddTaskInput!) {
  saveTask(task: $task) {
    id
  }
}

mutation deleteTask($id: ID!) {
  deleteTask(id: $id) {
    id
  }
}

graphql-codegen のCLIコマンドを使うことで次のようなファイルを自動生成できます

import { gql } from "@apollo/client";
import * as Apollo from "@apollo/client";

export const TasksDocument = gql`
  query tasks {
    tasks {
      id
      title
      createdAt
      updatedAt
      taskContents {
        id
        checked
        title
        createdAt
        updatedAt
      }
      categories {
        id
        name
        color
      }
    }
  }
`;

/**
 * __useTasksQuery__
 *
 * To run a query within a React component, call `useTasksQuery` and pass it any options that fit your needs.
 * When your component renders, `useTasksQuery` returns an object from Apollo Client that contains loading, error, and data properties
 * you can use to render your UI.
 *
 * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
 *
 * @example
 * const { data, loading, error } = useTasksQuery({
 *   variables: {
 *   },
 * });
 */
export function useTasksQuery(baseOptions?: Apollo.QueryHookOptions<TasksQuery, TasksQueryVariables>) {
  return Apollo.useQuery<TasksQuery, TasksQueryVariables>(TasksDocument, baseOptions);
}
export function useTasksLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<TasksQuery, TasksQueryVariables>) {
  return Apollo.useLazyQuery<TasksQuery, TasksQueryVariables>(TasksDocument, baseOptions);
}
export type TasksQueryHookResult = ReturnType<typeof useTasksQuery>;
export type TasksLazyQueryHookResult = ReturnType<typeof useTasksLazyQuery>;
export type TasksQueryResult = Apollo.QueryResult<TasksQuery, TasksQueryVariables>;

    あとはをApolloProviderにApolloClientを渡すとReactコンポーネントでhooksを呼び出せるようになります
import { ApolloClient, InMemoryCache } from "@apollo/client";
import getConfig from "next/config";

export const client = new ApolloClient({
  uri: "http://localhost:3300/graphql",
  cache: new InMemoryCache(),
});
import React from "react";

import { ApolloProvider } from "@apollo/client";
import { AppProps } from "next/app";

import { client } from "@/graphql/client";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ApolloProvider client={client}>
      <Component {...pageProps} />
    </ApolloProvider>
  );
}

export default MyApp;
    • SSRとCSRの両方に対応した例を載せておきます(大抵の場合は片方だけでいいはずが)

HooksはComponent内でしか使用できないため、 SSR対応のgetServerSidePropsではclient.query()を使用してデータを取得しています

import React, { memo } from "react";

import { InferGetServerSidePropsType } from "next";

import { client } from "@/graphql/client";
import { TasksDocument, TasksQuery, TasksQueryVariables, useTasksQuery } from "@/graphql/generated";

export const getServerSideProps = async () => {
  const { data } = await client.query<TasksQuery, TasksQueryVariables>({
    query: TasksDocument,
  });
  return { props: { initialData: data } };
};

export default memo<InferGetServerSidePropsType<typeof getServerSideProps>>(({ initialData }) => {
  const { data, refetch } = useTasksQuery();
  const tasksData = data ? data.tasks : initialData.tasks;

  return (
    <div>Nest Next TODO Sample</div>
  );
});

情感 / 语义化用户界面

    • 以前はscssをBEM記法で書いてましたが、DOMとstyleを別々に記述することや別々のファイルにあるものをclass名でつなぎ合わせる作業が面倒なので、自分は CSS in JS が好きです

スタイルのスコープがグローバルな場合はclass名の競合に気をつけないといけなかったり、そもそもclass名の設計を考えること自体が面倒だったりチーム開発の場合は命名規則がズレがち
styled-componentsの場合はコンポーネントごとにファイルを分けている場合は名前の競合は基本発生しない

これまでstyled-componentsで記述していたのですが、Gatsbyがemotionを採用していたこともあり使ってみました

styled-componentsで個人的に気になる点としてはちょっとスタイルを当てたいだけでもstyled.XXXを書かないといけな買った
emotionの場合はstyled-componentと同等の機能が直感的にインラインスタイルで書けるのが普通に便利です
フロントエンドにおける「関心の分離」は間違っていた

また、自分でコンポーネントのデザインを考えるのが面倒なのでSemantic UIを使っています

import React, { memo } from "react";

import { css } from "@emotion/core";
import { Button, Header, Icon, Input, Label } from "semantic-ui-react";

import { TaskContent, TaskContentType } from "@/components/TaskContent";
import { Color } from "@/graphql/generated";
import { useTaskSection } from "@/hooks/useTaskSection";

export type TaskType = {
  id: string;
  title: string;
  createdAt: number;
  updatedAt: number;
  categories: {
    id: string;
    name: string;
    color: Color;
  }[];
};

export const TaskSection = memo<{
  task: TaskType;
  taskContents: TaskContentType[];
  refetchTasks: () => Promise<unknown>;
}>(({ task, taskContents, refetchTasks }) => {
  const { isActive, tmpTitle, dispatch, handleAddTaskContent, handleDeleteTask } = useTaskSection({
    task,
    refetchTasks,
  });

  const { id, title, categories } = task;

  return (
    <div
      key={id}
      css={css`
        margin-top: 32px;
      `}
    >
      <div
        css={css`
          display: flex;
          justify-content: space-between;
          align-items: flex-start;
        `}
      >
        <div>
          <Header
            as="h2"
            css={css`
              &&& {
                margin: 0;
              }
            `}
          >
            {title}
          </Header>
          <div
            css={css`
              display: flex;
              margin-top: 4px;
            `}
          >
            {categories.map((category) => {
              return (
                <Label
                  key={category.id}
                  content={category.name}
                  color={category.color}
                  tag
                  css={css`
                    &&& {
                      margin-right: 12px;
                    }
                  `}
                />
              );
            })}
          </div>
        </div>
        <Button icon="trash alternate" color="red" onClick={handleDeleteTask} />
      </div>
      <div
        css={css`
          margin-left: 16px;
        `}
      >
        {taskContents.map((taskContent) => {
          return <TaskContent key={taskContent.id} taskContent={taskContent} refetchTasks={refetchTasks} />;
        })}
        {!isActive && (
          <Button
            basic
            color="blue"
            onClick={() => dispatch({ type: "setIsActive", payload: true })}
            css={css`
              &&& {
                margin-top: 8px;
              }
            `}
          >
            <Icon name="plus" />
            追加
          </Button>
        )}
        {isActive && (
          <div
            css={css`
              display: flex;
              align-items: center;
              margin-top: 8px;
              width: 100%;
              padding: 4px;
            `}
          >
            <form
              css={css`
                width: 100%;
              `}
            >
              <Input
                value={tmpTitle}
                action={{
                  content: "保存",
                  disabled: !tmpTitle,
                  onClick: (e: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
                    e.preventDefault();
                    handleAddTaskContent();
                  },
                }}
                onChange={(e, d) => dispatch({ type: "setTmpTitle", payload: d.value })}
                css={css`
                  &&& {
                    width: 100%;
                  }
                `}
              />
            </form>
            <Button
              icon="close"
              onClick={() => dispatch({ type: "setIsActive", payload: false })}
              css={css`
                &&& {
                  margin-left: 8px;
                }
              `}
            />
          </div>
        )}
      </div>
    </div>
  );
});

其他 (qí tā)

yarn工作区

    • yarn workspaceを使用すると1つのリポジトリで複数のnpmパッケージを管理(いわゆるmonorepo)することができます

 

    • 今回はpackeges/backendとpackeges/frontendで別々のアプリを起動して使えるようにしています

 

    ライブラリの依存関係も一元管理されるため、それぞれにpackage.jsonがあってもyarn.lockは1つになります
{
  "name": "nest-next-sample",
  "version": "1.0.0",
  "private": true,
  "workspaces": [
    "packages/*"
  ]
}

ESLint / Prettier / husky的中文释义为:

ESLint(代码质量检查工具)/ Prettier(代码格式化工具)/ husky(Git钩子工具)。

    • ESLintを設定しておけば問題のあるコードを事前に検知&自動修正することができ開発体験がグッと上がります

 

    • さらにtypescript-eslintでより強固な静的解析がされるようになり、prettierも使えばコードもより見やすく整形できます

 

    • また、eslint-plugin-importを使用するとimportの記述の順序もきれいに直すことができます

 

    自分は次のようにrecommendedの設定をほぼそのまま使用しています
{
  "ignorePatterns": ["node_modules", ".next", "dist"],
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "plugin:@typescript-eslint/recommended",
    "prettier/@typescript-eslint",
    "plugin:prettier/recommended",
    "plugin:import/errors",
    "plugin:import/warnings",
    "plugin:import/typescript"
  ],
  "plugins": ["react", "react-hooks", "@typescript-eslint", "prettier", "emotion"],
  "env": {
    "node": true,
    "browser": true,
    "jest": true,
    "es6": true
  },
  "rules": {
    "react/display-name": ["off"],
    "react/prop-types": ["error", { "skipUndeclared": true }],
    "@typescript-eslint/no-unused-vars": ["warn", { "args": "none" }],
    "@typescript-eslint/explicit-function-return-type": ["off"],
    "@typescript-eslint/explicit-module-boundary-types": ["off"],
    "sort-imports": ["error", { "ignoreDeclarationSort": true }],
    "import/order": [
      "error",
      {
        "newlines-between": "always",
        "alphabetize": { "order": "asc" },
        "pathGroups": [
          {
            "pattern": "react",
            "group": "external",
            "position": "before"
          }
        ],
        "pathGroupsExcludedImportTypes": ["builtin"]
      }
    ],
    "prettier/prettier": [
      "error",
      {
        "semi": true,
        "printWidth": 120,
        "trailingComma": "all"
      }
    ]
  },
  "parser": "@typescript-eslint/parser",
  "settings": {
    "react": {
      "version": "detect"
    },
    "import/resolver": {
      "typescript": {
        "project": "packages/*/tsconfig.json"
      }
    }
  }
}

    さらにhuskyを利用することで git commit 時にlintを実行してくれるようにできます

Docker / Docker Compose → Docker / Docker Compose

    開発環境を簡単に用意できるようにするためにDockerfileとdocker-composeも整備してみました
FROM node:14-alpine

WORKDIR /app

COPY . .

RUN yarn install
    • docker-compose3.4から使えるようになったExtension FieldsとYAMLのAnchorとAliasを使うことでx-dev-baseの記述をmigrate/backend/frontend`コンテナで省略しています

docker-compose up時に自動的にマイグレーションを実行したかったため、migrateコンテナを用意しました
ローカルでyarn installしなくてもnode_modulesを使えるようにvolumesに/app/node_modulesを記述しています

version: "3"

x-dev-base: &dev-base
  build: .
  environment:
    DB_HOST: mysql
    DB_PORT: 3306
    DB_USERNAME: docker
    DB_PASSWORD: docker
    DB_NAME: nest_next_sample
    SERVER_APOLLO_URI: http://backend:3300
    PUBLIC_APOLLO_URI: http://localhost:3300
  volumes:
    - .:/app
    - /app/node_modules
    - /app/packages/backend/node_modules
    - /app/packages/frontend/node_modules

services:

  mysql:
    image: mysql:5.7
    command: mysqld
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_USER: docker
      MYSQL_PASSWORD: docker
      MYSQL_DATABASE: nest_next_sample
      TZ: 'Asia/Tokyo'
    ports:
      - 4306:3306
    volumes:
      - ./docker/db/data:/var/lib/mysql
      - ./docker/db/my.cnf:/etc/mysql/conf.d/my.cnf

  migrate:
    << : *dev-base
    working_dir: /app/packages/backend
    command: yarn migrate:run
    depends_on:
      - mysql

  backend:
    << : *dev-base
    working_dir: /app/packages/backend
    command: yarn start:dev
    tty: true
    depends_on:
      - mysql
      - migrate
    ports:
      - "3300:3300"

  frontend:
    << : *dev-base
    working_dir: /app/packages/frontend
    command: yarn dev
    tty: true
    depends_on:
      - backend
    ports:
      - "3000:3000"
    ちなみにSSRとCSRでホストのURLの記述を切り替える必要があるので要注意です
SERVER_APOLLO_URI: http://backend:3300
PUBLIC_APOLLO_URI: http://localhost:3300

Vercel => 环栖

    • FrontendはNext.js公式からおすすめされている通りVercelを使ってホスティングするとGitHubと連携するだけで簡単にホスティングができます

 

    • Vercelを使えばSSRが必要な場合もサーバーサイド用のホスティングを別途用意する必要がありません

 

    また、PRを作った時にPreviewとしてPRの内容を反映したホスティングも自動でしてくれます
vercel.com_mikan3rd_commitly-next-web(Laptop with HiDPI screen).png

GCP(CloudSQL / Cloud Run / Cloud Build)

    • Backendについては今回はGCPで用意してみました

 

    • DBはフルマネージドのCloudSQL for MySQLを使用したため、セットアップもGUIで簡単にできました

 

    さらにCloud Buildで次のように設定することで、CloudSQLにつないでmigrationを実行し、Cloud Runにコンテナへのデプロイする処理を自動化しました
steps:

  - id: 'Build: docker image'
    name: 'gcr.io/cloud-builders/docker'
    args: [
      'build', '.',
      '-f', 'Dockerfile.prod',
      '-t', 'gcr.io/$PROJECT_ID/build:$COMMIT_SHA',
    ]

  - id: 'Run: cloudsql-proxy'
    name: 'gcr.io/cloudsql-docker/gce-proxy:1.16'
    args: [
      '/cloud_sql_proxy',
      '-dir=/cloudsql',
      '-instances=${_CLOUD_SQL_CONNECTION_NAME}'
    ]
    volumes:
      - name: cloudsql
        path: /cloudsql

  - id: 'Migrate: database'
    name: 'gcr.io/cloud-builders/docker'
    args: [
      'run',
      '-v', 'cloudsql:/cloudsql',
      '-w', '/app/packages/backend',
      '-e', 'DB_SOCKET_PATH=/cloudsql/${_CLOUD_SQL_CONNECTION_NAME}',
      '-e', 'DB_HOST=/cloudsql/${_CLOUD_SQL_CONNECTION_NAME}',
      '-e', 'DB_PORT=${_DB_PORT}',
      '-e', 'DB_USERNAME=${_DB_USERNAME}',
      '-e', 'DB_PASSWORD=${_DB_PASSWORD}',
      '-e', 'DB_NAME=${_DB_NAME}',
      'gcr.io/$PROJECT_ID/build:$COMMIT_SHA',
      'yarn', 'migrate:run'
    ]
    volumes:
      - name: cloudsql
        path: /cloudsql
    waitFor:
      - 'Build: docker image'

  - id: 'Kill: cloudsql-proxy'
    name: 'gcr.io/cloud-builders/docker'
    waitFor:
      - 'Migrate: database'
    entrypoint: 'bash'
    args: ['-c', 'docker ps -q --filter ancestor="gcr.io/cloudsql-docker/gce-proxy:1.16"|xargs docker stop']

  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/$PROJECT_ID/build:$COMMIT_SHA']
    waitFor:
      - 'Migrate: database'

  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args: [
      'run', 'deploy', '$_CLOUD_RUN_SERVICE_NAME',
      '--image', 'gcr.io/$PROJECT_ID/build:$COMMIT_SHA',
      '--region', 'asia-northeast1',
      '--platform', 'managed',
      '--memory', '128Mi',
      '--set-cloudsql-instances', '${_CLOUD_SQL_CONNECTION_NAME}',
      '--allow-unauthenticated',
      '--set-env-vars', '
        DB_SOCKET_PATH=/cloudsql/${_CLOUD_SQL_CONNECTION_NAME},
        DB_HOST=/cloudsql/${_CLOUD_SQL_CONNECTION_NAME},
        DB_PORT=${_DB_PORT},
        DB_USERNAME=${_DB_USERNAME},
        DB_PASSWORD=${_DB_PASSWORD},
        DB_NAME=${_DB_NAME}'
    ]

images:
  - gcr.io/$PROJECT_ID/build:$COMMIT_SHA
    ちなみにCloud SQLは起動したままにすると課金されてしまうので、開発用で利用する場合以外は停止させています

总结

    • 実際にはいろいろとつまづきポイントがありましたが、TypeScriptでバックエンドとフロントエンドの両方の開発ができました

 

    • あくまで一例ですが参考にしてもらえると嬉しいです

 

    https://github.com/mikan3rd/nest-next-sample
广告
将在 10 秒后关闭
bannerAds