iYM2151 : Fairyland from HYDLIDE3

先週YoutubeにはUpしていたハイドライド3のFairyland。

iYM2151のファイル貼ってなかったのでこちらに。動画あげた後、シンセの音だけちょっといじった。

iYM2151用iymファイル

ハイドライド3はT&Eが誇るActive RPGの3作目にして完結編。1987年の作品。音楽は当時T&EのBGMの多くを担当していた富田茂氏。当時のパソコンについていた音源はSSG(PSG)かFM音源の2択だけど、ハイドライド3の音楽はSSGという音源あってこそ作れる世界だった。ピーとかポーとかいう音しか出ないことを逆手にとって、明るくソフトな独特の世界観を作り出していた。実際、X68やX1(FM音源ボード版)ではSSGがなくその分FM音源が豪華なYM2151用にアレンジし直して、激しく失敗していた感がある(個人的な意見です)。

今回作成したFairylandはフィールドのBGM。一応オリジナルに似せる努力をしたつもり…だけど、後半のシンセの音は似なかったなぁ。音を重ね合わせるイメージで単純な波形を組み合わせれば素人でも組めてしまう後半4アルゴリズムと違って、前半はそれなりにFM音源をわかっていないと使いこなせないもので、どうも苦手。4オペでこれだけ難しいんだから、6オペFM音源を使いこなす人とかはほんと尊敬する。どういう頭の構造しているんだろう。

iYM2151も風前の灯火な感じだけど、よかったらお楽しみください。

Reading JAR/Zip Archive Comment (JDK1.6)

前回の話で、Pack200で圧縮するとJARファイルのファイルサイズがかなり小さくなり、JNLPのパフォーマンス向上に大きく寄与することがわかった。しかし、この高い圧縮率は

pack → unpack → jarsigner  →pack

という誰がどう見ても非効率なプリプロセスを前提としており、ネットワーク上の転送時間は高速化されるものの、その代償としての圧縮処理のパフォーマンス悪化があまりにも大きすぎる。特に最初のノーマライズをなんとかしたい。

もちろん、普通は配布前に一度だけオフラインバッチで流しておけばそれで問題ない。プリプロセスに少し手間をかけることで、本番でのネットワーク転送を高速化する、ここがPack200の本来の目的だろう。でも自分の場合もとはといえば、jarsignerの署名をオンデマンドで実行するアプリの高速化が目的で、テスト環境への配布がメインであるためリリースも多い。jarsignerまでオフラインに戻してしまうと、また署名忘れやバージョン不一致が起こりかねない。

かといって、この無駄に長い圧縮処理をそのままオンデマンドの処理に詰め込むとパフォーマンスが悪すぎて使い物にならない。

JAR.isNormalized?

そこで一つ考えたのが、最初のnormalizeを省略すること。文字通り丸っと省略するとさすがに署名検証エラーになるので、当然のことながら条件付きである。仮に、署名対象のJARが最初からnormalizeされているものであるとしたら、わざわざ再度pack/unpackするのは無駄以外の何ものでもない。だからJARファイルを見てnormalizeされているかどうかを事前に知ることができれば、最初のpack→unpackのステップを丸ごと省略できることになる。

この判定ができるのなら、最初からnormalizeされているJARはオンラインの自動処理でjarsigner → pack200してクライアントに送信、normalizeされていなければjarsignerだけかけて送信(normalizeとpackをかけてもいいけど、遅いのでここではしないことにする)、といった感じで処理を分けることができる。

まぁ、当然のことながら最初からnormalizeされているJARなんて存在しないので、pack200形式で流すためにはどこかで事前にpack200 –repackを流しておく必要はある。とすると、jarsignerだけオンライン/オンデマンドで、normalizeがオフライン/バッチに、とわざわざ分割する意味があるのか、jarsignerまで含めて全部バッチの方がいいんじゃないか、という意見もでるとは思う。

ここであえて2つに分けて考えるのにはもちろん理由がある。jarsignerをオフライン処理にしてしまうと署名忘れ=即障害になってしまうわけだけど、normalizeだけをオフラインバッチにすれば万が一normalizeし忘れたJARがあったとしても、最低限jarsignerはかかるのでエラーを回避することができる。本番運用では署名忘れなんてあってはいけないので論外で、事前のリリースプロセスにじっくり時間をかけて準備万端で挑むものだろう。しかしテスト環境ではリリースの速さとかダウンタイムの最小化が重要になってくるわけで、じっくり時間をかけてリリースに成功しても誰も褒めてはくれないし、そんなことをしていたら依頼されたリリース作業の半分もこなせない。jarsignerだけは何があっても必ずかかっている、ということに確信が持てるだけで、障害報告を受けたときに「jarsignerがうまくいっていないのかも?」という可能性をいきなり排除できるわけで、調査に無駄な時間を使うことを避けられるのだ。

Zipアーカイブコメントを読む

そんなわけで、JARファイルがnormalizeされているかどうかを調べる方法。unpackされたJARを判別するには、JARファイルにPACK200というアーカイブコメントがついているかどうかで判断できる。めったに使わないけど、Zipファイルってアーカイブレベルとファイルエントリーレベルでコメント書けるんだよね。

Javaの場合、アーカイブコメントはZipFileクラスのgetComment()で取得できる。しかしこのメソッド、残念なことに使えるのはJava SE 7からである。今開発しているシステムはJava SE 6なので、使えない。

とはいっても、まぁアーカイブコメントくらいなら、バイナリ形式が公開されているファイル形式だし何とかなるんじゃないかとおもって仕様書見ながら書いたのが以下のコード。

import java.io.*;
import java.nio.*;
import java.nio.channels.*;
import java.nio.charset.*;
import java.math.*;
import static java.nio.channels.FileChannel.MapMode.*;

public class ZipParser {

  private final static int ARC_COMMENT_OFFSET = 22;
  private final static int MAX_COMMENT_LENGTH = (int) Character.MAX_VALUE;
  private final static int CENTRAL_DIR_LENGTH = ARC_COMMENT_OFFSET + MAX_COMMENT_LENGTH;

  public static void main(String... args) throws Exception {
    File source = new File(args[0]);
    if (Integer.MAX_VALUE < source.length()) {
       throw new IllegalArgumentException("File larger than 2GB is not supported.");
     }
     // Reverse iterate from end of file.  Stop search when;
     // 1) reached begining of file or
     // 2) scanned size of CENTRAL_DIR_LENGTH
     FileInputStream stream = new FileInputStream(source);
     try {
       long searchLimit = Math.max(0L, source.length() - CENTRAL_DIR_LENGTH);
       int i = (int) source.length();
       MappedByteBuffer buf = stream.getChannel().map(READ_ONLY, 0, (int) i);
       buf.order(ByteOrder.LITTLE_ENDIAN);
       while (i > searchLimit)
        if (0x06 == buf.get(--i))         // 0x06
          if (0x05 == buf.get(--i))       // 0x05
            if (0x4B == buf.get(--i))     // K
              if (0x50 == buf.get(--i)) { // P
                buf.position(i + ARC_COMMENT_OFFSET - 2 /* sizeof(short) */);
                break;
              }
      if (i >= searchLimit) {
        char commentLength = buf.getChar();
        if (0 < commentLength) {
          byte[] commentBinary = new byte[commentLength];
          buf.get(commentBinary, 0, commentLength);
          System.out.println(new String(commentBinary, Charset.forName("UTF-8")));
          return;
        }
        System.err.println("No archive comment found.");
        System.exit(1);
      }
    } finally {
      stream.close();
    }
  }
}

軽く説明

Zipファイルはファイル毎にエントリーがあって最後にcentral directoryという構造体がある。アーカイブコメントはこのcentral directoryの一番最後にあるend of central directoryについているので、今回の目的に限っていえば極端な話、このend of central directoryだけ検査すれば良い。

end of central directoryは22バイト+最大64KBの可変長アーカイブコメントからなる。ファイルの中身に全く興味がない以上頭から読んでいくのは馬鹿馬鹿しいので、ファイルの一番後ろから上に向かって最大65,558バイト分まで順番に読んでみて、end of central directoryのsignatureである0x06054b50(‘P’,’K’,0x05,0x06)を探す。

end of central directoryのsignatureを見つけたら晴れてend of central directoryの構造体が作れるわけだけど、今回はコメントにしか興味がないのでそれ用のclassは作らない。代わりに、最後の2バイトがアーカイブコメントの長さを表していることが分かっているので、いきなりoffset20バイト目を読んじゃう。16ビット整数はJavaだとshortだけど、この場合長さなので負の数ということはないはずで、Cでいうところのunsigned shortで取りたいのでByteBuffer#getShort()ではなくByteBuffer#getChar()で取る。長さが0よりも大きければ、シグネチャからのoffset22バイト目〜長さ分のbyte[]をもってきて、文字列に変換。

とりあえずこんな感じで無事、normalizeしたJARからPACK200が取れた。バイナリを読む処理とか久しぶりに書いたけどNew I/Oって便利だな。もっと広範囲につかわれてもいいAPIな気がするわ。

JNLP Performance Tuning with Pack200

今時どうかとは思うんだけど、今参画しているプロジェクトでJava Web Startのアプリを提供している。先日これをユーザーテスト環境へ配ったところ「以前のバージョンと比較して遅い」と苦情がでているそうで、パフォーマンスチューニングをさせられるはめに。

そもそもパッケージソフトについているライブラリのサイズが前バージョンよりも全然でかいので、同じ時間で出せるわけねーだろ、とかフツーの人なら考えるのだが。そういう事実をお客さんに何も説明しないまま「お客さんが遅いって障害票あげているからなんとかしろ」って話を一方的に受けてくるのはエンジニアとしてどうかと思うんだが、まぁ俺はその場にいなかったので今更騒いでも後の祭り。こんな感じで中身がよく分かってない人たちがものをつくったり運営したりすると、まじめに調べて仕事している方はいろいろ納得いかないことが多くて、ほんとストレスがたまる。

でもまぁ、そんな納得のいかない話を黙って受けたのは、その原因にまったく思い当たらないわけでもなかったから。このアプリはJavaのセキュリティでフルアクセスが必要なのでjarsignerの署名が必須なのだけど、実は対象ファイルの数が数百と多く、サイズも大きいため(全部で300MBくらい)、Deploymentの際に署名したJARを準備するのがかなり面倒くさい。それを今まで手作業で回していたのだから、署名し忘れたりとかクライアントとサーバーでバージョン違いのものが置かれていて「Unmarshalできねーよ例外」がでるとか、リリース関連の問題にもなっていたわけで。

今回はそこら辺を改善するために、JNLP/JARダウンロード用のServletを書いた。こいつは初回アクセス時にサーバー側のJARを持ってきてコッソリjarsignerを呼び出して署名をつけ、クライアントに送りつける仕組み。なので万が一署名し忘れても裏で勝手に署名されるからエラーになることはほとんどないし、サーバー側とバージョンがずれることもない。ただし、署名している時間がかかることは当然として、さらには既に署名済みJARが存在するか、存在する場合は前に署名されてから元ファイルが更新されていないか(再署名が必要か)、などのいくつかのチェック処理が入るので、単純に要求されたファイルをWebサーバーからストリームするのと比較すると、あちらこちらのファイルを見に行かなければならない分、多少オーバーヘッドがある。

JARに署名する時間はJarSignerクラスに任せている関係でほとんど調整の余地がないので、他の部分で埋め合わせするしかない。それで署名が終わった後のダウンロード時間を短縮するいい方法はないかと調べていて見つけたのが、署名済みJARをそのままダウンロードさせる代わりに、Pack200フォーマットというより高圧縮な形式を並行で用意することでダウンロードするファイルのサイズを大幅に削減する方法。実はJava 1.5の時代からあったらしい。全然知らなかった。そういえばeclipseの更新サイトに転がっていたよpack.gz。あれか。

Pack200形式と署名付きJAR

Pack200形式のファイルを作るにはJARを作ってからpack200コマンドにjarファイルを食わせれば良い。長年JavaやってるけどJDKにそんなコマンドあったのか!って感じでした、俺。

こんな感じか。

[~] cd classes
[~/classes] jar cf ../hoge.jar *
(略)
[~/classes] cd ..
[~] pack200 hoge.pack.gz hoge.jar

Pack200というのはJava特有の最適化を行うことで、JARの中身を不可逆圧縮する仕組みらしい。もちろん不可逆といってもJPEGとかMP3のように劣化してしまうのではなく、Java的に見て意味が変わってしまうような変更はされない。ただ、圧縮率を稼ぐために各種並べ替え等が発生し、バイナリレベルではかなり変わってしまう。これではバイナリに対してデジタル署名しているJARをpack200にかけるとせっかくのデジタル署名が全部無効になってしまい、使い物にならない。

JARのNormalize

署名済みJARのデジタル署名を壊さないためには、JARのnormalize作業が必要になる。一度Pack200形式に圧縮して、それをunpackすると元のJARファイルが出てくるわけだけど、このときに出てくるJARファイルはpack200向けに最適化されたレイアウトに変更されている(だから不可逆圧縮なんですね)。このことをnormalizeするという。これをもう一度pack200しなおしてもレイアウトの変更は行われないので、つまるところ、normalizeされたJARに対してjarsignerを実行すれば、pack200しても署名が無効にならずに済むというわけだ。

とはいっても、normalizeするだけのためにpackして即unpackするのも馬鹿馬鹿しい。だからそういうことをしないでも済むように、pack200には–repackというオプションが用意されている。これをつけて呼び出すとpack.gzを作るのではなく、JARファイルのnormalizeだけを実施する(–repackの場合pack.gzは作成されない)。

[~] ls -al commons-cli-1.0.jar
-rw-r--r--  1 debugjunkie  staff  30117  7 13 18:24 commons-cli-1.0.jar
[~] pack200 --repack commons-cli-1.0.jar
[~] ls -al commons-cli-1.0.jar
-rw-r--r--  1 debugjunkie  staff  29754  7 13 18:25 commons-cli-1.0.jar

この後にjarsignerを呼べば良い。

[~] jarsigner -keystore hoge.key -storepass hogepass commons-cli-1.0.jar hoge

んで、最後にpack200。こんな感じで、笑っちゃうくらい小さくなる。

[~] pack200 commons-cli-1.0.pack.gz commons-cli-1.0.jar
[~] ls -al commons-cli-1.0*
-rw-r--r-- 1 debugjunkie staff 32903 7 13 18:30 commons-cli-1.0.jar
-rw-r--r-- 1 debugjunkie staff 13912 7 13 18:31 commons-cli-1.0.pack.gz

署名もちゃんと検証可能。

[~] jarsigner -verify commons-cli-1.0.jar
jar verified.

Warning:
This jar contains entries whose certificate chain is not validated.

Re-run with the -verbose and -certs options for more details.

ただしjpgとかpngとかリソース系のものがたくさん入っているJARファイルだとpack200はむしろ逆効果で、倍以上のサイズになったりする場合もある。だから、Deploymentの前にちゃんとサイズを見ておく必要がある。通常のJARの方が小さい場合はpack.gzをおかないようにする方がいいだろう。

Server側の返し方

JNLPでは<property name=”jnlp.packEnabled” value=”true”/>をjnlpファイルに書いておくと、HTTP要求のaccept-encodingヘッダにpack200-gzipが付加されて送られるようになる。pack.gzをサポートしますよ、とクライアントから意思表示してもらうのであって、JNLPに「オマエはpack.gzを取りに来いや」とファイル名を直接書くのではないことに注意する。pack.gzはあくまでも最適化の一環であり、pack200をサポートしないクライアントも含めた全てのクライアントが正しくファイルを受信できるよう、pack200-gzipがaccept-encodingにない場合は通常のJARファイルを返せるようにしなければいけない。

Server側ではpack200-gzipが指定されていて、該当JARファイルに対応するpack.gzファイルがあればそちらを返し、なければjarファイルを返すようServletを実装しておけば良いだろう。このときのHTTPヘッダはJARファイルの場合は


content-type: application/x-java-archive

pack.gzの場合は


content-type: application/x-java-pack200
content-encoding: pack200-gzip

と返すようにする。あとはjavawsがよきにはからって処理してくれる。はず。

ちなみにJDKのSampleにこの辺をサポートしているJnlpDownloadServletなるものがあるらしいから、それを使うのがいいのだろう。自分はつい先日までそのサンプルの存在を知らなくて、自分で書いてしまったけど。

早くこの世からなくならないかなぁ、Java Web Start。