すでに Rails 環境における utf8mb4 の扱いに関しては多くの記事が出ているため新しいことは何も書いてありません。
Rails 5 と現状 MySQL 5.6 compatible な Aurora の環境での考察です。
環境
- Rails 5.0.1
- MySQL 5.6.22
なぜ 5.6 か
Amazon Aurora を利用しており、まだ MySQL 5.7 compatible になっていないため 5.6 系である必要がありました。
今回利用する Charset と Collation
Charset は utf8mb4
に、Collation は utf8mb4_bin
にすることとします。
Charset や Collation に関しては、直近で以下の素晴らしい資料があがっておりました。すごく詳しくてありがたい。
MySQLの文字コード事情 2017版 - SlideShare
ただ、今回はサーバー側も utf8mb4 で揃えるということはできないのですが。
Rails の設定
今回私が気にした点は以下の 2 点のみです。
-
database.yml
の設定 - character を入れるカラムのインデックスに関して
database.yml の設定
development:
adapter: mysql2
pool: 5
username: root
password:
database: utf8mb4_sample_development
charset: utf8mb4
collation: utf8mb4_bin
encoding: utf8mb4
以降では、ここで設定した charset
, collation
, encoding
がどういう役割を担っていくかの一部を見ていきます。
charset と collation 設定
上記の database.yml
に設定した状態で、bin/rails db:create
を行うと以下のようになっています。
character_set_database 以外はサーバー自体の設定が utf8 なので気にしないでください。 私が利用している Aurora の設定に合わせてあるだけです。
mysql> use utf8mb4_sample_development;
mysql> show variables like "chara%";
+--------------------------+------------------------------------------------------+
| Variable_name | Value |
+--------------------------+------------------------------------------------------+
| character_set_client | utf8 |
| character_set_connection | utf8 |
| character_set_database | utf8mb4 |
| character_set_filesystem | binary |
| character_set_results | utf8 |
| character_set_server | utf8 |
| character_set_system | utf8 |
| character_sets_dir | /usr/local/Cellar/mysql/5.6.22/share/mysql/charsets/ |
+--------------------------+------------------------------------------------------+
8 rows in set (0.00 sec)
mysql> SHOW CREATE DATABASE utf8mb4_sample_development;
+----------------------------+------------------------------------------------------------------------------------------------------------+
| Database | Create Database |
+----------------------------+------------------------------------------------------------------------------------------------------------+
| utf8mb4_sample_development | CREATE DATABASE `utf8mb4_sample_development` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_bin */ |
+----------------------------+------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
デフォルトデータベースの character_set_database
が utf8mb4
になっており、 Database としてもデフォルトの charset と collate が狙い通りになっているのが分かります。
ではこの状態でテーブルを作ってみます。
class CreateBookTable < ActiveRecord::Migration[5.0]
def change
create_table :books do |t|
t.string :title
t.timestamps
end
end
end
% bin/rails db:migrate
create_table "book_tables", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" do |t|
t.string "title"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
mysql> SHOW CREATE TABLE books;
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table |
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| books | CREATE TABLE `books` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin |
+-------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
mysql> show full columns from books;
+------------+--------------+-------------+------+-----+---------+----------------+---------------------------------+---------+
| Field | Type | Collation | Null | Key | Default | Extra | Privileges | Comment |
+------------+--------------+-------------+------+-----+---------+----------------+---------------------------------+---------+
| id | int(11) | NULL | NO | PRI | NULL | auto_increment | select,insert,update,references | |
| title | varchar(255) | utf8mb4_bin | YES | | NULL | | select,insert,update,references | |
| created_at | datetime | NULL | NO | | NULL | | select,insert,update,references | |
| updated_at | datetime | NULL | NO | | NULL | | select,insert,update,references | |
+------------+--------------+-------------+------+-----+---------+----------------+---------------------------------+---------+
4 rows in set (0.01 sec)
テーブル、カラムの設定も狙い通りの charset, collate になっていることが確認できています。
では試しに 4 byte 文字を入れてみます。
mysql> INSERT INTO books (title, created_at, updated_at) VALUES ('😇', NOW(), NOW());
Query OK, 1 row affected, 1 warning (0.01 sec)
mysql> SHOW warnings;
+---------+------+------------------------------------------------------------------------+
| Level | Code | Message |
+---------+------+------------------------------------------------------------------------+
| Warning | 1366 | Incorrect string value: '\xF0\x9F\x98\x87' for column 'title' at row 1 |
+---------+------+------------------------------------------------------------------------+
1 row in set (0.00 sec)
お、Warning が出ていますね。 Incorrect
となっています。中を見てみても以下のように正しく登録できていません。
mysql> SELECT * FROM books;
+----+-------+---------------------+---------------------+
| id | title | created_at | updated_at |
+----+-------+---------------------+---------------------+
| 1 | ???? | 2017-02-02 17:00:39 | 2017-02-02 17:00:39 |
+----+-------+---------------------+---------------------+
最初に紹介したスライドにもあるようにクライアント側が utf8
になっているためです。(かつ strict にはしていないので登録もされています)
なので、utf8mb4
にします。
mysql> SET NAMES 'utf8mb4';
Query OK, 0 rows affected (0.00 sec)
mysql> show variables like "chara%";
+--------------------------+------------------------------------------------------+
| Variable_name | Value |
+--------------------------+------------------------------------------------------+
| character_set_client | utf8mb4 |
| character_set_connection | utf8mb4 |
| character_set_database | utf8mb4 |
| character_set_filesystem | binary |
| character_set_results | utf8mb4 |
| character_set_server | utf8 |
| character_set_system | utf8 |
| character_sets_dir | /usr/local/Cellar/mysql/5.6.22/share/mysql/charsets/ |
+--------------------------+------------------------------------------------------+
8 rows in set (0.00 sec)
mysql> SELECT * FROM books;
+----+-------+---------------------+---------------------+
| id | title | created_at | updated_at |
+----+-------+---------------------+---------------------+
| 1 | ???? | 2017-02-02 17:00:39 | 2017-02-02 17:00:39 |
| 2 | 😇 | 2017-02-02 17:02:43 | 2017-02-02 17:02:43 |
+----+-------+---------------------+---------------------+
2 rows in set (0.00 sec)
無事登録できていますね
ついでにクライアントが utf8
の場合には登録できた 4 byte 文字を正しく読めないことも確認しておきます。
mysql> SET NAMES 'utf8';
Query OK, 0 rows affected (0.00 sec)
mysql> show variables like "chara%";
+--------------------------+------------------------------------------------------+
| Variable_name | Value |
+--------------------------+------------------------------------------------------+
| character_set_client | utf8 |
| character_set_connection | utf8 |
| character_set_database | utf8mb4 |
| character_set_filesystem | binary |
| character_set_results | utf8 |
| character_set_server | utf8 |
| character_set_system | utf8 |
| character_sets_dir | /usr/local/Cellar/mysql/5.6.22/share/mysql/charsets/ |
+--------------------------+------------------------------------------------------+
8 rows in set (0.00 sec)
mysql> SELECT * FROM books;
+----+-------+---------------------+---------------------+
| id | title | created_at | updated_at |
+----+-------+---------------------+---------------------+
| 1 | ???? | 2017-02-02 17:00:39 | 2017-02-02 17:00:39 |
| 2 | ? | 2017-02-02 17:02:43 | 2017-02-02 17:02:43 |
+----+-------+---------------------+---------------------+
2 rows in set (0.00 sec)
読めていませんね。期待通りに動作しました。
SET NAMES がやっていること
先ほど SET NAMES utf8mb4
を実行しましたが、挙動を見て分かる通り以下のような事をやっています。
SET NAMES 'charset_name' [COLLATE 'collation_name']
SET NAMES は、クライアントからサーバーへの SQL ステートメントの送信に使用される文字セットを示します。したがって、SET NAMES 'cp1251' は、「このクライアントから今後受信するメッセージが文字セット cp1251 で送信される」ことを、サーバーに知らせます。また、クライアントに結果を返信するときにサーバーが使用する文字セットも指定します。(たとえば、SELECT ステートメントを使用する場合に、カラム値に使用する文字セットを指定します。)
SET NAMES 'charset_name' ステートメントは次の 3 つのステートメントと同等です。
SET character_set_client = charset_name;
SET character_set_results = charset_name;
SET character_set_connection = charset_name;
SET NAMES で変更される 3 つの変数は クライアント側の文字セット (character set) に関するものであり、以下の説明がされています。
- クライアントから送信されるときに、ステートメントはどの文字セットで送信されますか。
サーバーは、character_set_client システム変数値を、クライアントが送信するステートメントの文字セットにします。
- ステートメントを受信したあとで、サーバーはこれをどの文字セットに変換しますか。
これには、サーバーは character_set_connection および collation_connection システム変数値を使用します。クライアントから送信されたステートメントは、character_set_client から character_set_connection に変換されます (_latin1 や _utf8 などのイントロデューサがある文字列リテラルを除きます)。collation_connection はリテラル文字列の比較で重要です。カラム値のある文字列の比較には、collation_connection は重要視されません。なぜなら、カラムには独自の照合順序があり、この照合順序が優先されるからです。
- 結果セットまたはエラーメッセージをクライアントに返送する前に、サーバーはこれらをどの文字セットに変換しますか。
character_set_results システム変数値は、サーバーがクライアントにクエリー結果を返信するときに使用する文字セットを示します。これには、カラム値などの結果データと、カラム名やエラーメッセージなどの結果メタデータが含まれます。
SET NAMES と encoding 設定
これは Rails では encoding
の設定をすることによって有効にできます。
:encoding - (Optional) Sets the client encoding by executing “SET NAMES ” after connection.
実際のコードを見ると以下のようになっています。collation
に関しても設定されていることがわかりますね。
module ActiveRecord
module ConnectionAdapters
class AbstractMysqlAdapter < AbstractAdapter
def configure_connection
# ....
# NAMES does not have an equals sign, see
# http://dev.mysql.com/doc/refman/5.7/en/set-statement.html#id944430
# (trailing comma because variable_assignments will always have content)
if @config[:encoding]
encoding = "NAMES #{@config[:encoding]}"
encoding << " COLLATE #{@config[:collation]}" if @config[:collation]
encoding << ", "
end
# Gather up all of the SET variables...
variable_assignments = variables.map do |k, v|
if defaults.include?(v)
"@@SESSION.#{k} = DEFAULT" # Sets the value to the global or compile default
elsif !v.nil?
"@@SESSION.#{k} = #{quote(v)}"
end
# or else nil; compact to clear nils out
end.compact.join(", ")
# ...and send them all in one query
execute "SET #{encoding} #{sql_mode_assignment} #{variable_assignments}"
end
よって、 encoding
の設定が正しくしてあれば Rails から実行する場合には問題は起きません。
Loading development environment (Rails 5.0.1)
irb(main):001:0> Book.create(title: '😖')
(0.2ms) BEGIN
SQL (3.8ms) INSERT INTO `books` (`title`, `created_at`, `updated_at`) VALUES ('😖', '2017-02-02 08:22:47', '2017-02-02 08:22:47')
(2.4ms) COMMIT
=> #<Book id: 3, title: "😖", created_at: "2017-02-02 08:22:47", updated_at: "2017-02-02 08:22:47">
irb(main):004:0> b= Book.last
Book Load (13.8ms) SELECT `books`.* FROM `books` ORDER BY `books`.`id` DESC LIMIT 1
=> #<Book id: 3, title: "😖", created_at: "2017-02-02 08:22:47", updated_at: "2017-02-02 08:22:47">
irb(main):005:0> b.title
=> "😖"
database.yml の設定まとめ
ここまでで database.yml
に指定した charset
, collation
, encoding
がどのような役割をしたかを確認しました。
ひとまずはこの設定があれば utf8mb4
対応はできたことになります。
次に、だいたいぶつかるインデックスの課題について見ていきます。
character を入れるカラムのインデックスに関して
上記の記事から引用しますと、
ActiveRecordでstring型のカラムを定義すると、MySQLだとvarchar(255)になるので、utf8mb4だとインデックス張ると767バイトを超えてしまう。
という自体が発生します。
これに関しては回避方法は多くあると思いますが、ここでは以下の 3 つに絞って考察します。
- キープレフィックスの制限を拡張
- カラムの文字数を制限
- カラムの charset を変更
キープレフィックスの制限を拡張
これに関しては、上記の記事でも解決法としてあげられています。
では Aurora 環境ではどうすればよいかですが、Aurora でも同様の事が実現可能と思われます。(というのも、実際やってはいない。。)
パラメータ的には可能だろうというものです。
- innodb_file_format
- パラメータグループからオンラインで変更可能
- innodb_large_prefix
- パラメータグループからオンラインで変更可能
- innodb_file_per_table
- パラメータグループには無いが、設定を見ると元から 1 になっている
- ROW_FORMAT
- Rails の
create_table
オプションに渡す (5.6 系なので)
- Rails の
create_table :books, options: 'ROW_FORMAT=DYNAMIC' do |t|
create_table "books", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin ROW_FORMAT=DYNAMIC" do |t|
file_format に関しては以下の記事が勉強になりました。
InnoDBの制限とファイルフォーマットAntelopeとBarracudaの違い - かみぽわーる
Rails での ROW_FORMAT に関しては以下が参考になります。
カラムの文字数を制限
インデックスに使うカラムを 191 文字以下に制限するという方法です。
一時期は schema_migrations
テーブルの version
カラムに関してもこの対応が取られていたのでしょうか。
https://github.com/rails/rails/pull/17601
- Rails4 で MySQL の utf8mb4 を扱う - xykのブログ
- Mysqlデータベースに絵文字を格納する方法 - Qiita
- How to store emoji in a Rails app with a MySQL database
最も単純にやるなら以下の方法で良さそうです。
class CreateBook < ActiveRecord::Migration[5.0]
def change
create_table :books do |t|
t.string :title, limit: 191, index: true
t.timestamps
end
end
end
create_table "books", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" do |t|
t.string "title", limit: 191
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["title"], name: "index_books_on_title", using: :btree
end
カラムの charset を変更
4byte の charset でなければ良いので例えばそのカラムが utf8 でも問題無いのなら、カラムの charset を変更すれば文字数の最大値を減らさなくてもすみます。
当然ではありますが、charset を変えるということはインデックスを付けるカラムには 4byte 文字は入らなくなるのでその点は注意してください。
方法に関してですが以下のように charset
を直接指定することもできます。
class CreateBook < ActiveRecord::Migration[5.0]
def change
create_table :books do |t|
t.string :title, index: true, charset: :utf8
t.timestamps
end
end
end
これで charset 自体は確かに問題なくインデックスも作成されるのですが、以下のように collate
が意図しないものに変わります。
mysql> show full columns from books;
+------------+--------------+-----------------+------+-----+---------+----------------+---------------------------------+---------+
| Field | Type | Collation | Null | Key | Default | Extra | Privileges | Comment |
+------------+--------------+-----------------+------+-----+---------+----------------+---------------------------------+---------+
| id | int(11) | NULL | NO | PRI | NULL | auto_increment | select,insert,update,references | |
| title | varchar(255) | utf8_general_ci | YES | MUL | NULL | | select,insert,update,references | |
| created_at | datetime | NULL | NO | | NULL | | select,insert,update,references | |
| updated_at | datetime | NULL | NO | | NULL | | select,insert,update,references | |
+------------+--------------+-----------------+------+-----+---------+----------------+---------------------------------+---------+
4 rows in set (0.00 sec)
ここに関しては Rails が schema_migrations
に対して現在取っているアプローチが良さそうです。
https://github.com/rails/rails/blob/65bf1c60053e727835e06392d27a2fb49665484c/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L75-L81
(PR はこれかな? https://github.com/rails/rails/pull/23168)
module ActiveRecord
module ConnectionAdapters
class AbstractMysqlAdapter < AbstractAdapter
#...
CHARSETS_OF_4BYTES_MAXLEN = ["utf8mb4", "utf16", "utf16le", "utf32"]
def internal_string_options_for_primary_key # :nodoc:
super.tap { |options|
options[:collation] = collation.sub(/\A[^_]+/, "utf8") if CHARSETS_OF_4BYTES_MAXLEN.include?(charset)
}
end
これは collate
の方を直しています。例えば utf8mb4_bin
であったのなら utf8_bin
になります。
この方法を用いると以下のように書けます。
class CreateBook < ActiveRecord::Migration[5.0]
def change
create_table :books do |t|
t.string :title, index: true, collation: :utf8_bin
t.timestamps
end
end
end
これを実行すると以下のような結果になります。
create_table "books", force: :cascade, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin" do |t|
t.string "title", collation: "utf8_bin"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["title"], name: "index_books_on_title", using: :btree
end
mysql> show full columns from books;
+------------+--------------+-----------+------+-----+---------+----------------+---------------------------------+---------+
| Field | Type | Collation | Null | Key | Default | Extra | Privileges | Comment |
+------------+--------------+-----------+------+-----+---------+----------------+---------------------------------+---------+
| id | int(11) | NULL | NO | PRI | NULL | auto_increment | select,insert,update,references | |
| title | varchar(255) | utf8_bin | YES | MUL | NULL | | select,insert,update,references | |
| created_at | datetime | NULL | NO | | NULL | | select,insert,update,references | |
| updated_at | datetime | NULL | NO | | NULL | | select,insert,update,references | |
+------------+--------------+-----------+------+-----+---------+----------------+---------------------------------+---------+
4 rows in set (0.01 sec)
mysql> SHOW CREATE TABLE books;
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table |
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| books | CREATE TABLE `books` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin DEFAULT NULL,
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `index_books_on_title` (`title`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin |
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
charset が utf8
になり collate も意図通りになりました。
character を入れるカラムのインデックスに関してのまとめ
4byte の character を扱うカラムのインデックスに関して、回避方法 3 つを調査しました。
1 で対応できるならば 1 が最もコードへの影響はなく良さそうに見えます。
2, 3 に関しては、最大文字長・charset の制限に対して要件として問題無いのであれば、選択しても良さそうです。
私個人としては DBA とも相談し、要件を鑑みた上で 2 で十分という事になり 2 を採用しました。
余談
ActiveRecord::ValueTooLong に関して
Rails5から使えるActiveRecord便利機能 - Qiita の記事で知ったのですが、 ActiveRecord::ValueTooLong
が制限値を超えているケースで raise
されるようになったようです。
同僚が昔に MySQL で制限値を超えた時に文字を切られて保存されたという事象にあたったと聞いたので、今回 limit を付けることもあり調査していました。
module ActiveRecord
module ConnectionAdapters
class AbstractMysqlAdapter < AbstractAdapter
# ...
private
# See https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html
ER_DATA_TOO_LONG = 1406
def translate_exception(exception, message)
case error_number(exception)
when ER_DUP_ENTRY
RecordNotUnique.new(message)
# 略
when ER_DATA_TOO_LONG
ValueTooLong.new(message)
when ER_OUT_OF_RANGE
RangeError.new(message)
# 略
else
super
end
end
上記の通り、この例外は MySQL の ER_DATA_TOO_LONG
というエラーコードが投げられた時に変換されるもののようです。
B.3 Server Error Codes and Messages - MySQL
Error: 1406 SQLSTATE: 22001 (ER_DATA_TOO_LONG)
Message: Data too long for column '%s' at row %ld
このエラーは sql_mode が strict になっていなければ発行されません。 (STRICT_TRANS_TABLES
, STRICT_ALL_TABLES
, TRADITIONAL
)
5.1.7 サーバー SQL モード - MySQL
ここまで見てきたように私が検証していた DB は strict ではありません。(Warning を出して不正な文字が登録されていた)
しかし Rails console から文字数制限を超える処理を実行すると ActiveRecord::ValueTooLong
は発行されてきます。
strict が未指定の場合は true
以下は encoding の時も出てきた #configure_connection
です。
module ActiveRecord
module ConnectionAdapters
class AbstractMysqlAdapter < AbstractAdapter
def strict_mode?
self.class.type_cast_config_to_boolean(@config.fetch(:strict, true))
end
private
def configure_connection
# ....
# Make MySQL reject illegal values rather than truncating or blanking them, see
# http://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sqlmode_strict_all_tables
# If the user has provided another value for sql_mode, don't replace it.
if sql_mode = variables.delete("sql_mode")
sql_mode = quote(sql_mode)
elsif !defaults.include?(strict_mode?)
if strict_mode?
sql_mode = "CONCAT(@@sql_mode, ',STRICT_ALL_TABLES')"
else
sql_mode = "REPLACE(@@sql_mode, 'STRICT_TRANS_TABLES', '')"
sql_mode = "REPLACE(#{sql_mode}, 'STRICT_ALL_TABLES', '')"
sql_mode = "REPLACE(#{sql_mode}, 'TRADITIONAL', '')"
end
sql_mode = "CONCAT(#{sql_mode}, ',NO_AUTO_VALUE_ON_ZERO')"
end
sql_mode_assignment = "@@SESSION.sql_mode = #{sql_mode}, " if sql_mode
上記を見て分かる通り、 strict_mode?
が true
の場合には STRICT_ALL_TABLES
が sql_mode
に指定されることがわかります。
そして #strict_mode?
を見ると、 @config
今回だと database.yml
の設定から strict
を fetch する際にデフォルトを true
にしています。
実際に以下のように true
が返ってくることがわかります。
irb(main):007:0> Book.connection.strict_mode?
=> true
よって、明示的に strict
や sql_mode
を異なるものに指定しない限りは STRICT_ALL_TABLES
が適用されているため、データベースにその設定をしていなくても Rails での実行時には ActiveRecord::ValueTooLong
を受け取れるということになります。