Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 8691

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

$
0
0

概要

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

webpack.config.js
// import 部分constCopyWebpackPlugin=require("copy-webpack-plugin");// plugins 部分plugins:[newCopyWebpackPlugin(["ormconfig.js"]),],

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

twitterer-handler.ts
import"source-map-support/register";import{TwittererExpress}from"./src/twitterer-express";constserverless=require("serverless-http");exportconstv1=serverless(TwittererExpress);

twitterer-express.ts

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

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

twitterer-service.ts
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との組み合わせに難航したのでモンキーパッチ

ありがとうsdebaun

typeorm-helper.ts
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制限に阻まれて使うことができない。

対策には、

  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"})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書かなくていい!ちょう楽ちん!!
このテンプレートをつかって、今後の開発が爆速になりそう。


Viewing all articles
Browse latest Browse all 8691

Trending Articles