概要
Rest API を経由してツイッターのリツイート情報を取得し、
Aurora DB に格納するサンプルツール twitterer
を、以下の構成で実装した。
その過程で 5000兆個くらいある落とし穴を踏み抜いたので倒し方を書こうと思ったが、
1日経ったら過程をほとんど忘れたのでちゃんと動く結果を主に書き残す。
前提と関連記事
- Aurora Serverless DB を作って Node.js(TS) から使う を前提としています。
- Aurora Serverless MySQL(5.6) で日本語データを扱えるようにする も設定しています。
- Transactionの扱い、注意点
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 を使うために追加したポリシー。
必要なポリシーセットがわからず苦労していた
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 で受けるので、以下のように書く
functions:
twitterer:
handler: twitterer-handler.v1
events:
- http: ANY /
- http: "ANY /{proxy+}"
package.json
scripts
"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
"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されなくてハマったので、以下のように追記
// import 部分
const CopyWebpackPlugin = require("copy-webpack-plugin");
// plugins 部分
plugins: [
new CopyWebpackPlugin(["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
import "source-map-support/register";
import { TwittererExpress } from "./src/twitterer-express";
const serverless = require("serverless-http");
export const v1 = serverless(TwittererExpress);
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 を指定しないと、
ローカルでは動いてもデプロイ後動かなくなる
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との組み合わせに難航したのでモンキーパッチ
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制限に阻まれて使うことができない。
対策には、
- キーカラムの長さを短くするか、
- 上記記事の内容と合わせて テーブルに
ROW_FORMAT=DYNAMIC
を指定する必要がある。
前者はめんどかったので後者で実現しようとしたが、typeorm には ROW_FORMAT を指定できない、なんてことだ
ということでSQLインジェクションをつかって無理やり解決
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書かなくていい!ちょう楽ちん!!
このテンプレートをつかって、今後の開発が爆速になりそう。