8
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

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

概要

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

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

前提と関連記事

typeormとは

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

TypeORMはNode.js開発のスタンダードになるか?
こちらの紹介記事がわかりやすいと思いました。
べんりだね!

環境

  • 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

基本的には以下でしたときのまま。

serverless create --template aws-nodejs-typescript

ポリシーの設定

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

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 で受けるので、以下のように書く

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

package.json

scripts

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

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されなくてハマったので、以下のように追記

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

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

ormconfig.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

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

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

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 を指定しないと、
ローカルでは動いてもデプロイ後動かなくなる

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

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) となる。
こちらの記事でも触れたように、このままではキーカラム767bytes制限に阻まれて使うことができない。

対策には、

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

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

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の生成と実行

npm run migration:generate
npm run migration:run

generateでできるスクリプト

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

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

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);
    }

}

ローカルで実行してみる

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>

デプロイして確かめてみる

sls deploy
curl -X GET https://XXXXXXXX/twitterer/v1/tweets/<TWEET_ID>

まとめ

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


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

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

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

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
8
Help us understand the problem. What are the problem?