やりたいこと
文字列を特定のバイト数 (not 文字数) で切り詰めたいです。
最初に結論
Active Support コア拡張機能が提供する String#truncate_bytes を使います。
limit = 100 # bytes
engineers = '👨🏿💻👩🏿💻👩🏼💻👨🏻💻👩🏾💻👨💻👨🏼💻👨🏾💻👨🏽💻👩💻👩🏻💻👩🏽💻'
engineers.bytesize
#=> 172
truncated_engineers = engineers.truncate_bytes(limit, omission: '')
#=> "👨🏿💻👩🏿💻👩🏼💻👨🏻💻👩🏾💻👨💻"
truncated_engineers.bytesize
#=> 86
長い解説
例: 文字列を MySQL の TEXT 型に収まる範囲に切り詰めたい
とある Rails アプリケーションでデータベースに MySQL を使っているとします。
yoyos というテーブルがあります。
CREATE TABLE `yoyos` (
`id` bigint NOT NULL AUTO_INCREMENT,
`brand_id` bigint NOT NULL,
`name` varchar(255) NOT NULL,
`diameter` float NOT NULL,
`width` float NOT NULL,
`weight` float NOT NULL,
`description` text,
`created_at` datetime(6) NOT NULL,
`updated_at` datetime(6) NOT NULL,
PRIMARY KEY (`id`),
KEY `index_yoyos_on_brand_id` (`brand_id`),
CONSTRAINT `fk_rails_e9be80a518` FOREIGN KEY (`brand_id`) REFERENCES `brands` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
yoyos テーブルは TEXT 型の description カラムを持ちます。この description カラムに 16,384 文字の文字列を設定して保存しようとすると ActiveRecord::ValueTooLong
が発生してしまいます。
yoyo = Yoyo.find_by!(name: 'ヘトロジェニティ')
yoyo.update!(description: '👼' * 16_383)
#=> true
yoyo.update!(description: '👼' * 16_384)
# Mysql2::Error:
# Data too long for column 'description' at row 1 (ActiveRecord::ValueTooLong)
これは MySQL の TEXT 型の制限が 65,535 バイトまでだからです。
A TEXT column with a maximum length of 65,535 (2^16 − 1) characters.
The effective maximum length is less if the value contains multibyte characters.
Each TEXT value is stored using a 2-byte length prefix that indicates the number of bytes in the value.
(https://dev.mysql.com/doc/refman/8.4/en/string-type-syntax.html より)
column = Yoyo.columns_hash['description']
column.sql_type_metadata.then { [_1.type, _1.limit] }
#=> [:text, 65535]
('👼' * 16_383).bytesize
#=> 65532
('👼' * 16_384).bytesize
#=> 65536
そこで文字列を 65,535 バイトで切り詰めたいです。しかし UTF-8 では 1 文字ごとのバイト数が異なるので、単純に文字数で切り詰めることはできません。
# とても長い文字列を 65,535 文字に切り詰めても 262,131 バイトで、65,535 バイトを超えてしまう。
too_many_angels = '👼' * 100_000
too_many_angels.truncate(65_535, ommision: '').bytesize
#=> 262131
# 16,386 文字に切り詰めるとちょうどいい。
too_many_angels.truncate(16_386, ommision: '').bytesize
#=> 65535
# しかし、仮に 1 バイト文字のみで構成される文字列の場合は
# 16,386 文字に切り詰めるとたくさんの無駄が生じてしまう。
too_many_alphabets = [*'a'..'z', *'A'..'Z'].then { |chars| Array.new(100_000) { chars.sample }.join }
too_many_alphabets.truncate(16_386, ommision: '').bytesize
#=> 16386
そこで String#bytes で文字列をいったんバイト数値 (整数) の配列に変換し、Array#slice で 65,535 バイトに切り詰めます。
# バイト数値 (整数) の配列に変換して、要素を 65,535 個に切り詰める。
truncated_bytes = too_many_angels.bytes.slice(...65_535)
# バイト数値 (整数) の配列を文字列に戻す。
truncated_many_angels = truncated_bytes.pack('c*').force_encoding('UTF-8')
truncated_many_angels.bytesize
#=> 65535
しかし、切り詰めた文字列の末尾を確認すると文字が壊れています 😢
truncated_many_angels.last(10)
#=> "👼👼👼👼👼👼👼\xF0\x9F\x91"
これは文字の途中のバイトで切り詰めたためです。
# 切り詰めた際に末尾の \xBC が消されてしまった。
'👼'.bytes.pack('c*')
#=> "\xF0\x9F\x91\xBC"
truncated_many_angels.last(3)
#=> "\xF0\x9F\x91"
つまり文字のバイト列の途中で切り詰めないようにするため工夫が必要です。Active Support コア拡張機能が提供する String#truncate_bytes を使うと、文字を壊さずに切り詰めることができます。
too_many_angels = '👼' * 100_000
too_many_angels.bytesize
#=> 400000
# 65,535 バイトに収まる最大の文字数で切り詰めることができる。
truncated_many_angels = too_many_angels.truncate_bytes(65_535, omission: '')
truncated_many_angels.bytesize
#=> 65532
書記素クラスタを考慮する
「これくらいならメソッドを自作するぜ!」という方もいるかもしれません。例えば String#each_char を使って以下のように実装します。
module MyTruncateBytes
refine String do
def my_truncate_bytes(limit)
return dup if bytesize <= limit
cut = ''
each_char do |char|
if cut.bytesize + char.bytesize <= limit
cut << char
else
break
end
end
cut
end
end
end
using MyTruncateBytes
limit = 100 # bytes
engineers = '👨🏿💻👩🏿💻👩🏼💻👨🏻💻👩🏾💻👨💻👨🏼💻👨🏾💻👨🏽💻👩💻👩🏻💻👩🏽💻'
engineers.bytesize
#=> 172
truncated_engineers = engineers.my_truncate_bytes(100)
#=> "👨🏿💻👩🏿💻👩🏼💻👨🏻💻👩🏾💻👨💻👨🏼"
truncated_engineers.bytesize
#=> 97
切り詰めできました!しかし truncated_engineers をよく見てみると一番右の男性がパソコンを取り上げられています (👨🏼💻 → 👨🏼) 。ざっくり言うと 👨🏼💻 は複数の絵文字を合成した文字で、その文字の途中で切り詰めてしまったせいですね。
一部の Unicode 絵文字など特殊な文字も正しく処理するためには書記素クラスタ (grapheme cluster) を考慮する必要があります。書記素クラスタについては以下の拙記事を参照してください。
String#truncate_bytes の実装を簡易化して記載します。この実装ではきちんと書記素クラスタを考慮していますね。
# Active Support コア拡張機能の String#truncate_bytes
# (https://github.com/rails/rails/blob/v8.0.0/activesupport/lib/active_support/core_ext/string/filters.rb#L101-L126)
# から omission (省略文字) の概念を取り除いて簡易化した実装。
def truncate_bytes(limit)
return dup if bytesize <= limit
cut = ''
scan(/\X/) do |grapheme|
if cut.bytesize + grapheme.bytesize <= limit
cut << grapheme
else
break
end
end
cut
end
limit = 100 # bytes
engineers = '👨🏿💻👩🏿💻👩🏼💻👨🏻💻👩🏾💻👨💻👨🏼💻👨🏾💻👨🏽💻👩💻👩🏻💻👩🏽💻'
engineers.bytesize
#=> 172
truncated_engineers = engineers.truncate_bytes(limit, omission: '')
#=> "👨🏿💻👩🏿💻👩🏼💻👨🏻💻👩🏾💻👨💻"
truncated_engineers.bytesize
#=> 86
これでパソコンを取り上げられるエンジニアはいなくなりました!
バージョン情報
$ ruby -v
ruby 3.3.6 (2024-11-05 revision 75015d4c1f) [arm64-darwin24]
$ gem list | grep activesupport
activesupport (8.0.0)