Amazon LambdaでS3のオブジェクト名を自動でUnicode正規化する

はじめまして。AWS担当のwakです。 細かい話をほじくるのが趣味なのですが、今日はなるべく間口を広くするため、皆さんが大好きなAmazon S3の話をします。

Unicode正規化 (Unicode normalization)

結合文字

Unicodeでは膨大な数の文字や記号、絵文字が定義されていますが、その中には単独の文字ではなく、他の文字にくっつけるための特別な濁点やアクセント記号が含まれています(結合文字)。たとえば濁点を例にとると、単独文字バージョンの濁点「゛(U+309B)」、結合文字バージョンの濁点「゛(U+3099)」の2種類があるわけです。(「U+FFFF」の意味については Unicode - Wikipedia あたりを参考にしてください)

この結合文字とはどんなものでしょうか。Windowsのワードパッド*1には、ALT+Xを押すだけでコードポイントを文字に変換できる機能があります。これを使うと結合文字の扱いを簡単に試すことができます。


f:id:nurenezumi:20150116021400p:plain

  1. 普通に「び」「ひ」を入力し、「ひ」の後ろに「3099」と入力

  2. 続いてALT+Xを押下。「3099」がまず結合文字の濁点(U+3099)に置換され、その前の「ひ」と結合して「び」になった


この2つの「び」は見かけ上まったく同じ文字ですが、もちろん実際には別のものです。見た目は同じでも、後者の「び」はあくまでも「ひ」+「゛」の2文字の並びがそう見えているだけですから。したがって、たとえばCTRL+Fで「び」を検索しても引っかかることはありません。……でも、こんなの面倒ですよね?

正準等価・互換等価と正規化

Unicodeでは、このように視覚的・機能的に等価な文字――この2種類の「び」のように、人間にとって区別する必要がまったくない文字――を正準等価(Canonical Equivalent)と呼び、同じ文字パターンに揃えるための手続きが定義されています。簡単に言えば、「合成できる文字はなるべく1文字にまとめてしまう」か、逆に「分解できる文字は全てバラして特定の順序で並べる」ことで、同じ文字は同じパターンに揃えて扱いやすくしまうのです。これが正規化(normalization)と呼ばれる操作です。

「同じ文字」の範囲をもっと広く取る考え方もあります。たとえば「㌫」「㍊」という文字は「パーセント」「ミリバール」を1文字に合成したものだ、だから分解することもできるはずだ、というやや過激な主張です。こちらは互換等価(Compatibility Equivalent)と呼ばれ、やはり“分解”する(前者を後者に置換する)ための手続き*2が定義されています。これも正規化と呼ばれます。

JavaScriptでのUnicode正規化

他の主な言語同様、JavaScriptではString.prototype.normalize()メソッドにより正規化ができます。引数は1つだけで、正規化の種別を指定するために以下の4種類の文字列のいずれかを渡します。

引数 何の略か 処理内容
NFC Normalize Function Composite 正準等価なものをいったん分解し、その後でできる限り合成する(引数省略時のデフォルト)
NFD Normalize Function Decomposite 正準等価なものだけを分解する
NFKC Normalize Function Compativle Composite 互換等価なものもいったん分解し、その後でできる限り合成する
NFKD Normalize Function Compativle Decomposite 互換等価なものも分解する

「㍊びひ゛」(「゛」は結合文字の濁点)をこの4種類の方法でそれぞれ正規化するとどうなるか試してみましょう。

["NFC", "NFD", "NFKC", "NFKD"].map(function(x) { return "㍊びひ\u3099".normalize(x).replace(/\u3099/g, "゛") });
// ["㍊びび", "㍊ひ゛ひ゛", "ミリバールびび", "ミリハ゛ールひ゛ひ゛"]

NFCでは「ひ゛」も1文字になりますし、NFKDでは「ミリバール」の「バ」までもが「ハ゛」に分解されていることが分かります。

Node.js

Node.js上では上記のnormalize()メソッドは実装されていません(ハマった)。同等の処理をピュアなJavaScriptで実装した unorm というプロジェクトがあるので、ありがたく利用させていただきます。

Unicode正規化とファイル名

Windows

Windowsのファイル名として正規化されていない文字列を使うことは可能です。たとえば、前述の2種類の「び」を使えば、見かけ上同じ名前のファイルが同じフォルダに共存できます。

f:id:nurenezumi:20150116032949p:plain

Amazon S3

これをブラウザ経由でS3にアップロードしてもファイル名は保持されます。

気持ち悪いからなんとかしよう

最近誰でも利用できるようになったAWS Lambdaを使うと、S3へのファイルアップロード時に任意のコードを呼び出すことができます。これを使うと何かができそうですね。S3の制約上、オブジェクトのリネームはできず複製することになるのですが、まずはやってみましょう。

AWSのコンソールにログインして"Lambda"を選びます。画面に従って進み、Code Templateに"S3 Get Object"を選択すると、S3へのファイルアップロード時にログを出力するサンプルが動くようになります。あとはこのスクリプトAPIドキュメントあたりを参考にしてちょっと修正するだけです。

console.log('Loading event');
var aws = require('aws-sdk');
var unorm = require('unorm');
var s3 = new aws.S3({apiVersion: '2006-03-01'});

exports.handler = function(event, context) {
   console.log('Received event:');
   console.log(JSON.stringify(event, null, '  '));
   // バケットの名前
   var bucket = event.Records[0].s3.bucket.name;
   // アップロードされたファイルの名前(URLエンコードされている)
   var key = event.Records[0].s3.object.key;

   if (unorm.nfc(decodeURI(key)) == decodeURI(key)) {
      console.log("skipped! : ", decodeURI(key));
      context.done(null, "");
   } else {
      s3.copyObject({
         Bucket: bucket,
         CopySource: bucket + "/" + key,
         Key: unorm.nfc(decodeURI(key))
      },
      function(err, data) {
        if (err) {
            console.log(err, err.stack);
            context.done('error', key);
        } else {
            context.done(null, '');
        }
      });
   }
};

ただし外部ライブラリを利用しているため、このコードにライブラリを同梱したzipファイルを作成してアップロードする必要があります。この辺りの詳しい手順についてはまたそのうち書いてみたいと思います。それでは今回はここまで。

*1:正確にはワードパッドが利用しているリッチテキストコントロール。Wordでも同じことができます

*2:さすがにその逆はありません。「パーセント」→「㌫」の変換ルールはないということです