文字列を指定の文字エンコーディングでのバイト数で切る

文字列を指定の文字エンコーディングでのバイト数で切る処理を作ってみた。固定バイト長の文字コードであれば指定のバイト長で切る処理というのはさほど難しいところはないのだが。

たとえば、"1234" という ASCII 文字列を、3 バイトで切りたい場合、"123"。文字数とバイト数が一致するため、もっとも簡単だ。


しかし、ShiftJIS 等の主要な文字コードでは、文字種によってバイト長が変化する。結局のところ、指定のバイト数に何文字まで入るか、というのはその文字コードに変換してみないとわからない。


Java では文字の内部表現は普通 UTF-8 だけど、DB では EUC で、カラム長は 256 バイト。というような場合。カラム長がバイト指定なのが全部悪いんだが。


で、先日見たとあるロジックはこの「文字列を指定の文字エンコーディングでのバイト数で切る処理」を実装してたのだが、なかなか気になる処理だった。簡単に言うと、

"あいう".getBytes("EUC-JP").length;

で指定エンコーディングでのバイト長が取得できることを利用して、切り捨て対象文字列を

"あ"

からはじめて、以下1文字ずつ増やして、

"あい”
"あいう"

... と、バイト長が指定バイト数を越えるまでループする、という処理をしていたのだ。ソースコードを載せた方が話が早いが職場の持ち物なのでそれはできない。ご容赦を。たしか、この「一文字ずつ増やす」という処理には、元の文字列に毎回String.substring() をかけることで実現していたように記憶している。


で、この処理、無駄はあるものの、256 byte 程度の文字列を対象に動かす程度であれば何の問題も無いだろう。ただ、多少なりとも対象文字列が大きくなってくると、とたんにまずい。メモリを食いすぎる。自分のPCで動かしてみたところ、30KB 程度で処理時間が1秒を越える状態になり、50KB 程度で処理が返ってこなくなり、Eclipse ごと強制終了するハメになった。


このような処理を、getBytes() を使って無理にやるよりは、NIO を使用するのがいいんじゃなの?と思って以下のような、StringTruncator を実装してみた。


要するに、指定のバイト数分だけバッファを作成し、そのバッファに対して、「あふれるまで」エンコードさせるのだ。あふれたらそのポイントがちょうどそのバイト数ということになる。
仕組みとしてとても単純なことだが、手元で動かして見たところ、100 MB の文字列を 100MB で切る、という無理のある処理をしても 1.8 秒程度、50MB で切るなら 0.9 秒程度で処理が終了する。つまり元の処理より 1000 倍以上高速化したことになる。メモリの使用量も全然違うし。


若干工夫したのは、大きいサイズ(数十MBとか、数百MBとか。)の文字列の先頭数バイト分だけを切り出したいような時の処理を無駄にメモリを使わないようにしたのと、文字列に対して、明らかに長い場合、そのまま返すようにしているあたり。どの程度長ければ「明らかに」なのか、というと、


「指定された文字エンコーディングの最大バイト数*文字数」を上回る「バイト数指定」


があったときだ。例えば、EUC であれば、まれに 3byte の文字が存在するので、そのような文字だけで構築された文字列を渡された場合がワーストケースとなるのだが、指定バイト数がそのワーストケースを上回った場合、わざわざ変換を試みるまでもなく全長が入ることは明らかなので、そのまま返しているということだ。


数10KB から 数十MB バイト 程度のサイズを指定して数万回実行させても特段問題は無かった。NIO とかバッファ系は使いすぎると極端に可読性が下がるので、使用は慎重になるべきだが、要所で使えば効果はでかいよということだろう。


ちなみに、元の処理を、NIO を使わずに、getBytes() を使ったまま書き直しても数 MB 程度の文字列に対して1秒程度で返すようにするくらいまでは簡単に書き直せる。元の処理は NIO 云々じゃなくて、ロジックに問題があるのだ。通常の用途であれば NIO を使用しなくてもこれで十分だとは思う。(そもそも getBytes() の内部処理では NIO を使用しているんだし。。。)

import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;
import java.nio.charset.CoderResult;
import java.nio.charset.UnsupportedCharsetException;

/**
 * 指定した文字エンコーディングでのバイト長で文字列をカットする。
 * 
 * <pre>
 * "あいうえお" --> EUC で 10 byte
 *             --> UTF-8 だと 15 byte
 * 
 * 半角カナなど入った場合は文字コードによってバイト数が 1 byte になったり、
 * 3 byte になったりします。DBの文字コードがAPサーバ側と異なっていて、
 *  DB に入るかどうか、APサーバ側で確認してから入れたいことがあります。
 * 
 * 本機能は「指定されたエンコーディングでのバイト数」を見て、文字列をカットし、
 * 返却する機能です。
 * 
 * StringTruncator.trunc("あいうえお", 7, "EUC-JP")   --> "あいう"
 * StringTruncator.trunc("あいうえお", 7, "UTF-8")    --> "あい"
 * StringTruncator.trunc("1234567890", 7, "UTF-8")   --> "1234567"
 * StringTruncator.trunc("1234567890", 7, "UTF-16")  --> "12"
 *  
 *  というような文字列が返却されます。
 *  </pre>
 *  
 * @author kamei
 */
public final class StringTruncator {
	private StringTruncator() {};
	/**
	 * 簡易版。<br>
	 * 
	 * エンコーディング処理が CharsetEncoder のデフォルト動作で問題無い場合は、
	 * こちらを使用してください。
	 * 
	 * @param str 処理対象となる文字列
	 * @param capacity カットしたいバイト数
	 * @param csn 文字エンコーディング("EUC-JP", "UTF-8" etc...)
	 * @return カットされた文字列
	 * @throws UnsupportedCharsetException 文字エンコーディングが不正
	 * @throws CharacterCodingException エンコーディング不正
	 */
	public static final String trunc(final String str, final int capacity, final String csn)
			throws UnsupportedCharsetException, CharacterCodingException {
		CharsetEncoder ce = Charset.forName(csn).newEncoder();
		if (capacity >= ce.maxBytesPerChar() * str.length())
			return str;
		CharBuffer cb = CharBuffer.wrap(new char[Math.min(str.length(), capacity)]);
		str.getChars(0, Math.min(str.length(), cb.length()), cb.array(), 0);
		return trunc(ce, cb, capacity).toString();
	}
	
	/**
	 * 詳細・高速版。<br>
	 * 
	 * エンコーディング処理を指定したい場合、CharsetEncoder を指定できる本機能を使用してください。
	 * エンコーディング中にエラーが出ても、その文字を「■」に置き換え、処理継続、といったような場合です。
	 * 
	 * 呼び出し側でバッファの確保を行いますので、繰り返し処理の中で本機能を呼び出すような場合は、
	 * 簡易版より高速になる場合があります。(1割から3割程度です。)
	 * 
	 * 内部でエンコードした文字列を置くためのワークエリアとして capacity バイト分の
	 * バイトバッファを作成します。
	 * 
	 * @param ce エンコーダ。
	 * @param in 処理対象となる文字列のバッファ
	 * @param capacity カットしたいバイト数
	 * @return in にて渡されたバッファを指定バイト数で flip() したもの
	 * @throws CharacterCodingException エンコーディング不正
	 */
	public static final CharBuffer trunc(final CharsetEncoder ce, final CharBuffer in,
			final int capacity) throws CharacterCodingException {
		if (capacity >= ce.maxBytesPerChar() * in.limit())
			return in;
		final ByteBuffer out = ByteBuffer.allocate(capacity);
		ce.reset();
		CoderResult cr = in.hasRemaining() ? ce.encode(in, out, true)
				: CoderResult.UNDERFLOW;
		if (cr.isUnderflow())
			cr = ce.flush(out);
		return (CharBuffer)in.flip();
	}
}