PONOS Advent Calendar 2020の6日目の記事です。
昨日は@kerimekaさんのAndroid開発者向けオプションを使ったチート対策でした。
はじめに
ORマッパーを使う場合、データベースのPK(仮にIDとします)はデフォルトだとオートインクリメントな数値型となっていることが多いと思います。
場合によってはこれが望まれないケースもあり、IDにUUIDが用いられているケースも見かけますので、今回はNode.jsのORマッパーの一つであるSequelizeを使って簡単にこれを実現してみたいと思います。
この記事の対象者
Node.jsとSequelizeの基本的な知識があることを前提とします。
この記事ではこれらの細かい部分については言及しません。
検証環境
下記の構成で試しました。
- Node.js 12.19.0
- Sequelize 6.3.5
- Sequelize-CLI 6.2.0
- uuid 8.3.1
- MySQL 5.7.25
UUIDについて
UUIDとは
UUIDについては沢山記事がありますので簡単な概要だけ記載させていただきますが、現実的にはまず重複することなく発行できるIDで、5つのバージョンがあり生成方法が異なります。
ここでは乱数によって生成されるUUIDv4を用います。
なぜUUIDにするのか
データのIDが数値型であった場合、主に下記の2点が懸念される部分かと思います。
(必ずしも全てのアプリでこれが問題になると言うわけではないですが)
URLから他のデータを類推できる
REST的なAPIなどなどでURLにIDが指定されることがよくありますが、数値の場合はその値から連番で他のデータを類推できます。
データの総数が推測できる
IDが数値であることから、そのまま使用するとデータ数がなんとなく見えてしまうこともあります。
実現してみる
まずは雛形のModelを作成する
Sequelize-CLIを使ってModelファイルとマイグレーションファイルを作成します。
ここではuser
というテーブルにname
とage
フィールドを持たせます。
npx sequelize model:generate --name user --underscored --attributes name:string,age:integer
※ --underscored
を指定しているのはテーブル定義上は名称をスネークケース、コード上はキャメルメースにしたいだけで、本質的には今回の件と関係ありません。
できあがったコードをみてみると下記の状態になっています。
マイグレーションファイル: xxxxxxxxxxx-create-user.js
'use strict';module.exports={up:async(queryInterface,Sequelize)=>{awaitqueryInterface.createTable('users',{id:{allowNull:false,autoIncrement:true,primaryKey:true,type:Sequelize.INTEGER},name:{type:Sequelize.STRING},age:{type:Sequelize.INTEGER},created_at:{allowNull:false,type:Sequelize.DATE},updated_at:{allowNull:false,type:Sequelize.DATE}});},down:async(queryInterface,Sequelize)=>{awaitqueryInterface.dropTable('users');}};
Modelファイル: user.js
'use strict';const{Model}=require('sequelize');module.exports=(sequelize,DataTypes)=>{classuserextendsModel{/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/staticassociate(models){// define association here}};user.init({name:DataTypes.STRING,age:DataTypes.INTEGER},{sequelize,modelName:'user',underscored:true,});returnuser;};
マイグレーションファイルのIDの定義をUUIDにする
上記のファイルをみてわかるとおり、Sequelize-CLIのmodel:generate
を使用した場合、id
,created_at
,updated_at
はデフォルトで定義されます。
明示的にid
を指定しても定義を上書きできるわけではなく、id
が二つ定義されたマイグレーションファイルが出力されるようでした。
この辺りもうちょっと楽にできたらなーと思うのですが・・・軽く調べた限りでは見つけられませんでした。
ということで上記の定義ファイルを変更する必要があります。
xxxxxxxxxxx-create-user.jsを編集する
編集前
id:{allowNull:false,autoIncrement:true,primaryKey:true,type:Sequelize.INTEGER},
編集後
id:{allowNull:false,primaryKey:true,type:Sequelize.UUID},
これでテーブル定義がUUID用になります。
MySQLの場合はCHAR(36) BINARY
が用いられるようです。
マイグレーションを実行してみましょう。
npx sequelize db:migrate
この状態でINSERTを実行してみます。
awaitdb.user.create({name:"sample",age:10});
しかしこれはエラーになります。
SequelizeDatabaseError: Field 'id' doesn't have a default value
IDをデフォルト値でINSERTしようとしていますが、テーブル定義上にデフォルト値が設定されていないからです。
SequelizeのUUID型のドキュメントによると、下記のような記載があります。
use defaultValue: Sequelize.UUIDV1 or Sequelize.UUIDV4 to make sequelize generate the ids automatically
SequelizeがIDを自動生成するためにdefaultValue
の指定が必要っぽいということです。
UUIDのdefaultValueを指定する
ここが一つややこしいポイントで、defaultValue
はマイグレーションファイルにもModel定義にも書くことができます。
まずマイグレーションファイルのほうにデフォルト値を指定するとどうなるか試してみます。
xxxxxxxxxxx-create-user.jsを編集する
編集前
id:{allowNull:false,primaryKey:true,type:Sequelize.UUID},
編集後
id:{allowNull:false,primaryKey:true,type:Sequelize.UUID,defaultValue:Sequelize.UUIDV4},
この状態で一度マイグレーションをUNDOし、もう一度実行します。
npx sequelize db:migrate:undo
npx sequelize db:migrate
テーブル定義を確認しますが、デフォルト値は何も設定されていません。
MySQLではデフォルトの定数にUUIDにしてくれみたいなものを設定することはできないはずので、当然かなと思います。
これはマイグレーションファイルなので実行時には関係がないはずですが、念のために実際に実行してみましたが、やはりエラーとなります。
どうやらModel側(つまりDB側のデフォルト値にたよるわけではない)での対処が必要ということです。予想通りですが。。
user.jsを編集する
user.jsのほうでdefaultValueを指定します。
今はid
についての記載が一切ないので、下記のようにIDを改めて定義し、defaultValueを指定します。
ポイントとなる点として、マイグレーションファイルの方と違って、コマンドで生成したModel側のType指定には、引数に渡ってきている変数DataTypes
が使われています。
(Sequelize.STRING
ではなく、DataTypes.STRING
になってる)
そこでDataTypes.UUIDV4
を指定してみます。
'use strict';const{Model}=require('sequelize');module.exports=(sequelize,DataTypes)=>{classuserextendsModel{/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/staticassociate(models){// define association here}};user.init({id:{allowNull:false,primaryKey:true,type:DataTypes.UUID,defaultValue:DataTypes.UUIDV4,},name:DataTypes.STRING,age:DataTypes.INTEGER},{sequelize,modelName:'user',underscored:true,});returnuser;};
これで実行してみると、どうやらうまく動作し、DBに下記のデータが登録されました。
id | name | age |
---|---|---|
99447f3e-328b-4a4b-8067-967c349c901b | sample | 10 |
他の方法
Sequelize.UUIDV4
をインポートしてみる
Modelファイルではsequelize
からインポートしている箇所があるので、ここでUUIDV4をインポートしても、結果はうまく動作しました。
'use strict';const{Model,UUIDV4}=require('sequelize');module.exports=(sequelize,DataTypes)=>{classuserextendsModel{/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/staticassociate(models){// define association here}};user.init({id:{allowNull:false,primaryKey:true,type:DataTypes.UUID,defaultValue:UUIDV4,},name:DataTypes.STRING,age:DataTypes.INTEGER},{sequelize,modelName:'user',underscored:true,});returnuser;};
引数のDataTypesも名称がそうなっているだけで、実態としてはSequelizeということなのでしょうか。
uuid
の関数を直接指定する
uuidパッケージを使用してそれをdefaultValue
に指定してもうまく動作します。
'use strict';const{Model}=require('sequelize');constuuid=require('uuid');module.exports=(sequelize,DataTypes)=>{classuserextendsModel{/**
* Helper method for defining associations.
* This method is not a part of Sequelize lifecycle.
* The `models/index` file will call this method automatically.
*/staticassociate(models){// define association here}};user.init({id:{allowNull:false,primaryKey:true,type:DataTypes.UUID,defaultValue:uuid.v4,},name:DataTypes.STRING,age:DataTypes.INTEGER},{sequelize,modelName:'user',underscored:true,});returnuser;};
ただし注意が必要な点があります。uuid.v4
は関数です。これを下記の様に指定すると大変なことになります。
defaultValue:uuid.v4(),
考えてみると当然ですが、この場合はこれが読み込まれた際に決定されたUUIDを全てのINSERTで使用するため、2回目以降はエラーになります。
少し中身のコードを調べてみましたが、defaultValue
は関数が設定されている場合、INSERT時にそれをSequelizeが実行して値を決定しているようです。もちろん固定値を渡すと毎回それが代入されるようです。
まとめ
ということでIDをUUIDにすることは出来ましたが、CLIのコマンドで良い感じに生成できたら楽なのですが、この方法だと(少しですが)手間が入ります。
どなたか良い感じの方法ご存知でしたらご教授いただけますと幸いです。
明日は@e73ryoさんです!