何度やっても同じ

ただの日記

そういえばImagesServiceで元画像がgifだと画像データがおかしくなる

ネガみたいな色になったり、PNGで出力しても透過背景がまっくろになったり。数年前にも同じ問題で悩んだ記憶はあるのだけど、どうやって解決したのか覚えていなくて再び悩んでしまいました。二度と悩まないようメモっておこうと思う。

解決方法は、この問題が起きるのは開発環境だけだから気にしない

ブロブストアに格納した画像データの扱い方

編集せずそのまま返したい場合

ふつうにBlobstoreからレスポンスに流し込めばおk。

BlobKey key = new BlobKey(asString("key"));
BlobstoreService blobstore = BlobstoreServiceFactory.getBlobstoreService();
blobstore.serve(key, response);

サムネイルを返したい場合

方法を3パターンに分けて紹介します。

ImagesService#getServingUrl をつかう

ImagesService#getServingUrlは、ブロブストアに格納した画像を取得する専用のURLを生成するメソッドです。つまり、毎回サムネイルが要求されるたびに実行するのではなく、画像がブロブストアにアップロードされたタイミングで一度だけURLの生成を行い、画像情報用のエンティティあたりに紐付けておくものです。

たとえばこんなモデルを作ってですね。。。

@Model(schemaVersion = 1)
public class Image implements Serializable {

    // keyプロパティやアクセサなどは省略

    private BlobKey blobKey;

    // ここにgetServingUrlで取得したURLを格納
    private String thumbnailUrl;
}

アップロード後の処理を行うコントローラでこんな感じでURLを生成しておきます。

// asBlobKeyの処理内容は察してね
BlobKey blobKey = asBlobKey("file");

String thumbnailUrl =
    ImagesServiceFactory.getImagesService().getServingUrl(
        ServingUrlOptions.Builder.withBlobKey(blobKey));

Image image = new Image();
image.setBlobKey(blobKey);
image.setThumbnailUrl(thumbnailUrl);
Datastore.put(image);

生成されるURLのメリットは高速であること、アプリケーションインスタンスが不要であることです。デメリットは、画像操作がリサイズとトリミングしかできないこと、publicなURLしか生成できないこと。とはいえサムネイル用途なら他の画像操作は通常不要ですし、URLは推測不可能な文字列なので、公開ページにリンクさえ張らなければ事実上のprivateなURLとしても扱えます。要するに、サムネイル用途ならgetServingUrlを使っておけば無難です。

getServingUrlにはオプションとして画像サイズやトリミングを行うかどうかの指定ができますが、これらの指定は一切しないことをオススメします。なぜかというと、サイズとトリミング指定は、基本となるURLにパラメータを付け加えることで実現できるからです。URLに固定でパラメータがついてると柔軟性がなくめんどくさいだけです。

URLにパラメータをつけるときのルールは以下の通り(Imagesサービスの概要ページからの抜粋)。URL末尾に =s32 みたいな文字列を付加するルールです。パラメータといってもクエリ文字列ではありません。

// Resize the image to 32 pixels (aspect-ratio preserved)
http://your_app_id.appspot.com/randomStringImageId=s32

// Crop the image to 32 pixels
http://your_app_id.appspot.com/randomStringImageId=s32-c

画像サイズの縦と横を別々に指定することはできません。縦横比率が維持されない使い方はできなくて(できる必要もないですが)、縦と横のうち長い方が指定サイズをオーバーしないように画像の縮小が行われます。

もうひとつささいなことですが個人的にはポイントだった特徴がありまして、getServingUrlのオプションで指定する画像サイズは、縮小されることがあっても拡大はされません。これはサムネイル生成の振る舞いとしては自然だと思うのですが、プログラムでブロブストアからImageオブジェクトを構築する手法(後述)ではこれを実現するのがなんだか面倒なのです。

画像が削除された場合などにURLを無効にしたい場合は、deleteServingUrlメソッドを使います。

ImagesServiceFactory.makeImageFromBlob をつかう

ブロブストアから画像を読み込んでImagesServiceで操作する手法です。以下のような感じ。binaryメソッドは勝手に作りました。処理内容は察してください。

Image image = ImagesServiceFactory.makeImageFromBlob(blobKey);
Transform resize = ImagesServiceFactory.makeResize(80, 80);
ImagesService service = ImagesServiceFactory.getImagesService();
image = service.applyTransform(resize, image, OutputEncoding.PNG);
byte[] data = image.getImageData();

return binary(data, "image/png");

この方法で困ってしまうのは、オリジナル画像が指定サイズよりも小さかった場合に、画像が拡大されてしまうことです。逆にそれを求めている場合や、その他(縮小とトリミング以外)の操作をしたい場合はgetServingUrlではなくこちらの方法を選択することになります。

ただ、また困ったことに、ブロブストアからmakeImageFromBlobで読み出したImageオブジェクトはいくつかのメソッドに対応しておらず、呼び出すとUnsupportedOperationExceptionを投げてきます。たとえば、上記の例で、画像の拡大はしたくないということで次のようなコードを書くと、Image#getWidthとImage#getHeightが使えないエラーが発生します。なんて理不尽なんでしょう。

Image image = ImagesServiceFactory.makeImageFromBlob(blobKey);
if (image.getHeight() > 80 || image.getWidth() > 80) {
    Transform resize = ImagesServiceFactory.makeResize(80, 80);
    // ...
}

これでは不便に感じることもありますよね。というわけで、最後のパターンです。

ブロブストアを自力で読み出してImageオブジェクトをつくる

サンプルソース書きます。単にブロブストアから読み出したデータでImageオブジェクトを作成しているだけなので、ふつうImagesServiceでできることは制限なく行えます。前記2つの方法でできないことをやりたい場合はこの方法を選択です。

BlobKey blobKey = new BlobKey(asString("id"));
BlobInfo info = new BlobInfoFactory().loadBlobInfo(blobKey);
int size = (int) info.getSize();
String contentType = info.getContentType();

BlobstoreService blobstore = BlobstoreServiceFactory.getBlobstoreService();
ByteArrayOutputStream out = new ByteArrayOutputStream();
int start = 0;

do {
    int end = Math.min(size, start + BlobstoreService.MAX_BLOB_FETCH_SIZE) - 1;
    byte[] buf = blobstore.fetchData(blobKey, start, end);
    out.write(buf);
    start = end + 1;
} while (start < size);

Image image = ImagesServiceFactory.makeImage(out.toByteArray());
if (image.getHeight() > 80 || image.getWidth() > 80) {
    Transform resize = ImagesServiceFactory.makeResize(80, 80);
    ImagesService service = ImagesServiceFactory.getImagesService();
    image = service.applyTransform(resize, image, OutputEncoding.PNG);
    contentType = "image/png";
}
byte[] data = image.getImageData();

return binary(data, contentType);

Silm3でリクエストパラメータをオブジェクトモデルに自動変換

employees[0].name
employees[0].address.zipCode
employees[0].address.line1
employees[1].name
employees[1].address.zipCode
employees[1].address.line1
...

たとえばこんなリクエストパラメータを受け取って、EmployeeとAddressオブジェクトのグラフを勝手に構築してほしい的な話です。実装してみた。

https://github.com/fxfan/Slim3.StructuredParamRequestHandler

プロパティの型が文字列しか対応してない点とか少々いいかげんですが。ぼちぼち手を加えていきます。

TwitterのOAuth認証プロセスがリダイレクトループしたとき

原因はいくつかパターンがあるようですが、サーバサイドがJavaの場合、誤ったURLリライティングによってリダイレクトループに陥ることがあります。というか、陥りました。

一見問題なさげな次のコードですが(slim3使ってます)。。。

StringBuffer callbackURL = request.getRequestURL();
int index = callbackURL.lastIndexOf("/");
callbackURL.replace(index, callbackURL.length(), "").append("/callback");

Twitter twitter = TwitterService.getTwitterInstance();
RequestToken requestToken = twitter.getOAuthRequestToken(callbackURL.toString());
sessionScope("requestToken", requestToken);

return redirect(requestToken.getAuthenticationURL());

slim3はこの後、redirectメソッドに渡したパスに対してresponse.encodeRedirectURL()を呼び出しておりまして、このコードだとrequestToken.getAuthenticationURL() の戻り値であるtwitter apiのURLをリライティングしてjsessionidをくっつけてしまう場合があるのです。次のような感じに。

http://api.twitter.com/oauth/authenticate;jsessionid=xxxxxx?oauth_token=xxxxxx

URLがこうなってしまうと、あとはtwitter内部の問題なので詳細は知りませんが、リダイレクトループに陥ってしまいます。

これを回避するには、こう。

StringBuffer callbackURL = request.getRequestURL();
int index = callbackURL.lastIndexOf("/");
callbackURL.replace(index, callbackURL.length(), "").append("/callback");

// こっちをURLリライティング
String rewritedURL = response.encodeRedirectURL(callbackURL.toString());

Twitter twitter = TwitterService.getTwitterInstance();
RequestToken requestToken = twitter.getOAuthRequestToken(rewritedURL);
sessionScope("requestToken", requestToken);

// 勝手にURLリライティングしないように自分でリダイレクトロジック書く
response.sendRedirect(requestToken.getAuthenticationURL());
return null;

BlobstoreService + slim3 でプチはまり

Blobstoreにファイルをアップロードするのといっしょにサーバにパラメータを渡す必要があったので、実現方法を二つほど考えたのですが、

  • multipartで送る
  • Blobアップロード後に呼び出されるパスにあらかじめクエリ文字列でパラメータを埋め込んでおく

どちらの方法でも渡せないのでした。いや、サーバには渡せているのだけど、どちらもslim3によって処理されないのです(前者の方法は開発環境では何故か問題ないみたい)。なので、アップロード後に呼ばれるコントローラは次のような状態です。別にこれでもいいんですけどね。。。

String id = asString("id"); // ぬるっと
String id = request.getParameter("id"); // これなら取得できる

これでは不満な場合、以下のように解決できそう。

まずこんなクラスを作ってですね。。。

public class BlobUploadedRequestHander extends RequestHandler {

  public BlobUploadedRequestHander(HttpServletRequest request) {
    super(request);
  }

  @Override
  public void handle() {
    super.handle();
    BlobstoreService blobstore = BlobstoreServiceFactory.getBlobstoreService();
    Map<String, List<BlobKey>> map = blobstore.getUploads(request);
    for (Map.Entry<String, List<BlobKey>> e : map.entrySet()) {
      request.setAttribute(e.getKey(), e.getValue());
    }
  }
}

アップロード後の処理をするコントローラを次のように。ついでに共通の親コントローラにasBlobKeyメソッドとか定義しておくと更にすっきり。

public class UploadController extends OreOreController {

  @Override
  protected RequestHandler createRequestHandler(HttpServletRequest request) {
    return new BlobUploadedRequestHander(request);
  }

  @Override
  public Navigation run() throws Exception {

    Validators v = new Validators(request);
    v.add("id", v.required(), v.longType());
    v.add("file", v.required());
    if (!v.validate()) {
      return forward("xxx.jsp");
    }

    long id = asLong("id");
    BlobKey blobKey = asBlobKey("file");

    // なんか処理
  }
}

GAEJ PDFのテキスト抽出

Apache PDFBoxを使ってPDFのテキスト抽出を行おうとすると、java.awtパッケージのクラス(GAEJのホワイトリストに含まれない)を使っている関係でエラーが発生します。テキスト抽出にjava.awt関連クラスは不要なので、使用個所を削りまくってGAEJ上で動作するjarを作りました。ベースにしたバージョンは1.7.1です。

デモサイトのソースコードslim3
public class UploadController extends Controller {

  @Override
  public Navigation run() throws Exception {

    BlobstoreService blobstore = BlobstoreServiceFactory.getBlobstoreService();
    Map<String, List<BlobKey>> map = blobstore.getUploads(request);
    List<BlobKey> keys = map.get("pdfFile");
    if (keys == null || keys.isEmpty()) {
      return html("<p>ファイルが選択されてないかも</p>", "utf-8");
    }

    BlobKey key = new BlobKey(keys.get(0).getKeyString());
    PDFParser parser = new PDFParser(new BlobstoreInputStream(key));
    parser.parse();
    PDDocument doc = parser.getPDDocument()
    PDFTextStripper stripper = new PDFTextStripper();
    response.setContentType("text/plain; charset=utf8");
    stripper.writeText(doc, response.getWriter());

    blobstore.delete(key);

    return null;
  }
}

GAEJ Blobstoreのでっかいファイルをダウンロード

BlobstoreService blobstore = BlobstoreServiceFactory.getBlobstoreService();
BlobKey key = new BlobKey(asString("key"));
response.setHeader("Content-disposition", "attachment; filename=hogehoge");
blobstore.serve(key, response);

何か理由があって自分でがんばるときは。

BlobKey key = new BlobKey(asString("key"));
BlobInfo info = new BlobInfoFactory().loadBlobInfo(key);
int size = (int) info.getSize();

response.setContentType(info.getContentType());
response.setContentLength(size);
response.setHeader("Content-disposition", "attachment; filename=" + info.getFilename());

BlobstoreService blobstore = BlobstoreServiceFactory.getBlobstoreService();
int start = 0;

do {
  int end = Math.min(size, start + BlobstoreService.MAX_BLOB_FETCH_SIZE) - 1;
  byte[] buf = blobstore.fetchData(key, start, end);
  response.getOutputStream().write(buf);
  start = end + 1;
} while (start < size);
        
response.flushBuffer();