何度やっても同じ

ただの日記

HuggingFaceの日本語埋め込みモデルをJavaから使う

先日ScalaMatsuriで聞いたセッションの内容を後追いで自分でやってみたメモ。 https://scalamatsuri.org/ja/programs/SESSION_DAY_1_06

そのままScalaで試してもいいけれど、Javaで書いておくほうがScalaの人もKotlinの人も参考になるかなということで、Javaです。

optimumのインストール

まずはHuggingFaceがホストしているモデルをONNXフォーマットに変換するために optimum をインストール。

venv ❯ python -m pip install optimum
venv ❯ pip install onnx
venv ❯ pip install onnx onnxruntime

# 必要であれば
venv ❯ pip install numpy==1.26.4

optimumが使っているnumpyはバージョン2.xと互換性がない。2.x系がインストールされている場合はバージョンダウンを行っておく。

ONNX形式ファイルの取得

venv ❯ optimum-cli export onnx --model sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 ./

これでカレントディレクトリに model.onnx が作成される。

Javaから使う

モデルの読み込み

前項で入手したモデルファイルは src/main/resources/model.onnx に配置したとして。モデルの読み込みと、一応、入力情報の確認。

Path modelPath = Paths.get(Main.class.getResource("/model.onnx").getPath());
OrtEnvironment env = OrtEnvironment.getEnvironment();
OrtSession sess = env.createSession(modelPath.toString());

System.out.println("### Input Info");
System.out.println(sess.getInputInfo().entrySet().stream()
    .map(e -> e.getKey() + ": " + e.getValue())
    .collect(Collectors.joining("\n")));

トークン分割

トークン分割して、このモデル用の語彙IDリストに変換するために、 ai.djl.huggingface.tokenizers.HuggingFaceTokenizer を使う。

String modelName = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2";
String text = "ジョバンニは、口笛を吹いているようなさびしい口付きで、檜のまっ黒にならんだ町の坂を下りて来たのでした";
HuggingFaceTokenizer tokenizer = HuggingFaceTokenizer.newInstance(modelName);
Encoding encoding = tokenizer.encode(text);
long[] inputIds = encoding.getIds();
long[] attentionMask = encoding.getAttentionMask();

実行と結果確認

OrtSession.Result result = sess.run(input);

String resultString = StreamSupport.stream(result.spliterator(), false)
    .map(e -> e.getKey() + ": " + e.getValue())
    .collect(Collectors.joining("\n"));
System.out.println("### Result");
System.out.println(resultString);

String embedding = result.get("sentence_embedding")
    .map(val -> {
        if (val instanceof OnnxTensor tensor) {
            float[] array = new float[tensor.getFloatBuffer().remaining()];
            tensor.getFloatBuffer().get(array);
            return Arrays.toString(array);
        } else {
            return "Unexpected type for sentence_embedding";
        }
    })
    .orElse("sentence_embedding not found");
System.out.println("### Sentence Embedding");
System.out.println(embedding);

出力。

### Input Info
input_ids: NodeInfo(name=input_ids,info=TensorInfo(javaType=INT64,onnxType=ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64,shape=[-1, -1],dimNames=[batch_size,sequence_length]))
attention_mask: NodeInfo(name=attention_mask,info=TensorInfo(javaType=INT64,onnxType=ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64,shape=[-1, -1],dimNames=[batch_size,sequence_length]))
### Result
token_embeddings: OnnxTensor(info=TensorInfo(javaType=FLOAT,onnxType=ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT,shape=[1, 38, 384]),closed=false)
sentence_embedding: OnnxTensor(info=TensorInfo(javaType=FLOAT,onnxType=ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT,shape=[1, 384]),closed=false)
### Sentence Embedding
[-0.0020816314, 0.2423955, 0.11978909, 0.14337282, -0.31591, -0.09212523, 0.32155237, 0.051040396, -0.08631388, -0.19759132, -0.009577593, 0.027946806, 0.09154547, -0.12720552, -0.044311255, 0.26040718, -0.1126086, 0.38889477, -0.14505047, -0.14417113, -0.04871829, 0.2035913, -0.02071402, 0.10712299, 0.15857005, -0.12898092, 0.048474822, -0.0183427, -0.008262274, 0.08896698, 0.0601254, -0.09995149, 0.15064578, 0.19170961, -0.3958069, 0.2134233, -0.086771265, 0.09434838, 0.20276073, 0.15431835, -0.042769473, 0.27813333, -0.098175354, -0.0044365753, -0.043380845, -0.1130121, -0.040292393, -0.012241059, -0.060921878, -0.02932244, -0.008153578, 0.2720622, -0.2735945, -0.051457625, 0.078123346, -0.015609589, 0.26123077, 0.11494505, 0.105798006, 0.2750898, -0.10039395, -0.15305457, -0.2624034, -0.00846793, -0.0029644866, -0.009464061, 0.04293173, -0.14771241, -0.08968776, 0.14320074, 0.29582435, -0.034091156, 0.076159224, -0.08348047, -0.08298157, -0.045832783, -0.032697655, 0.1044369, -0.20353818, -0.09441827, 0.24824338, 0.04576258, -0.24413367, 0.10997464, -0.08498667, -0.16032238, -0.0488784, 0.1371697, 0.090281904, -0.36472264, 0.054759026, 0.12628634, -0.11030211, 0.045372438, 0.3387068, 0.18563122, 0.24112236, 0.017931899, -0.06464821, -0.09741225, 0.09775052, -0.17725222, 0.15078665, -0.077700295, 0.08012917, 0.07087637, 0.010613829, -0.099288605, 0.017896734, -0.18578519, 0.20990473, -0.28260574, 0.10126011, 0.056119848, 0.22910583, -0.10580331, 0.10306189, 0.2487672, -0.5640683, 0.22360973, 0.050774943, 0.07172338, 0.035284888, 0.1909817, -0.19840407, -0.021836186, 0.22901832, 0.06795832, 0.13300219, -0.029518472, -0.27053538, -0.045032352, 0.1939415, -0.06849994, -0.25829947, -0.11151693, 0.25306138, 0.4013163, 0.08253966, -0.24612874, -0.21021514, -0.06757945, -0.09635901, 0.023983184, -0.14115155, -0.4710199, -0.004274892, -0.04922954, 0.2562949, 0.07956684, -0.06699103, -0.08889848, 0.20983675, -0.04874076, -2.2906535E-4, -0.08580096, -0.07015178, 0.3806056, 0.092844546, 0.15213062, 0.108941264, 0.014079875, 0.22147596, 0.039003614, -0.004057294, 0.28773662, -0.1642377, 0.044668436, 0.22939233, -0.3387797, -0.109499834, 0.044001874, 0.054292303, 0.050398022, -0.32900232, -0.03188986, -0.23111068, 0.1402413, -0.17654276, 0.07663513, -0.065999806, 0.50134826, 0.058562234, 0.11850357, -0.1424991, 0.32303342, 0.09647986, -0.04119722, 0.18306743, -0.07362559, 0.2629248, 0.013026965, 0.056230694, -0.27025503, -0.27598715, 0.012597741, 0.0039071282, -0.012196569, -0.10977429, 0.07490927, -0.3802358, -0.11539016, -0.07857229, -0.16229737, 0.02317088, 0.034772065, -0.019043904, -0.12866297, -0.2685265, -0.012191984, 0.095491886, 0.021413092, 0.37909678, 0.35062322, 0.15096243, -0.33481166, -0.03958083, 0.4416898, -0.17505065, 0.18706834, -0.023122206, -0.3226193, -0.12917587, -0.21202748, 0.042344045, 0.4168105, 0.21725138, 0.11442586, -0.037539445, 0.102315314, -0.21463247, 0.10809561, -0.16815355, -0.19253664, -0.17749414, 0.113823034, -0.06449619, 0.09802189, 0.14423084, 0.021088319, 0.12801269, -0.049804654, 0.052360598, 0.16967073, 0.14515975, -0.30143127, -0.20954755, -0.024957605, -0.12878956, 0.15641627, -0.011011287, 0.041462164, -0.30600128, -0.013934156, -0.10677049, 0.17738268, -0.11369698, 0.066030644, 0.060351416, 0.39678323, 0.18325976, -0.16502687, -0.33439147, 0.06723236, -0.026820838, -0.09354704, 0.10462331, 0.033233907, -0.04017015, -0.020055527, -0.060685184, 0.25027314, -0.25940597, -0.13197045, 0.450145, 0.30943942, -0.08958518, 0.05190213, -0.11768581, -0.11090352, 0.09725523, -0.18770468, -0.041713722, 0.030483833, 0.10556246, -0.0030300615, -0.016945675, -0.12231275, 0.19318376, 0.016808728, -0.17178476, -0.089757025, 0.22980964, 0.17048196, -0.154568, 0.10009092, -0.091702476, 0.04088522, -0.01636906, 0.10409124, -0.3464199, -0.2070567, -0.2309827, -0.53170073, -0.036772095, -0.08715737, -0.13421352, -0.23098472, 0.07534084, -0.17144303, -0.11371176, 0.040744673, 0.12101577, 0.2218123, 0.32129112, 0.12993109, 0.10268837, -0.030803442, -0.011844495, -0.21370201, -0.14694539, 0.105497725, -0.25062585, 0.058732748, 0.511807, 0.15080138, 0.36027572, -0.3251966, -0.055602282, -0.07510729, -0.2744894, 0.108775906, -0.5520873, 0.12586062, 0.015684498, 0.29812157, 0.08261067, -0.28208563, -0.08555798, 0.026221476, 0.025934048, 0.12961014, -0.17781249, -0.189778, -0.12501855, -0.090032466, -0.26090753, 0.009061217, -0.118372, 0.09249876, 0.0031131413, 0.322147, -0.28671587, 0.12739377, -0.1990998, 0.1799682, 0.009031787, -0.20696922, 0.15425323, -0.37033176, 0.33013555, -0.035668675, 0.19875517, 0.21846782, -0.042990595, 0.023760945, 0.16686802, -0.05632981, 0.07883397, 0.26585495, 0.0027796673, -7.237553E-4, -0.16707557, 0.0187999, -0.02842969, -0.18897568, -0.031184757, 0.0022931835, -0.20622307, -0.23067239, -0.34265828, -0.03665327, -0.05457748, -0.019665213]

ソースコードはこちら github.com

Javaの正規表現で \p{Han} がPatternSyntaxException

Java8と9で確認した。それ以降のバージョンでは確認していない。

Pattern p = Pattern.compile("\\p{Han}+");
Matcher m = p.matcher("吾輩は猫である");

while (m.find()) {
  System.out.println("matched: " + m.group());
}

Exception in thread "main" java.util.regex.PatternSyntaxException: Unknown character property name {Han} near index 6
\p{Han}+

こうなる。

https://github.com/Engelberg/instaparse/issues/106#issuecomment-127065635 のコメントによると、次のように書けばOK。UnicodeブロックとUnicodeスクリプトとで書き方が違うらしい。

Pattern p = Pattern.compile("\\p{script=Han}+");

結果。

matched: 吾輩
matched: 猫

エラーはこれで解決なのだけど、先のコメントと同じページに次のような記述があるので、\{script=Han} が、これらのブロックの文字すべてにマッチするのかどうか、確認してみた。

    (* Same as Han but without IDC *)
    L = CJK | CJKA | CJKB | CJKC | CJKD | CJKE | CJKRS | KR | CJKSP | CJKS | ECJKLM | CJKCo | CJKCI | CJKCF | CJKCIS
    (* IDC are excluded from this *)
    Han = L | IDC

    (* Blocks follow *)
    CJK =     #'[\u4e00-\u9fff]'
    CJKA =    #'[\u3400-\u4dbf]'
    CJKB =    #'[\u20000-\u2a6df]'
    CJKC =    #'[\u2a700-\u2b73f]'
    CJKD =    #'[\u2b740-\u2B81F]'
    CJKE =    #'[\u2b820-\u2ceaf]'
    CJKRS =   #'[\u2e80-\u2eff]'
    KR =      #'[\u2f00-\u2fdf]'
    IDC =     #'[\u2ff0-\u2fff]'
    CJKSP =   #'[\u3000-\u303f]'
    CJKS =    #'[\u31c0-\u31ef]'
    ECJKLM =  #'[\u3200-\u32ff]'
    CJKCo =   #'[\u3300-\u33ff]'
    CJKCI =   #'[\uf900-\ufaff]'
    CJKCF =   #'[\ufe30-\ufe4f]'
    CJKCIS =  #'[\u2f800-\u2fa1f]'

各ブロックから表示できそうな文字を選んで並べてマッチさせてみる。文字はCyber Librarianで探した。

    String chars = String.join("", new String[] {
      "\u4e00",
      "\u3400",
      "\ud840", "\udc0b", // "\u2000b",
      "\ud869", "\udfdd", // "\u2a7dd",
      "\ud86d", "\udf46", // "\u2b746",
      "\ud86e", "\udcb8", // "\u2b8b8",
      "\u2e80",
      "\u2f00",
      "\u2ff0",
      "\u3000",
      "\u31c0",
      "\u3200",
      "\u3300",
      "\uf900",
      "\ufe30",
      "\ud87e", "\udc04", // "\u2f804",
    });
    System.out.println(chars);

    Pattern p = Pattern.compile("\\p{script=Han}+");
    Matcher m = p.matcher(chars);

    while (m.find()) {
      System.out.println("matched: " + m.group());
    }

その結果。

一㐀𠀋𪟝𫝆𫢸⺀⼀⿰ ㇀㈀㌀豈︰你
matched: 一㐀𠀋𪟝𫝆
matched: ⺀⼀
matched: 豈
matched: 你

CJK統合漢字拡張E(CJKE)、漢字構成記述文字(IDC)、CJKの記号及び句読点(CJKSP)、CJKの筆画(CJKS)、囲みCJK文字・月(ECJKLM)、CJK互換用文字(CJKCo)、CJK互換形(CJKCF)はマッチしない模様。見た感じ、普通に考えて漢字でないブロックを除外しているようではある。CJK統合漢字拡張Eは漢字だけど、このブロック自体がJava8ではサポートされていないようなので、仕方ない。

一応、自分で正規表現にすべてのUnicodeブロック(拡張Eを除く)からなる文字クラスを定義してマッチさせてみた。

    String[] props = {
      "\\p{InCJKUnifiedIdeographs}",                // CJK    [\u4e00-\u9fff]
      "\\p{InCJKUnifiedIdeographsExtensionA}",      // CJKA   [\u3400-\u4dbf]
      "\\p{InCJKUnifiedIdeographsExtensionB}",      // CJKB   [\u20000-\u2a6df]
      "\\p{InCJKUnifiedIdeographsExtensionC}",      // CJKC   [\u2a700-\u2b73f]
      "\\p{InCJKUnifiedIdeographsExtensionD}",      // CJKD   [\u2b740-\u2B81F]
      // "\\p{InCJKUnifiedIdeographsExtensionE}",   // CJKE   [\u2b820-\u2ceaf] ない
      "\\p{InCJKRadicalsSupplement}",               // CJKRS  [\u2e80-\u2eff]
      "\\p{InKangxiRadicals}",                      // KR     [\u2f00-\u2fdf]
      "\\p{InIdeographicDescriptionCharacters}",    // IDC    [\u2ff0-\u2fff]
      "\\p{InCJKSymbolsandPunctuation}",            // CJKSP  [\u3000-\u303f]
      "\\p{InCJKStrokes}",                          // CJKS   [\u31c0-\u31ef]
      "\\p{InEnclosedCJKLettersandMonths}",         // ECJKLM [\u3200-\u32ff]
      "\\p{InCJKCompatibility}",                    // CJKCo  [\u3300-\u33ff]
      "\\p{InCJKCompatibilityIdeographs}",          // CJKCI  [\uf900-\ufaff]
      "\\p{InCJKCompatibilityForms}",               // CJKCF  [\ufe30-\ufe4f]
      "\\p{InCJKCompatibilityIdeographsSupplement}" // CJKCIS [\u2f800-\u2fa1f]
    };

    String chars = String.join("", new String[] {
      "\u4e00",
      "\u3400",
      "\ud840", "\udc0b", // "\u2000b",
      "\ud869", "\udfdd", // "\u2a7dd",
      "\ud86d", "\udf46", // "\u2b746",
      "\ud86e", "\udcb8", // "\u2b8b8",
      "\u2e80",
      "\u2f00",
      "\u2ff0",
      "\u3000",
      "\u31c0",
      "\u3200",
      "\u3300",
      "\uf900",
      "\ufe30",
      "\ud87e", "\udc04", // "\u2f804",
    });
    System.out.println(chars);

    Pattern p = Pattern.compile("[" + String.join("", props) + "]+");
    Matcher m = p.matcher(chars);

    while (m.find()) {
      System.out.println("matched: " + m.group());
    }

結果。拡張E以外マッチ。

一㐀𠀋𪟝𫝆𫢸⺀⼀⿰ ㇀㈀㌀豈︰你
matched: 一㐀𠀋𪟝𫝆
matched: ⺀⼀⿰ ㇀㈀㌀豈︰你

サロゲートペアはJavaScriptで雑なコード書いてつくりました。

const toSurrogatePair = function(ch) {
  const x = ch - 0x10000
  return [
    Math.floor(x / 0x400) + 0xD800,
    x % 0x400 + 0xDC00
  ].map(c => `\\u${c.toString(16)}`)
}

[
  0x2000b,
  0x2a7dd,
  0x2b746,
  0x2b8b8,
  0x2f802,
].forEach(c => {
  const p = toSurrogatePair(c)
  console.log(`"${p[0]}", "${p[1]}", // ${c.toString(16)}`)
})

GAEでTwitterみたいなフォロー関係を実現する - fanout problem

http://dl.google.com/io/2009/pres/W_0415_Building_Scalable_Complex_App_Engines.pdf

古い資料だけど。何か驚くべき手法が書かれているのか?と思って読んだらそういうわけでもなく。大勢のフォロワーに向けてパブリッシングするメッセージエンティティに、フォロワーのキーをリストプロパティでもっておけということだった(下記コードはイメージです)。

public class Message implements Serializable {
  private Key key;
  private Key senderKey;
  private String body;
  private List<Key> followerKeys;
}

あとからフォローしたりアンフォローした場合は、タスクでせっせと過去メッセージのfollowerKeysを書き換えるんでしょう。

エンティティあたりインデックス数の制限により、この設計ではフォロワー数の上限が10,000(インデックス数上限20,000として)になってしまうけれど、どうせそんな規模にはそうそうならないでしょう。そこだけ許容してしまえば、とてもシンプルに作れそう。

もうひとつ問題があって、リストプロパティのデシリアライズコストが馬鹿にならないらしい。この資料が作成された当時はどうにもならなかったようだけど、今のGAEにはProjectionクエリがあるので、followerKeysは取得せず欲しいプロパティだけを取得することができる。これはこれでまた制約があって、たとえばProjectionクエリで取得するプロパティはインデックス作成対象になっていなければならないので、Text型などは使えなかったりするのだけど。

Java + facebook Graph API メモ

Graph API は restfb

Graph API の呼び出しはrestfb。きれいに設計されていてとても使いやすい。オブジェクトをGETしたいときは、オブジェクトの種類を問わずfetchObjectまたはfetchConnectionを使う。これはすべてのオブジェクトを一意なIDで管理していて型(?)におおらかなfacebook側の設計と親和性が高い。もちろん、生JSONとかMAPが返ってきてもイヤなので、型は引数で指定。

User user = facebook.fetchObject("me", User.class, 
        Parameter.with("fields", "id,name,location"));

使いたいフィールドが、restfbによって提供されているモデルオブジェクトに含まれていなければ、自分で拡張すればよい。たとえば、Userクラスにはpictureフィールドがない(プロフィール画像が取れない!)ので、そんな場合はこうやって…

ExtendedUser user = facebook.fetchObject("me", ExtendedUser.class, 
        Parameter.with("fields", "id,name,location,picture"));

ExtendedUserを自作する。facebookが返すpictureフィールドは値がオブジェクトなので、対応するクラスが必要。返ってくるデータの形式さえつかんでいれば、あとは機械的作業。

public class ExtendedUser extends User {

    private static final long serialVersionUID = 1L;

    @Facebook
    private Picture picture;
  
    public Picture getPicture() {
      return picture;
    }

    public void setPicture(UserPicture picture) {
      this.picture = picture;
    }
}

public static class Picture implements Serializable {

    private static final long serialVersionUID = 1L;

    @Facebook
    private Data data;

    public Data getData() {
        return data;
    }

    public void setData(Data data) {
        this.data = data;
    }

    public static class Data implements Serializable {

        private static final long serialVersionUID = 1L;

        @Facebook("is_silhouette")
        private boolean isSilhouette;

        @Facebook
        private String url;

        public boolean isSilhouette() {
            return isSilhouette;
        }

        public void setSilhouette(boolean isSilhouette) {
            this.isSilhouette = isSilhouette;
        }

        public String getUrl() {
            return url;
        }

        public void setUrl(String url) {
            this.url = url;
        }
    }
}

ログイン処理は自分で書いてしまう

restfbはログイン処理してくれないので、ここは自分で書いてしまう。こんな感じで使えるFacebookLoginクラスを作った。あとで公開しとく。かも。

public class IndexController extends BaseController {

    @Override
    protected Navigation run() throws Exception {

        AccessToken accessToken = sessionScope("access_token");
        if (accessToken != null && !accessToken.isExpired()) {
            return forward("index.jsp");
        }

        FacebookLogin login =
            FacebookLogin.Builder
                .with(FacebookService.APP_ID, FacebookService.APP_SECRET)
                .setCallbackUrl("https://apps.facebook.com/xxx/")
                .setPermissions(Permission.values())
                .begin();

        String code = asString("code");
        if (StringUtil.isEmpty(code)) {
            AccessToken token = login.getAccessToken(code);
            sessionScope("access_token", token);
            return forward("index.jsp");
        }

        return html(login.getLoginDialogRedirectScript());
    }
}

それはそうと、写真へのタグ付けでひっかかった

写真へのタグ付けを行って成功すると、facebookから次のようなレスポンスが返ってくる。

true

trueって何だよ…。wktkしてJSONを期待しているrestfbがこれをくらうとパースエラー吐くので、restfbを直さないといけない。GitHubで公開されているプロジェクトは、pom.xmlが少し変(ソースフォルダの設定がないとか)な気がするけど、build.xmlもついてるしビルドはしやすい。

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

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

ふつうに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;