LoginSignup
1
3

More than 3 years have passed since last update.

モダンなwebシステムを勉強したい backend編

Last updated at Posted at 2020-04-19

目次

1.モダンなwebシステムを勉強したい 準備編
2.モダンなwebシステムを勉強したい backend編 (本記事)
3.モダンなwebシステムを勉強したい frontend編

環境作成

環境はこちらへ一応公開 予定

コンテナの準備

今回使用したDockerfile
FROM node:lts-buster-slim

RUN npm -g install \
        @types/express \
        @types/node \
        @typescript-eslint/eslint-plugin \
        @typescript-eslint/parser \
        apollo-server-express \
        cors \
        eslint \
        eslint-config-prettier \
        eslint-plugin-prettier \
        express \
        express-graphql \
        graphql-iso-date \
        graphql@14.4.1 \
        mysql2 \
        nodemon \
        prettier \
        reflect-metadata \
        ts-node \
        tsconfig-paths \
        typeorm \
        typescript \
        && npm cache clean --force

WORKDIR /app

docker-compose.yaml でちょっとひと手間。
environmentでLOCALUIDと、LOCALGID を設定しておくことで、
backendコンテナ内のnodeユーザのuid・gidを同じ値に設定するようにしています。

docker-compose.yaml
version: "3"

services:
  #proxy:
  #  image: nginx:stable

  #frontend:
  #  build:
  #    context: ./frontend
  #    dockerfile: ./Dockerfile
  #  environment:
  #    NODE_PATH: /usr/local/lib/node_modules

  backend:
    image: kudoshunsuke/booking-system-backend
    build:
      context: ./backend
      dockerfile: ./Dockerfile
    environment:
      NODE_PATH: /usr/local/lib/node_modules
      LOCALUID: 1000
      LOCALGID: 1000
      MYSQL_DATABASE: "booking_prod"
      MYSQL_USER: "booking"
      MYSQL_PASSWORD: "booking"
    volumes:
      - ./backend/entrypoint.sh:/entrypoint.sh:ro
      - ./backend/app:/app
      - ./backend/.bashrc:/home/node/.bashrc
        #- /etc/group:/etc/group:ro
        #- /etc/passwd:/etc/passwd:ro
    entrypoint: "/entrypoint.sh"
    command: "bash"
    tty: true
    ports:
      - "4000:4000"

  db:
    image: mariadb:10
    environment:
      MYSQL_ROOT_PASSWORD: "maria"
      MYSQL_DATABASE: "booking_prod"
      MYSQL_USER: "booking"
      MYSQL_PASSWORD: "booking"
      MYSQL_ALLOW_EMPTY_PASSWORD: 0
      # MYSQL_RANDOM_ROOT_PASSWORD:

    command: "mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci"
    volumes:
      - mysql:/var/lib/mysql
    restart: always
    ports:
      - "13306:3306"

volumes:
  mysql:
    driver: "local"

これの何が嬉しいかというと、volumeマウントしたディレクトリをnodeユーザが変更・作成したとしても
ホストユーザ側で編集できるという点。
ガリガリコード書いたり自動生成したりと、パーミッションはめんどくさいので今はこの方法で
落ち着いている感じです。

一応、記事にしたので、興味がある方は参照ください。
docker-composeで起動したプロセスのUID、GIDをホストユーザと同じにする

backendを作ってく

ベースを作成

以下のコマンドを実行してベースを作成。

$ typeorm init

実行すると以下のようなファイル・ディレクトリが生成される。

$ tree
.
├── README.md
├── ormconfig.json
├── package.json
├── src
│   ├── entity
│   │   └── User.ts
│   ├── index.ts
│   └── migration
└── tsconfig.json

3 directories, 6 files

こっからあとはゴリゴリ作っていくが、引っかかった点があったので、その辺を補足。

引っかかった点

typeormコマンドがうまく動かない

typeormコマンドを実行してentityファイル作ったり、migrationファイル作ったりすると思います。
ベースを作った状態だと、User.tsファイルが出来ているので、これのマイグレーションファイルを作成しようとすると
以下のようなエラーが表示されます。

$ typeorm migration:generate -n Initialize
Error during migration generation:
/app/tmp/src/entity/User.ts:1
import {Entity, PrimaryGeneratedColumn, Column} from "typeorm";
^^^^^^

SyntaxError: Cannot use import statement outside a module
    at wrapSafe (internal/modules/cjs/loader.js:1070:16)
    at Module._compile (internal/modules/cjs/loader.js:1120:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1176:10)
    at Module.load (internal/modules/cjs/loader.js:1000:32)
    at Function.Module._load (internal/modules/cjs/loader.js:899:14)
    at Module.require (internal/modules/cjs/loader.js:1042:19)
    at require (internal/modules/cjs/helpers.js:77:18)
    at Function.PlatformTools.load (/usr/local/lib/node_modules/typeorm/platform/PlatformTools.js:114:28)
    at /usr/local/lib/node_modules/typeorm/util/DirectoryExportedClassesLoader.js:39:69
    at Array.map (<anonymous>)
       requ
requ

よく理解できていないんですが、ここの記事から推測するに、typeormを直接実行すると、typeormのcliがjsファイルの扱いをするから typeormのcliを実行するときにはnodeコマンドで実行するのではなく、ts-nodeで実行すればいいという風に思っています。
ですので、自分はpackage.jsonに以下のように定義しています。

"script": {
-  "start": "ts-node src/index.ts"
+  "start": "ts-node src/index.ts",
+  "typeorm": "ts-node -r tsconfig-paths/register ${NODE_PATH}/typeorm/cli.js"
}

このように定義することで、typeormコマンドが意図したとおりに動きます。

$ num run typeorm migration:generate -- -n Initialize

> new-typeorm-project@0.0.1 typeorm /app/tmp
> ts-node -r tsconfig-paths/register ${NODE_PATH}/typeorm/cli.js "migration:generate" "-n" "Initialize"

Missing baseUrl in compilerOptions. tsconfig-paths will be skipped
Migration /app/tmp/src/migration/1587297512810-Initialize.ts has been generated successfully.

ここでのハマりポイントとしては、自分は無知だったのですが、「-」ついた文字をオプションではなく引数として認識させるため、migration:generate のあとに「--」が必要ということでした。

自分の中では、package.jsonに書いたscriptは単なる文字の置き換えで、コマンドは以下のようになるもんだと思っていました。

npm run typeorm migtaion:generate -n Initialize
↓
"ts-node -r tsconfig-paths/register ${NODE_PATH}/typeorm/cli.js"  -n Initialize

ところが、「--」をつけないの「-n」すらnpm runに対してのオプションとして扱われるようでうまく変換されていませんでした。
こちらの記事を参照し理解しました。ありがとうございます。

マイグレーションファイルがなんかちょっと違う

typeorm はエンティティを書いておけばマイグレーションファイルを作ってくれるという素敵な機能があります。
例えば、ベースのままで作ると以下になります。

$ cat src/migration/1587297512810-Initialize.ts
import {MigrationInterface, QueryRunner} from "typeorm";

export class Initialize1587297512810 implements MigrationInterface {
    name = 'Initialize1587297512810'

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query("CREATE TABLE `user` (`id` int NOT NULL AUTO_INCREMENT, `firstName` varchar(255) NOT NULL, `lastName` varchar(255) NOT NULL, `age` int NOT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB", undefined);
    }

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

}

このエンティティに以下のようなカラムを追加。

+      @Column({
+        type: "date",
+        nullable: false,
+        default: () => "CURRENT_TIMESTAMP",
+        comment: ""
+      })

マイグレションファイルがどうなるかというと以下のようになる。
多分正しい。

import {MigrationInterface, QueryRunner} from "typeorm";

export class Initialize1587299549362 implements MigrationInterface {
    name = 'Initialize1587299549362'

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query("ALTER TABLE `user` ADD `created_at` date NOT NULL DEFAULT CURRENT_TIMESTAMP", undefined);
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query("ALTER TABLE `user` DROP COLUMN `created_at`", undefined);
    }

}

マイグレーションを実行すると、ちゃんとcreated_atカラムが意図通りに追加されています。
ここで、entityを何も変更せずにマイグレーションファイルを作成すると、変更がないのに出来てしまう。

内容としては、先程追加したcreated_atカラムに対して同じ内容で変更しようとしている。


import {MigrationInterface, QueryRunner} from "typeorm";

export class Initialize1587299583332 implements MigrationInterface {
    name = 'Initialize1587299583332'

    public async up(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query("ALTER TABLE `user` CHANGE `created_at` `created_at` date NOT NULL DEFAULT CURRENT_TIMESTAMP", undefined);
    }

    public async down(queryRunner: QueryRunner): Promise<void> {
        await queryRunner.query("ALTER TABLE `user` CHANGE `created_at` `created_at` date NOT NULL DEFAULT CURRENT_TIMESTAMP()", undefined);
    }

}

これについては、どのように対応するのが正なのか不明で、とりあえず怖いので以下のような手順を踏むようにする。
こちらの記事が大変勉強になりました。
TypeORMで本番運用を見据えたマイグレーション

  • entity はsynchronizeを falseに設定 ※ デフォルトはtrue
  • マイグレーションファイルは npm run typeorm migration:create -- -n xxxx と最初に何もしないファイルを作成
  • 生成されたマイグレーションファイルに手動でマイグレーションの内容を追記

実害はないのかもしれないけど、やっぱり運用中のDBのカラムに対して不要なALTER TABLEとかは避けれるものなら避けたい。
となると、多分これしか方法ないのかも。

あとは、typeormに対してPR出して修正するとか?
できることなら、その辺も今後チャレンジしていきたい。

DBにアクセス出来ない

entityも作成し、マイグレーションも行い、schema・resolbersも設定してplaygroundでstaticに持っているデータアクセスができるとことまで確認取れたので、次はDBアクセスだと思ってリポジトリ経由でデータを取得しようとしても ConnectionNotFoundError がでて接続出来ない。
マイグレーションもできてDBにつながっているのになんで出来ないんだと、かなり悩んだ。
自分の中では、てっきりormconfig.json に書いておけばなんかうまいことつながってくれるもんだと思っていたけど、最初にcreateConnection で接続してあげないといけないということがわかった。
DBの設定をormconfig.json とソース上の2箇所で持つことになるので、この辺何かいいアイデアは無いのか知りたいです。

次にここを突破したと思ったらRepositoryNotFoundError がでる。
これはほんとにわからなくての悩んでいたときにふとormconfig.json の中身をみて気がついた。

createConecctionにわたすコンフィルは以下のように設定していた。

export const DBConfig: ConnectionOptions = {
  type: "mysql",
  host: "db",
  port: 3306,
  username: "booking",
  password: "booking",
  database: "booking_prod"
};

ormconfig.jsonは以下。

{
   "type": "mysql",
   "host": "db",
   "port": 3306,
   "username": "booking",
   "password": "booking",
   "database": "booking_prod",
   "synchronize": false,
   "logging": false,
   "entities": [
      "src/entity/**/*.ts"
   ],
   "migrations": [
      "src/migration/**/*.ts"
   ],
   "subscribers": [
      "src/subscriber/**/*.ts"
   ],
   "cli": {
      "entitiesDir": "src/entity",
      "migrationsDir": "src/migration",
      "subscribersDir": "src/subscriber"
   }
}

もしかして「entities」が無いから出来ないのか?と思い以下のように修正したところあっさり接続。

export const DBConfig: ConnectionOptions = {
  type: "mysql",
  host: "db",
  port: 3306,
  username: "booking",
  password: "booking",
-  database: "booking_prod"
+  database: "booking_prod",
+  entities: [
+      "src/entity/**/*.ts"
+  ]
};

ホント、公式ちゃんと見ましょうってことでした。。。

relation作っているのにデータが取れない

ユーザと予約情報は以下のような構造を登録としている。

image.png

entityも以下のように設定。

User.ts
import {Entity, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Column, OneToMany} from "typeorm";
import {Bookinginfo} from "./Bookinginfo";

@Entity({synchronize: false})
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: "varchar",
    length: 20,
    nullable: false,
    comment: ""
  })
  firstname: string;

  @Column({
    type: "varchar",
    length: 20,
    nullable: false,
    comment: ""
  })
  lastname: string;

  @Column({
    type: "boolean",
    nullable: false,
    default: "0",
    comment: ""
  })
  delflg: boolean;

  @Column({
    type: "date",
    nullable: false,
    default: () => "CURDATE()",
    comment: ""
  })
  created_at: Date;

  @Column({
    type: "date",
    nullable: false,
    default: () => "CURDATE()",
    comment: ""
  })
  updated_at: Date;

  @OneToMany(
    type => Bookinginfo,
    bookinginfolist => bookinginfolist.user
  )
  bookinginfolist: Bookinginfo[];
}
Bookinginfo.ts
import {Entity, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, Column, JoinColumn, ManyToOne} from "typeorm";
import {User} from "./User";
import {Facility} from "./Facility";

@Entity({synchronize: false})
export class Bookinginfo {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: "varchar",
    length: 20,
    nullable: false,
    comment: ""
  })
  title: string;

  @Column({
    type: "datetime",
    nullable: false,
    comment: ""
  })
  start_datetime: Date;

  @Column({
    type: "datetime",
    nullable: false,
    comment: ""
  })
  end_datetime: Date;

  @Column({
    type: "boolean",
    nullable: false,
    default: "0",
    comment: ""
  })
  delflg: boolean;

  @Column({
    nullable: false,
    default: () => "CURDATE()",
    comment: ""
  })
  created_at: Date;

  @Column({
    nullable: false,
    default: () => "CURDATE()",
    comment: ""
  })
  updated_at: Date;

  @ManyToOne(
    type => User,
    user => user.bookinginfolist
  )
  @JoinColumn()
  user: User;

  @ManyToOne(
    type => Facility
  )

  @JoinColumn()
  facility: Facility;
}

resolvers 内で指定ユーザのBookinginfo を取得して返そうと思ってもnullが返る。
コードは以下。


    bookinginfo_by_user_id: async(_: any, args: any) => {
      const userRepository = getRepository(User);
      const user = await userRepository.findOne({where: {id: args.user_id}});
      return user.bookinginfolist;
    },

えっ?違うの?と、また色々調べていると、relation張ったデータも取りたいならちゃんと指定しないといけないらしい。


    bookinginfo_by_user_id: async(_: any, args: any) => {
      const userRepository = getRepository(User);
      const user = await userRepository.findOne({ relations: ["bookinginfolist"], where: {id: args.user_id}});
      return user;    },

これも公式案件ですね。。。ゴメンナサイゴメンナサイ。。。

とりあえずここまで来てなんとかbackendでどのように実装すれば良いかが理解出来た気がする。
あとはfrontend側の実装を行いながらチマチマ修正していこうかと思います。

長くなりましたが、ここまでお付き合いしていただきありがとうございます。

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3