Aurora Data API を TypeScript + typeorm から 使う物語

# 概要
Rest API を経由してツイッターのリツイート情報を取得し、
Aurora DB に格納するサンプルツール `twitterer` を、以下の構成で実装した。

その過程で 5000兆個くらいある落とし穴を踏み抜いたので倒し方を書こうと思ったが、
1日経ったら過程をほとんど忘れたのでちゃんと動く結果を主に書き残す。

- AWS
    - Aurora Serverless (MySQL5.6)
    - ServerlessFramework
    - Lambda
- Node.js
- TypeScript
- [express](https://github.com/expressjs/express)
- [typeorm](https://github.com/typeorm/typeorm)
    - [typeorm-aurora-data-api-driver](https://github.com/ArsenyYankovsky/typeorm-aurora-data-api-driver)

## 前提と関連記事

- [Aurora Serverless DB を作って Node.js(TS) から使う](https://qiita.com/tokiha0s/private/6b92c21c814ba70a1c78) を前提としています。
- [Aurora Serverless MySQL(5.6) で日本語データを扱えるようにする](https://qiita.com/tokiha0s/items/beda4be4f6815e198c76) も設定しています。
- [Transactionの扱い、注意点](https://qiita.com/tokicat/items/a2e131b3603533eacfe0)

## typeormとは
TypeScript の class として Entity を定義すると、
自分でSQL書かなくても一通りなんでもできる OR Mapper だよ。

[TypeORMはNode.js開発のスタンダードになるか？](https://qiita.com/tejitak/items/b6965380afd600db6513)
こちらの紹介記事がわかりやすいと思いました。
べんりだね！

# 環境

- `Aurora エンジン` Aurora (MySQL)-5.6.10a
- `AWS SDK` 2.590.0
- `Node.js` v10.15.0
- `npm` 6.6.0
- `typeorm` 0.2.21
- `typeorm-aurora-data-api-driver` 1.1.8
- `Serverless Framework`

# プロジェクトの構成と解説

## 主要な構成

```
twitterer/
├── serverless.yml
├── ormconfig.js    // typeormの設定ファイル
├── package.json
├── webpack.config.js
|
├── twitterer-handler.ts
└── src/
    ├── twitterer-express.ts
    ├── twitterer-service.ts
    ├── entities/
    |   └── twitterer-types.ts
    ├── helpers/
    |   └── typeorm-helper.ts
    └── db/    // typeormにより出力されたスクリプトが蓄積される
        ├── migrations/
        └── subscribers/
```

## serverless.yml
基本的には以下でしたときのまま。

```sh
serverless create --template aws-nodejs-typescript
```

### ポリシーの設定
Lambda Role から Data API を使うために追加したポリシー。
必要なポリシーセットがわからず苦労していた

```yaml:serverless.yml
provider:
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "secretsmanager:GetSecretValue"
        - "secretsmanager:PutResourcePolicy"
        - "secretsmanager:PutSecretValue"
        - "secretsmanager:DeleteSecret"
        - "secretsmanager:DescribeSecret"
        - "secretsmanager:TagResource"
      Resource: "arn:aws:secretsmanager:*:*:secret:*"
    - Effect: "Allow"
      Action:
        - "dbqms:CreateFavoriteQuery"
        - "dbqms:DescribeFavoriteQueries"
        - "dbqms:UpdateFavoriteQuery"
        - "dbqms:DeleteFavoriteQueries"
        - "dbqms:GetQueryString"
        - "dbqms:CreateQueryHistory"
        - "dbqms:DescribeQueryHistory"
        - "dbqms:UpdateQueryHistory"
        - "dbqms:DeleteQueryHistory"
        - "rds-data:ExecuteSql"
        - "rds-data:ExecuteStatement"
        - "rds-data:BatchExecuteStatement"
        - "rds-data:BeginTransaction"
        - "rds-data:CommitTransaction"
        - "rds-data:RollbackTransaction"
        - "secretsmanager:CreateSecret"
        - "secretsmanager:ListSecrets"
        - "secretsmanager:GetRandomPassword"
        - "tag:GetResources"
      Resource: "arn:aws:rds:ap-northeast-1:XXXXXXXXXXXX:cluster:XXXXXXXXXXXX"
```

### webpack
そのまんまだと、なぜか `typeorm-aurora-data-api-driver` がpackされなくてハマったので追記

```
custom:
  webpack:
    webpackConfig: ./webpack.config.js
    includeModules:
      packagePath: package.json
      forceInclude:
        - typeorm-aurora-data-api-driver
```

### handler
express で受けるので、以下のように書く

```yaml:serverless.yml
functions:
  twitterer:
    handler: twitterer-handler.v1
    events:
      - http: ANY /
      - http: "ANY /{proxy+}"
```

## package.json

### scripts

```json:package.json
  "scripts": {
    "debug": "$(npm bin)/ts-node-dev --clear --respawn ./twitterer-handler.ts",
    "migration:generate": "ts-node $(npm bin)/typeorm migration:generate -n migration",
    "migration:run": "ts-node $(npm bin)/typeorm migration:run "
  },
```

### dependencies

```json:package.json
  "dependencies": {
    "aws-sdk": "^2.590.0",
    "body-parser": "^1.19.0", // expressで意図したrequest bodyを受け取るため
    "cors": "^2.8.5", // web viewから蹴ることも想定して
    "express": "^4.17.1", 
    "serverless-http": "^2.3.0",
    "source-map-support": "^0.5.10",
    "twitter": "^1.7.1",
    "typeorm": "^0.2.21",
    "typeorm-aurora-data-api-driver": "^1.1.8" // typeormからData APIを使える
  },
  "devDependencies": {
    "@types/aws-lambda": "^8.10.17",
    "@types/express": "^4.17.2",
    "@types/node": "^10.12.18",
    "@types/twitter": "^1.7.0",
    "@typescript-eslint/eslint-plugin": "^2.11.0",
    "@typescript-eslint/parser": "^2.11.0",
    "copy-webpack-plugin": "^5.1.1", // ormconfig.js をpackするために使う
    "eslint": "^6.7.2",
    "eslint-config-prettier": "^6.7.0",
    "eslint-plugin-prettier": "^3.1.1",
    "fork-ts-checker-webpack-plugin": "^3.0.1",
    "prettier": "^1.19.1",
    "serverless-webpack": "^5.2.0",
    "ts-loader": "^5.3.3",
    "ts-node-dev": "^1.0.0-pre.44",
    "typescript": "^3.2.4",
    "webpack": "^4.29.0",
    "webpack-node-externals": "^1.7.2"
  }
```

## webpack.config.js
そのままだと、 `ormconfig.js` がpackされなくてハマったので、以下のように追記

```js:webpack.config.js
// import 部分
const CopyWebpackPlugin = require("copy-webpack-plugin");

// plugins 部分
  plugins: [
    new CopyWebpackPlugin(["ormconfig.js"]),
  ],
```

## ormconfig.js
```js:ormconfig.js
module.exports = {
  type: "aurora-data-api",

  region: "ap-northeast-1",
  // Aurora Serverless DB の arn
  resourceArn: "arn:aws:rds:ap-northeast-1:XXXXXXXXXXXX:cluster:XXXXXXXXXXXX",
  // DB にアクセスするために作った Secret の arn
  secretArn: "arn:aws:secretsmanager:ap-northeast-1:XXXXXXXXXXXX:secret:XXXXXXXXXXXX",
  // デフォルトでつなぐDB(schema)
  database: "twitterer",

  entities: [__dirname + "/src/entities/**/*.ts"],
  migrations: [__dirname + "/src/db/migrations/**/*.ts"],
  subscribers: [__dirname + "/src/db/subscribers/**/*.ts"],
  cli: {
    entitiesDir: "src/entities/",
    migrationsDir: "src/db/migrations/",
    subscribersDir: "src/db/subscribers/",
  },
};

```
## twitterer-handler.ts

```ts:twitterer-handler.ts
import "source-map-support/register";
import { TwittererExpress } from "./src/twitterer-express";

const serverless = require("serverless-http");

export const v1 = serverless(TwittererExpress);
```

## twitterer-express.ts
公開用に加工してます、エラーハンドリングとか適当なので許してちょ。

```ts:twitterer-express.ts
import bodyParser from "body-parser";
import cors from "cors";
import express, { Request, Router } from "express";
import { NextFunction, Response } from "express-serve-static-core";
import { RetweetEntity } from "./entities/twitterer-types";
import { TwittererService } from "./twitterer-service";

/**
 * initialize
 */
const { app, r } = (() => {
  TwittererService.init().then();

  const app = express();
  const r: Router = Router();

  app.use(cors());
  app.use(bodyParser.json());
  app.use(r);

  // ローカル実行用
  app.listen("8080", () => console.log(`Start listening on port 8080`));

  return { app, r };
})();
export const TwittererExpress = app;

/**
 * define routes
 */
const P = "/twitterer/v1";
r.post(`${P}/tweets/:tweetId/retweets/clawl`, clawlRetweet);
r.get(`${P}/tweets/:tweetId/retweets`, getRetweets);

/*****************************************************************/

async function clawlRetweet(req: Request, res: Response, next: NextFunction) {
  const { tweetId } = req.params;

  try {
    const retweets = await TwittererService.clawlRetweets(tweetId);
    res.status(200).send(retweets);
    next();
  } catch (e) {
    console.error(e);
    res.status(424 /* failed dependency */).send(JSON.stringify(e));
  }
}

async function getRetweets(req: Request, res: Response, next: NextFunction) {
  const { tweetId } = req.params;

  const retweets = await RetweetEntity.find({ where: { tweetId } });

  const resRetweets = retweets.map(e => ({
    retweetId: e.retweetId,
    userScreenName: e.userScreenName,
    userName: e.userName,
  }));

  res.status(200).send({
    count: retweets.length,
    retweets: resRetweets,
  });
  next();
}
```

## twitterer-service.ts
createConnection() で利用するすべての entities を指定しないと、
ローカルでは動いてもデプロイ後動かなくなる

```ts:twitterer-service.ts
import Twitter from "twitter";
import { BaseEntity, Connection, createConnection, getConnection, getConnectionOptions } from "typeorm";
import { TypeormHelper } from "./typeorm-helper";
import { RetweetEntity } from "./entities/twitterer-types";

export class TwittererService {
  /**
   * init aurora connnection
   */
  static async init() {
    // 後述の
    TypeormHelper.patchBug(Connection);

    const connectionOptions = await getConnectionOptions();
    const conn = await createConnection({
      ...connectionOptions,
      // 利用するEntityをこうして書かないと、デプロイ後動かない。
      // (entityがrepositoryに見つかりませんよ、みたいなエラー出る)
      entities: [RetweetEntity],
    });
    BaseEntity.useConnection(conn);
  }

  static get client(): Twitter {
    return new Twitter({
      consumer_key: "XXXXXXXXXX",
      consumer_secret: "XXXXXXXXXX",
      access_token_key: "XXXXXXXXXX",
      access_token_secret: "XXXXXXXXXX",
    });
  }

  static async clawlRetweets(tweetId: string): Promise<RetweetEntity[]> {
    const clawledAt = new Date();

    // 本当は保持していたカーソルの読み込みとかいろいろやってる

    const twitterRes = await this.client.get(`statuses/retweets/${tweetId}.json`, {});
    const retweets: RetweetEntity[] = [];

    for (const e of twitterRes as any) {
      const retweet = new RetweetEntity();

      retweet.tweetId = e.retweeted_status.id_str;
      retweet.retweetId = e.id_str;
      retweet.userId = e.user.id_str;
      retweet.userName = e.user.name;
      retweet.userScreenName = e.user.screen_name;
      retweet.retweetedAt = new Date(e.created_at);
      retweet.clawledAt = clawledAt;
      retweet.rawJson = e;

      retweets.push(retweet);
    }

    await getConnection().transaction(async e => {
      await e.save(retweets);
      // 本当はカーソルの更新とかいろいろやってる
    });

    return retweets;
  }
}
```

## typeorm-helper.ts
**何故かデプロイ後動かない** というエラーに悩まされた結果たどり着いたソリューション。
めっっっっちゃここでハマった。二度とハマりたくない
`patch-package` を使おうとしたけどwebpackとの組み合わせに難航したのでモンキーパッチ

[ありがとうsdebaun](https://github.com/typeorm/typeorm/issues/3427#issuecomment-464589341)

```ts:typeorm-helper.ts
import { EntityMetadata, EntitySchema } from "typeorm";

export class TypeormHelper {
  /**
   * デプロイすると動かなくなる糞バグのモンキーパッチ
   * https://github.com/typeorm/typeorm/issues/3427
   */
  static patchBug(typeormConnection: any) {
    // this is a copypasta of the existing typeorm Connection method
    // with one line changed
    // @ts-ignore
    typeormConnection.prototype.findMetadata = function(
      target: Function | EntitySchema<any> | string
    ): EntityMetadata | undefined {
      // @ts-ignore
      return this.entityMetadatas.find(metadata => {
        // @ts-ignore
        if (metadata.target.name === target.name) {
          // in latest typeorm it is metadata.target === target
          return true;
        }
        if (target instanceof EntitySchema) {
          return metadata.name === target.options.name;
        }
        if (typeof target === "string") {
          if (target.indexOf(".") !== -1) {
            return metadata.tablePath === target;
          } else {
            return metadata.name === target || metadata.tableName === target;
          }
        }

        return false;
      });
    };
  }
}
```

## twitterer-types.ts
typeormではlength指定なしの文字列カラムは varchar(255) となる。
[こちらの記事](https://qiita.com/tokiha0s/items/beda4be4f6815e198c76)でも触れたように、このままではキーカラム767bytes制限に阻まれて使うことができない。

対策には、

1. キーカラムの長さを短くするか、
2. 上記記事の内容と合わせて テーブルに `ROW_FORMAT=DYNAMIC` を指定する必要がある。

前者はめんどかったので後者で実現しようとしたが、typeorm には ROW_FORMAT を指定できない、なんてことだ
ということでSQLインジェクションをつかって無理やり解決

```ts:twitterer-types.ts
import { BaseEntity, Column, Entity, PrimaryColumn } from "typeorm";

// typeormには ROW_FORMAT の指定オプションが無いため、 SQL インジェクションを使うクソリューション
@Entity({ name: "retweet", engine: "InnoDB ROW_FORMAT=DYNAMIC" })
export class RetweetEntity extends BaseEntity {
  @PrimaryColumn() tweetId: string;
  @PrimaryColumn() retweetId: string;

  @Column() userId: string;
  @Column() userName: string;
  @Column() userScreenName: string;
  @Column() retweetedAt: Date;
  @Column() clawledAt: Date;
  // AuroraServerless は MySQL5.6 しか使えないため、実際はTEXT型になる
  // (JSON は MySQL5.7から)
  @Column("simple-json") rawJson: any;
}
```

# マイグレーションSQLの生成と実行
```sh
npm run migration:generate
npm run migration:run
```

## generateでできるスクリプト
現状のDBの状態とEntityの定義を比較し、差分を埋めるために必要なSQLを作ってくれる。
テーブルのない状態で実行するとCREATE文が生成される
あとはこれをgitで管理して環境ごとに適用したりして便利につかうわけですね

さっきSQLインジェクションした `ROW_FORMAT=DYNAMIC` もしっかり入ってるね

```ts
import {MigrationInterface, QueryRunner} from "typeorm";

export class migration1576639222255 implements MigrationInterface {
    name = 'migration1576639222255'

    public async up(queryRunner: QueryRunner): Promise<any> {
        await queryRunner.query("CREATE TABLE `retweet` (`tweetId` varchar(255) NOT NULL, `retweetId` varchar(255) NOT NULL, `userId` varchar(255) NOT NULL, `userName` varchar(255) NOT NULL, `userScreenName` varchar(255) NOT NULL, `retweetedAt` datetime NOT NULL, `clawledAt` datetime NOT NULL, `rawJson` text NOT NULL, PRIMARY KEY (`tweetId`, `retweetId`)) ENGINE=InnoDB ROW_FORMAT=DYNAMIC", undefined);
    }

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

}
```

# ローカルで実行してみる
```sh
npm run debug
curl -X POST http://localhost:8080/twitterer/v1/tweets/<TWEET_ID>/clawl
curl -X GET http://localhost:8080/twitterer/v1/tweets/<TWEET_ID>
```

# デプロイして確かめてみる
```sh
sls deploy
curl -X GET https://XXXXXXXX/twitterer/v1/tweets/<TWEET_ID>
```

# まとめ
正直落とし穴踏みすぎて、全部網羅できたか覚えてない。
もし問題あれば教えて下さい。

--- 

普段はFirestoreとか使ってるんだけど

- 小規模ツールでいちいちfirebase プロジェクト増やすのめんどいな
- やっぱSQL使いたいときもあるよね

ってことで取り組んでみました。

typeorm使うと、自分でSQL書かなくていい！ちょう楽ちん！！
このテンプレートをつかって、今後の開発が爆速になりそう。