概要
Rest API を経由してツイッターのリツイート情報を取得し、
Aurora DB に格納するサンプルツール twitterer
を、以下の構成で実装した。
その過程で 5000兆個くらいある落とし穴を踏み抜いたので倒し方を書こうと思ったが、
1日経ったら過程をほとんど忘れたのでちゃんと動く結果を主に書き残す。
前提と関連記事
「Aurora Serverless DB を作って Node.js(TS) から使う」 を前提としています。
「Aurora Serverless MySQL(5.6) で日本語データを扱えるようにする」 も設定しています。
typeormとは
TypeScript の class として Entity を定義すると、
自分でSQL書かなくても一通りなんでもできる OR Mapper だよ。
TypeORMはNode.js開発のスタンダードになるか?
こちらの紹介記事がわかりやすいと思いました。
べんりだね!
環境
Aurora エンジン
Aurora (MySQL)-5.6.10aAWS SDK
2.590.0Node.js
v10.15.0npm
6.6.0typeorm
0.2.21typeorm-aurora-data-api-driver
1.1.8Serverless 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.v1events:-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で意図したrequestbodyを受け取るため"cors":"^2.8.5",//webviewから蹴ることも想定して"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からDataAPIを使える},"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 部分constCopyWebpackPlugin=require("copy-webpack-plugin");// plugins 部分plugins:[newCopyWebpackPlugin(["ormconfig.js"]),],
ormconfig.js
module.exports={type:"aurora-data-api",region:"ap-northeast-1",// Aurora Serverless DB の arnresourceArn:"arn:aws:rds:ap-northeast-1:XXXXXXXXXXXX:cluster:XXXXXXXXXXXX",// DB にアクセスするために作った Secret の arnsecretArn:"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";constserverless=require("serverless-http");exportconstv1=serverless(TwittererExpress);
twitterer-express.ts
公開用に加工してます、エラーハンドリングとか適当なので許してちょ。
importbodyParserfrom"body-parser";importcorsfrom"cors";importexpress,{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();constapp=express();constr: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};})();exportconstTwittererExpress=app;/**
* define routes
*/constP="/twitterer/v1";r.post(`${P}/tweets/:tweetId/retweets/clawl`,clawlRetweet);r.get(`${P}/tweets/:tweetId/retweets`,getRetweets);/*****************************************************************/asyncfunctionclawlRetweet(req:Request,res:Response,next:NextFunction){const{tweetId}=req.params;try{constretweets=awaitTwittererService.clawlRetweets(tweetId);res.status(200).send(retweets);next();}catch(e){console.error(e);res.status(424/* failed dependency */).send(JSON.stringify(e));}}asyncfunctiongetRetweets(req:Request,res:Response,next:NextFunction){const{tweetId}=req.params;constretweets=awaitRetweetEntity.find({where:{tweetId}});constresRetweets=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 を指定しないと、
ローカルでは動いてもデプロイ後動かなくなる
importTwitterfrom"twitter";import{BaseEntity,Connection,createConnection,getConnection,getConnectionOptions}from"typeorm";import{TypeormHelper}from"./typeorm-helper";import{RetweetEntity}from"./entities/twitterer-types";exportclassTwittererService{/**
* init aurora connnection
*/staticasyncinit(){// 後述のTypeormHelper.patchBug(Connection);constconnectionOptions=awaitgetConnectionOptions();constconn=awaitcreateConnection({...connectionOptions,// 利用するEntityをこうして書かないと、デプロイ後動かない。// (entityがrepositoryに見つかりませんよ、みたいなエラー出る)entities:[RetweetEntity],});BaseEntity.useConnection(conn);}staticgetclient():Twitter{returnnewTwitter({consumer_key:"XXXXXXXXXX",consumer_secret:"XXXXXXXXXX",access_token_key:"XXXXXXXXXX",access_token_secret:"XXXXXXXXXX",});}staticasyncclawlRetweets(tweetId:string):Promise<RetweetEntity[]>{constclawledAt=newDate();// 本当は保持していたカーソルの読み込みとかいろいろやってるconsttwitterRes=awaitthis.client.get(`statuses/retweets/${tweetId}.json`,{});constretweets:RetweetEntity[]=[];for(consteoftwitterResasany){constretweet=newRetweetEntity();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=newDate(e.created_at);retweet.clawledAt=clawledAt;retweet.rawJson=e;retweets.push(retweet);}awaitgetConnection().transaction(asynce=>{awaite.save(retweets);// 本当はカーソルの更新とかいろいろやってる});returnretweets;}}
typeorm-helper.ts
何故かデプロイ後動かないというエラーに悩まされた結果たどり着いたソリューション。
めっっっっちゃここでハマった。二度とハマりたくないpatch-package
を使おうとしたけどwebpackとの組み合わせに難航したのでモンキーパッチ
import{EntityMetadata,EntitySchema}from"typeorm";exportclassTypeormHelper{/**
* デプロイすると動かなくなる糞バグのモンキーパッチ
* https://github.com/typeorm/typeorm/issues/3427
*/staticpatchBug(typeormConnection:any){// this is a copypasta of the existing typeorm Connection method// with one line changed// @ts-ignoretypeormConnection.prototype.findMetadata=function(target:Function|EntitySchema<any>|string):EntityMetadata|undefined{// @ts-ignorereturnthis.entityMetadatas.find(metadata=>{// @ts-ignoreif(metadata.target.name===target.name){// in latest typeorm it is metadata.target === targetreturntrue;}if(targetinstanceofEntitySchema){returnmetadata.name===target.options.name;}if(typeoftarget==="string"){if(target.indexOf(".")!==-1){returnmetadata.tablePath===target;}else{returnmetadata.name===target||metadata.tableName===target;}}returnfalse;});};}}
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"})exportclassRetweetEntityextendsBaseEntity{@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";exportclassmigration1576639222255implementsMigrationInterface{name='migration1576639222255'publicasyncup(queryRunner:QueryRunner):Promise<any>{awaitqueryRunner.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);}publicasyncdown(queryRunner:QueryRunner):Promise<any>{awaitqueryRunner.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書かなくていい!ちょう楽ちん!!
このテンプレートをつかって、今後の開発が爆速になりそう。