理系学生日記

おまえはいつまで学生気分なのか

Unicodeの正規化、UCDに記載された分解結果について

Unicodeの正規化については以下で書きました。今日はその補足です。

なぜUnicodeの正規化が必要になるのか

端的にいうと、Unicodeの中に同じ意味を持つ文字がある、あるいは同じ意味を持つ文字列が構成できてしまうからではないでしょうか。 僕たちに馴染みの深いほげという文字列も、Unicodeでは以下のように表現できてしまいます。

前提として、「ほげ」という文字列をUnicodeで表記しようとするとき、U+307B(ほ)とU+3052(げ)の2つのコードポイント列での表現が可能です。 また、別解として、U+307B(ほ)とU+3051(け)、U+3099(濁点)の組み合わせでも表現できます。

>>> print("\u307B\u3052")
ほげ
>>> print("\u307B\u3051\u3099")
ほげ

では「同じ」とはなんなのか

ここで「同じ」、つまり、等価性をどう定義するのかという問題が出てきます。

Unicode標準では、文字の「等しさ」について2つの基準を定めています。 Canonical Equivalence(正準等価性)とCompatibility Equivalence(互換等価性)です。

正準等価、互換等価の定義はUnicode® Standard Annex #15 UNICODE NORMALIZATION FORMS の1.1 Canonical and Compatibility Equivalenceにあります。

The Unicode Standard defines two formal types of equivalence between characters: canonical equivalence and compatibility equivalence. Canonical equivalence is a fundamental equivalency between characters or sequences of characters which represent the same abstract character, and which when correctly displayed should always have the same visual appearance and behavior. Figure 1 illustrates this type of equivalence with examples of several subtypes.

以下、ざっくりとChatGPTにまとめてもらいました。

特徴 Canonical Equivalence (正準等価性) Compatibility Equivalence (互換等価性)
定義 異なるバイトシーケンスが同じ文字として認識される。 異なるバイトシーケンスが同じような文字として認識される。ただし、表示スタイルや書体などの視覚的な差異がある場合がある。
目的 文字の基本的な形を維持しながら、異なるエンコード方法を同一視する。 異なるスタイルや書体の文字を、本質的に同じ文字として扱う。
キリル文字の「A」とラテン文字の「A」は見た目が同じだが、異なるコードポイントを持つ。正準等価性では、これらは別の文字として扱われる。 「fi」(合字)と「fi」(2つの別々の文字)は、視覚的に似ているが、異なるコードポイントを持つ。互換等価性により、これらは等価とみなされる。
使用場面 検索、ソート、テキスト処理での一貫性を保つため。 異なるスタイルや書体の文字を統一的に扱うため、特に文書処理や表示の際に重要。
重要性 同じ文字が異なる方法でエンコードされることを許容し、それらを等価とみなす。 視覚的に似ているが異なるコードポイントを持つ文字を等価とみなすことで、柔軟なテキスト処理を可能にする。

正準等価性は見た目では区別できない本質的な意味の同一性を表現している一方、互換等価性は見た目が違っていても同一とみなす、緩い同一性を表現しているようです。

合成と分解

先ほどの「ほげ」の例では、「げ」がU+3052(げ)、および、U+3051(け)、U+3099(濁点)の組み合わせの2パターンで表現されました。つまり、「合成」した結果を正規化の結果とするのか、「分解」した結果を結果とするのかの2つの考え方があるということです。

先ほどの等価性と組み合わせて、全部で4パターンの正規化パターンが存在します。

Form 説明
Normalization Form D (NFD) 正準等価によって分解する正規化方法
Normalization Form C (NFC) 正準等価によって分解された後、正準同意等価によって合成する正規化方法
Normalization Form KD (NFKD) 互換等価によって分解する正規化方法
Normalization Form KC (NFKC) 互換等価によって分解された後、正準等価によって合成する正規化方法

では具体的に見てみましょう

抽象論に終始してもなかなか理解できないので、基本的な文字列をそれぞれ正規化してみましょう。 末尾にJavaでのソースも示します。

末尾が「D」(分解)の正規化では、もともと1つのコードポイントだった文字が、複数のコードポイントに分解されたりしていることがわかります。fiやTEL.などは、正準等価だと1コードポイントのままですが、互換等価だと複数コードポイントに分解され、その表現も大きく変わっていますね。

文字列 Unicode NFD NFD Unicode NFC NFC Unicode NFKD NFKD Unicode NFKC NFKC Unicode
u3042 u3042 u3042 u3042 u3042
u304c が u304b u3099 u304c が u304b u3099 u304c
u3077 ぷ u3075 u309a u3077 ぷ u3075 u309a u3077
u30f4 ヴ u30a6 u3099 u30f4 ヴ u30a6 u3099 u30f4
Á u00c1 u0041 u0301 Á u00c1 u0041 u0301 Á u00c1
u2460 u2460 u2460 1 u0031 1 u0031
ufb01 ufb01 ufb01 fi u0066 u0069 fi u0066 u0069
u2121 u2121 u2121 TEL u0054 u0045 u004c TEL u0054 u0045 u004c
ハンカク uff8a uff9d uff76 uff78 ハンカク uff8a uff9d uff76 uff78 ハンカク uff8a uff9d uff76 uff78 ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af
ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af ハンカク u30cf u30f3 u30ab u30af
u337f u337f u337f 株式会社 u682a u5f0f u4f1a u793e 株式会社 u682a u5f0f u4f1a u793e
❤️ u2764 ufe0f ❤️ u2764 ufe0f ❤️ u2764 ufe0f ❤️ u2764 ufe0f ❤️ u2764 ufe0f
ufa19 u795e u795e u795e u795e

分解結果はどう定義されているのか

正準等価での正規化は、多くの場合「合字」を分解するか、結合するかによって行われているため、ある程度その正規化結果は想像しやすいです1。一方で互換等価の正規化は、その等価性が「緩い」こともあり、なかなか予想しづらいものになっています。この定義はどこで行われるのでしょうか。

およその答えはUNICODE CHARACTER DATABASE (UCD)にあります。Unicode® Standard Annex #44 UNICODE CHARACTER DATABASEがその解説。

UCDはUnicodeの文字に関するデータベースです。Unicodeの文字に関する情報が、コードポイントごとに記載されています。その中には、互換等価性に関する情報も含まれています。

たとえば「TEL」の例であれば、U+2121のコードポイントに対して、以下のような情報が記載されています。

2121;TELEPHONE SIGN;So;0;ON;<compat> 0054 0045 004C;;;;N;T E L SYMBOL;;;;

UCDは;区切りのCSV形式で記載されています。 <compat>というフィールドがあり、これが互換等価性を表しています。このフィールドには、互換等価性を持つコードポイントが列挙されています。この例では、0054 0045 004Cがそれにあたります。

同様に「Á」という文字についても、0041 0301が示されています。

00C1;LATIN CAPITAL LETTER A WITH ACUTE;Lu;0;L;0041 0301;;;;N;LATIN CAPITAL LETTER A ACUTE;;;00E1;

分解結果は5つ目のフィールドで表現されており、<hoge>というようなタグがある場合は互換等価性による分解結果、ない場合は正準等価性による分解結果を表現しています。 この辺りの解説は5.7.3 Character Decomposition Mappingをご参照ください。

https://f.hatena.ne.jp/kiririmode/20231217175131

正規化実装ソース

package com.kiririmode;

import java.text.Normalizer;

public class Main {
    // NFDでの正規化を行い、正規化後の文字列を返す
    public static String NFD(String str) {
        return Normalizer.normalize(str, Normalizer.Form.NFD);
    }

    // NFCでの正規化を行い、正規化後の文字列を返す
    public static String NFC(String str) {
        return Normalizer.normalize(str, Normalizer.Form.NFC);
    }

    // NFKDでの正規化を行い、正規化後の文字列を返す
    public static String NFKD(String str) {
        return Normalizer.normalize(str, Normalizer.Form.NFKD);
    }

    // NFKCでの正規化を行い、正規化後の文字列を返す
    public static String NFKC(String str) {
        return Normalizer.normalize(str, Normalizer.Form.NFKC);
    }

    // 文字列のUnicodeのコード値を取得する
    public static String getUnicode(String str) {
        StringBuilder unicode = new StringBuilder();
        for (int i = 0; i < str.length(); i++) {
            unicode.append(String.format("u%04x ", (int) str.charAt(i)));
        }
        return unicode.toString();
    }

    // 引数で与えられた文字列のリストに対して、NFD、NFC、NFKD、NFKCでの正規化後の文字列とそのUnicodeのコード値をMarkdownの表形式で出力する
    public static void printNormalized(String[] strs) {
        System.out.println("|文字列|Unicode|NFD|NFD Unicode|NFC|NFC Unicode|NFKD|NFKD Unicode|NFKC|NFKC Unicode|");
        System.out.println("|---|---|---|---|---|---|---|---|---|---|");
        for (String str : strs) {
            System.out.println("|" + str + "|" + getUnicode(str) + "|"
                    + NFD(str) + "|" + getUnicode(NFD(str)) + "|"
                    + NFC(str) + "|" + getUnicode(NFC(str)) + "|"
                    + NFKD(str) + "|" + getUnicode(NFKD(str)) + "|"
                    + NFKC(str) + "|" + getUnicode(NFKC(str)) + "|");
        }
    }

    public static void main(String[] args) {
        String[] strs = { "あ", "が", "ぷ", "ヴ", "Á", "①", "fi", "℡", "ハンカク", "ハンカク", "㍿", "❤️", "神", };
        printNormalized(strs);
    }
}

  1. 実際には、正準等価であってもその分解が想像しづらいものになるケースはあります。

GeoToolsを使って地図上に座標点・連結線を表示する

GeoToolsで扱う地図上の要素

今日はGeoToolsを使って地図上に点や線を引いてみます。

最初に結果を示しておくと、以下のようになります。 これは、札幌時計台、大通公園、さっぽろテレビ塔を順に直線で結んだものですね。

基礎知識

FeatureとFeatureType

まずは、GeoTools上で点や線がどのような扱いになるのかを知らなければなりません。 GeoToolsでは、マップ上に配置するオブジェクトをFeatureと呼び、Feature interfaceとして提供されます。 それは例えばエベレストであったり、あるいは札幌空港であったりします。

The strict definition is that a feature is something in the real world – a feature of the landscape - Mt Everest, the Eiffel Tower, or even something that moves around like your great aunt Alice.

Feature Tutorial — GeoTools 29-SNAPSHOT User Guide

一方で、札幌空港と羽田空港という明らかに共通のフィールドを持つオブジェクトを個別のFeatureとして定義するのは無駄です。そのため、GeoToolsではこれらをグループ化できるようFeatureTypeという概念が存在しています。JavaのオブジェクトシステムにおいてはClassに相当するものですね。 GeoTools上ではFeatureType interfaceとして提供されます。

これらが持つ情報というのはもちろん多岐に及び、Feature同士の関連性を持たせたり、FeatureとFeatureTypeとの関連性を持たせたりも可能です。 そしてFeatureやFeatureTypeはそのための柔軟性を持ち合わせているわけですが、柔軟すぎるものは扱いづらい。 このため、GeoToolsはシンプルな情報のみを持つFeatureやFeatureTypeとして、それぞれSimpleFeatureSimpleFeatureTypeが提供されています。

具体的なFeatureType、Featureの生成

では具体的にどうやってそれらを生成するのか。このデータ周りのメソッドはDataUtilitiesクラスにまとまっています。

例えば単純な線を地図上に引きたい場合、まずはそのSimpleFeatureTypeを以下のようにして生成します。

  // Lineという名前で「線」のFeatureTypeを作成
  SimpleFeatureType lineStringType = DataUtilities.createType("Line", "geometry:LineString");

第二引数に指定しているのはSimpleFeatureがどのような情報を持つのかを示すtypeSpecというフィールドです。JavaにおけるPropertyDescriptorに似たようなものでしょうか。

保持するフィールドの型をname:Type:hintという形式で指定します。複数のフィールドを持つ場合は,区切りです。 上記の場合はgeometryというフィールドの型がLineStringであることを宣言しています。

Featureがgeometryという情報を持つことは前提になっています。これはOpen Geospatial ConsortiumのSimple Feature Accessの仕様あたりで定められているようです。 ぼく自身はそこまでは確認できていませんが。 また、このLineStringはどこから来ているのかというと、おそらくはwikipedia:Well-known textからのようですね。

次に、SimpleFeatureTypeからSimpleFeatureを生成するには、同じDataUtilitiesのtemplateメソッドを使います。これにより、空のfeatureが作れます。

  SimpleFeature feature = DataUtilities.template(lineStringType);

FeatureTypeの描画

GeoToolsでFeatureを描画する際、理解が必要となるのが以下の3 interfaceです。

これらの関係を示す図をStyling features for display — GeoTools 30-SNAPSHOT User Guideから引用します。

名前が示す通り、FeatureTypeStyleはFeatureTypeを描画する際のレンダリング情報(Style)を提供するinterfaceです。 FeatureTypeStyleは、以下の情報を基にしてFeatureTypeを描画します。

  • いつ描画するか (Rule)
  • どのように描画するか (Symbolizer)

FeatureTypeStyleに対してRuleは1:n、Ruleに対してSymbolizerは1:nです。 FeatureTypeStyleが任意のFeatureTypeを描画する際、当該FeatureTypeは保持するすべてのRuleに渡されます。 そして、そのRuleで描画が決まると当該Ruleが保持する全Symbolizerによって描画されます。

Ruleは、FeatureTypeを描画するか否かを制御します。 例えば、どの表示倍率であれば描画するか、事前に描画されていないFeatureのみを描画するか等です。

図にすると以下のようなイメージです。

実装

package com.kiririmode;

import org.geotools.data.DataUtilities;
import org.geotools.feature.DefaultFeatureCollection;
import org.geotools.feature.SchemaException;
import org.geotools.geometry.jts.ReferencedEnvelope;
import org.geotools.map.FeatureLayer;
import org.geotools.map.MapContent;
import org.geotools.referencing.crs.DefaultGeographicCRS;
import org.geotools.styling.*;
import org.geotools.swing.JMapFrame;
import org.geotools.tile.TileService;
import org.geotools.tile.impl.osm.OSMService;
import org.geotools.tile.util.TileLayer;
import org.locationtech.jts.geom.*;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.feature.simple.SimpleFeatureType;

import java.awt.*;
import java.util.Arrays;
import java.util.function.Function;

public class OsmMapFrame {
  public static void main(String args[]) throws SchemaException {
    String baseURL = "https://tile.openstreetmap.org/";
    TileService service = new OSMService("OSM", baseURL);

    MapContent map = new MapContent();
    map.addLayer(new TileLayer(service));

    Coordinate[] coordinates = new Coordinate[] {
        new Coordinate(141.354496, 43.062096),  // 札幌時計台
        new Coordinate(141.356246, 43.055527),  // 大通公園
        new Coordinate(141.356882, 43.068624)   // さっぽろテレビ塔
    };

    FeatureLayer lineLayer = createLineLayer("Line", "geometry:LineString", coordinates,
        styleBuilder -> styleBuilder.createLineSymbolizer(styleBuilder.createStroke(Color.BLUE, 2.0)));
    map.addLayer(lineLayer);

    FeatureLayer pointLayer = createPointLayer("Point", "geometry:Point", coordinates,
        styleBuilder -> styleBuilder.createPointSymbolizer(
            styleBuilder.createGraphic(null,
                styleBuilder.createMark(StyleBuilder.MARK_CIRCLE, new Color(0,0,0,0), Color.BLUE, 1.0), null, 1,10, 0)));
    map.addLayer(pointLayer);

    setBound(map, coordinates);
    JMapFrame.showMap(map);
  }

  private static void setBound(MapContent map, Coordinate[] coordinates) {
    double minLat = Arrays.stream(coordinates).mapToDouble(c -> c.y).min().orElseThrow(IllegalStateException::new);
    double maxLat = Arrays.stream(coordinates).mapToDouble(c -> c.y).max().orElseThrow(IllegalStateException::new);
    double minLon = Arrays.stream(coordinates).mapToDouble(c -> c.x).min().orElseThrow(IllegalStateException::new);
    double maxLon = Arrays.stream(coordinates).mapToDouble(c -> c.x).max().orElseThrow(IllegalStateException::new);

    double offset = 0.001;
    map.getViewport().setBounds(new ReferencedEnvelope(minLon - offset, maxLon + offset, minLat - offset, maxLat + offset, DefaultGeographicCRS.WGS84));
  }

  private static FeatureLayer createLineLayer(String typeName, String typeSpec, Coordinate[] coordinates, Function<StyleBuilder, Symbolizer> symbolizerCreator) throws SchemaException {
    SimpleFeatureType type = DataUtilities.createType(typeName, typeSpec);
    DefaultFeatureCollection featureCollection = new DefaultFeatureCollection();

    SimpleFeature feature = DataUtilities.template(type);
    feature.setDefaultGeometry(new GeometryFactory().createLineString(coordinates));
    featureCollection.add(feature);
    return getFeatureLayer(typeName, symbolizerCreator, featureCollection);
  }

  private static FeatureLayer createPointLayer(String typeName, String typeSpec, Coordinate[] coordinates, Function<StyleBuilder, Symbolizer> symbolizerCreator) throws SchemaException {
    SimpleFeatureType type = DataUtilities.createType(typeName, typeSpec);
    DefaultFeatureCollection featureCollection = new DefaultFeatureCollection();

    for (Coordinate coordinate : coordinates) {
      SimpleFeature feature = DataUtilities.template(type);
      feature.setDefaultGeometry(new GeometryFactory().createPoint(coordinate));
      featureCollection.add(feature);
    }

    return getFeatureLayer(typeName, symbolizerCreator, featureCollection);
  }

  private static FeatureLayer getFeatureLayer(String typeName, Function<StyleBuilder, Symbolizer> symbolizerCreator, DefaultFeatureCollection featureCollection) {
    StyleBuilder styleBuilder = new StyleBuilder();
    Rule rule = styleBuilder.createRule(symbolizerCreator.apply(styleBuilder));
    FeatureTypeStyle fts = styleBuilder.createFeatureTypeStyle(typeName, rule);
    Style style = styleBuilder.createStyle();
    style.featureTypeStyles().add(fts);

    return new FeatureLayer(featureCollection, style);
  }
}

GISを学びながらOpenStreetMapの地図をJavaで描画してみる

最近は地図と向き合う業務も出てきており、色々と学ばないといけません。

基礎知識

GIS

まず学ばないといけない大きな括りがGIS (Geographic Information System)で、これは地理情報を扱うシステムの総称を指します。

地理情報システムとは、地理情報および付加情報をコンピュータ上で作成・保存・利用・管理・表示・検索するシステムを言う

wikipedia:地理情報システム

地図表示機能やルート案内機能を持つGoogle MapsももちろんGISの1つと言えるでしょう。

測地系

一般に、地球上の位置は緯度と経度で表現されます。しかし、地球の形状は完全な球状ではなく、赤道が少し膨らんだ形(回転楕円体)となっています。これにより、同じ緯度・経度の値でも、その値が地球上のどの場所を示すかは、使用されている地球形状のモデルによって微妙に異なります。 したがって、地球上の位置を特定または表現する際には、どの地球形状のモデル(測地系)を使用しているのかを明記する必要があります。これにより、位置情報の精度と一貫性が保たれます。

世界で共通的に利用できる測地系は「世界測地系」と呼ばれ、WGS系、ITRF系、PZ系の3つがあります。WGS系、ITRF系はいずれも地球の形状と重力場をモデル化する測地系です。

ITRF系

ITRF系は、定期的に改訂され、最新の測地学的データを反映します。これにより、地球の動きや地殻変動をより正確に表現できます。このため、主として陸域で利用されるようです。

ITRF系は、我が国をはじめ多くの国家が陸域で採用しています。

(問1-6)世界測地系は単一のものではないのか。

JGD2011

JDG2011は日本が現在採用している測地系です。

日本が現在採用している測地系は、VLBIやGNSSなどの宇宙技術を利用して定めた「日本測地系2011」(JGD2011)といい、世界全体で共通に利用ができる世界測地系であるITRF(国際地球基準座標系)に基づいています。

日本の測地系 | 国土地理院

WGS系

WGS系は、地殻変動等を明示的にモデル化しているわけではありません。

世界的に使われている測地系がWGS84 (World Geodetic System 1984)です。 GPSや海洋航行で使用されており、Google Mapsや後述するOpenStreetMapsもWGS84を使っています。

OpenStreetMap

OpenStreetMapは、オープンデータの地理情報を使るプロジェクトです。したがって、OpenStreetMap上での地理情報の正確性はコミュニティ次第となります。 この正確性に関してはいくつか研究論文も存在しており、日本においても(場所によりその精度は大きく異なれど)全体的にはある程度の精度は保持しているように読み取れます。

ライセンスはODbLに基づきます。 サマリはこちら

OpenStreetMapのデータを使って地図をJavaで描画してみる

https://github.com/kiririmode/geotools-sandbox にソースを突っ込みました。 以下のコマンドを実行することで、OpenStreetMap (測地系はWGS84です)の地図を画面上に描画できます。

$ mvn exec:java -Dexec.mainClass=com.kiririmode.OsmMapFrame

実装ソースは以下のとおりで、とても単純ですね。利用しているのはGeoToolsという、Java用のGIS Toolkitです。

import org.geotools.map.MapContent;
import org.geotools.swing.JMapFrame;
import org.geotools.tile.TileService;
import org.geotools.tile.impl.osm.OSMService;
import org.geotools.tile.util.TileLayer;

public class OsmMapFrame {
    public static void main(String args[])
    {
        String baseURL = "https://tile.openstreetmap.org/";
        TileService service = new OSMService("OSM", baseURL);

        MapContent map = new MapContent();
        map.addLayer(new TileLayer(service));
        JMapFrame.showMap(map);
    }
}

実装ソースを見ていただいた時、まず馴染みのないのがTileServiceでしょう。これを理解するためには、Web上で地図がどのように配信されているかを理解する必要があります。

タイルサーバの利用

地図の配信は、多くの場合タイルサーバと呼ばれるサーバによって行われます。

タイルサーバは、地図データを「タイル」と呼ばれる小さな正方形の画像に分割して配信します。タイルは一般的に256ピクセルx 256ピクセルの大きさを持ち、それぞれが地図の特定の地域を表しています。Google Mapsも、先のOpenStreetMapもこのタイルサーバによって地図が配信されます。

ユーザが特定の地域をズームインまたはズームアウトすると、タイルサーバはその地域のタイルを生成または取得し、配信します。これにより、全体の地図を一度にダウンロードする必要がなくなるわけですね。

このタイルサーバを抽象化したものがTileServiceであり、TileServiceのOpenStreetMap(OSM)用実装がOSMServiceです。

GeoTools上のユーザガイドとしては、Tile clientあたりが該当です。

地図の描画

GeoToolsを利用して地図を描画しようとすると、描画用アーキテクチャを理解しなければなりません。 GeoToolsでは、描画する地図は「一連のレイヤ」として表現されます。この「一連のレイヤ」がMapContextです。Map data and display classesから引用しますが、MapContextとLayerは以下の関係になります。

したがって、上記ソースではレイヤの1つにTileLayerを指定することで、OpenStreetMapの世界地図を描画させているわけです。

地図の絞り込み

JMapFrameの機能を用いればズームイン等も可能なのですが、ここでは東京駅周辺を直に描画してみましょう。 以下のように変更してみます。

 git diff
diff --git a/src/main/java/com/kiririmode/OsmMapFrame.java b/src/main/java/com/kiririmode/OsmMapFrame.java
index 4cd1e3b..6364726 100644
--- a/src/main/java/com/kiririmode/OsmMapFrame.java
+++ b/src/main/java/com/kiririmode/OsmMapFrame.java
@@ -1,6 +1,9 @@
 package com.kiririmode;

+import org.geotools.geometry.jts.ReferencedEnvelope;
 import org.geotools.map.MapContent;
+import org.geotools.map.MapViewport;
+import org.geotools.referencing.crs.DefaultGeographicCRS;
 import org.geotools.swing.JMapFrame;
 import org.geotools.tile.TileService;
 import org.geotools.tile.impl.osm.OSMService;
@@ -14,6 +17,20 @@ public class OsmMapFrame {

         MapContent map = new MapContent();
         map.addLayer(new TileLayer(service));
+
+        // 東京駅の緯度と経度
+        double lat = 35.681236;
+        double lon = 139.767125;
+
+        // 表示範囲を設定
+        double span = 0.01;  // 緯度/経度での範囲
+        DefaultGeographicCRS crs = DefaultGeographicCRS.WGS84;
+        ReferencedEnvelope envelope = new ReferencedEnvelope(lon - span, lon + span, lat - span, lat + span, crs);
+
+        // MapContentに表示範囲を設定
+        MapViewport viewport = new MapViewport(envelope);
+        map.setViewport(viewport);
+
         JMapFrame.showMap(map);
     }
 }

MapViewportというクラスが登場していますが、これは描画範囲を指定する位置付けになります。

Represents the area of the map to drawn.

Map data and display classes — GeoTools 29-SNAPSHOT User Guide

今回はWGS84測位系での緯度・軽度の範囲を指定し、東京駅周辺を表示してみました。

まとめ

というわけでGIS初心者がGeoToolsとOpenStreetMapを使って、地図を描画する基本を試してみました。楽しい。

ISUCONにJavaでチャレンジし予選敗退してきました

ISUCON 12とっても楽しかったですね。

今年はISUCON 6以降で久しぶりの登場となったJavaで参加してきました。

ISUCON 12における698チームのうち、利用言語が把握されているのが568チーム。 そのうちJavaを利用していたのはわずか5組(0.9%)ということで、なかなかレアな戦いになったようです。

予選問題

今年の予選問題についてはさまざまなところで語られていますが、マルチテナント型のSaaSがテーマでした。 どういう問題だったのかをここで逐一記述するよりも、こちらのエントリに詳しく纏まっているので、そちらをご参照いただく方がわかりやすいです。

当日にSQLiteがTwitterのトレンドに入ったとかそういう話もありましたが、個々のテナントに関するデータがSQLiteのファイルに格納されていることが特徴的であり、面食らった点でもあります。

もちろん、そういった情報は実装から読み取らなければなりません。 以下が予選中に競技者であるぼくたちへ公開された情報であり、あとは実装から読み取ります。

Java実装

ではJava実装がどうだったのかというと、こちらが実装でした。

Spring Frameworkベースで構築されていました。 APIエンドポイントはApplication.javaにあるので、このファイルを眺めるとおおよその実装は把握できる構成です。

実装上で特徴的なのは、DBトランザクションを使う代わりに、排他制御がロックで実行されているということでしょう。他言語ではflockが使われていたようですが、Javaではsynchronizedが使われていました。

なぜか初期実装はトランザクションという概念を知らない人が書いた(という想定)ので、アプリケーションの整合性を保つために排他ロックをしている

(中略)

排他ロックの実装は、大昔のWebアプリケーションを知っている方には懐かしの flock です。(Javaのみ、実装上の理由で synchronized でした)

ISUCON12 予選問題の解説と講評 : ISUCON公式Blog

Webアプリケーションはdocker-composeで動作し、systemdから呼び出される構成です。

戦い

昨年度と同様、@kkasaiさん、@hondaYoshitakaさんと参戦しました。 当日は、@kkasaiさんがnginxやMySQLのログ解析やチューニング、@hondaYoshitakaさんとぼくが主としてアプリケーションのチューニングを担当しました。

目についたのがsynchronizedでのロックやN+1問題です。 ソースを読んだ結果として、そもそもこの排他制御はロックじゃなくてトランザクションで守るべき問題やろ、ということは明確でした。 その手段として、SQLiteからMySQLへのマイグレーションを選択し、ぼくがそれを担当することにしました。参加チームの半分程度が同様の戦略をとったようです。

そこで、「あまり馴染みがないSQLiteではなく、MySQLに取り込んでからチューニングを行おう」と考えたチームも多かったようです。アンケートの結果では、半分ほどのチームが移行を検討したようです。

ISUCON12 予選問題の解説と講評 : ISUCON公式Blog

マイグレーション

順序としては以下のように計画を立てました。

  1. SQLiteに対するデータアクセスを、MySQLに対するデータアクセスにするようソース修正していく
  2. SQLite上のスキーマと初期データを、MySQLへ移行する
  3. あとは友情と汗と努力

ソース修正

Java実装では、SQLiteに対するクエリ発行は以下のような形で実装されていました。

            PreparedStatement ps = tenantDb.prepareStatement("SELECT * FROM player WHERE id = ?");
            ps.setQueryTimeout(SQLite_BUSY_TIMEOUT);
            ps.setString(1, id);
            ResultSet rs = ps.executeQuery();
            if (!rs.isBeforeFirst()) {
                return null;
            }
            return new PlayerRow(
                    rs.getLong("tenant_id"),
                    rs.getString("id"),
                    rs.getString("display_name"),
                    rs.getBoolean("is_disqualified"),
                    new Date(rs.getLong("created_at")),
                    new Date(rs.getLong("updated_at")));

こういった実装をセコセコとMySQL用の実装に修正します。

        SqlParameterSource source = new MapSqlParameterSource().addValue("playerId", id);
        RowMapper<PlayerRow> mapper = (rs, i) -> new PlayerRow(
            rs.getLong("tenant_id"),
            rs.getString("id"),
            rs.getString("display_name"),
            rs.getBoolean("is_disqualified"),
            new Date(rs.getLong("created_at")),
            new Date(rs.getLong("updated_at")));

        PlayerRow row =
            this.adminDb.queryForObject("SELECT * FROM player WHERE id = :playerId", source,
                mapper);

結構量はありましたが、とりあえずは完了し、次のステップに向かいました。

スキーマと初期データ移行

ここが厄介でした。 親切にも、SQLiteからSQLを取り出すスクリプトが用意されており、正直ここで苦戦するのは計算外でした。

何に苦戦したかというと、以下の点です。

  • SQLiteの中のDBに格納されている初期データは数百万レコード(=数百万のINSERT文)であること
  • データの初期化処理は30秒以内に完了しなければならないというレギュレーションがあること

ベンチマーカーによる負荷走行は以下のように実施されます。

  • 初期化処理の実行 POST /initialize(30 秒でタイムアウト)
  • アプリケーション互換性チェック(数秒~数十秒)
  • 負荷走行(60 秒)

初期化処理もしくはアプリケーション互換性チェックに失敗すると、負荷走行は即時失敗(fail)になります。

負荷走行について

これはすなわち、馬鹿正直に考えるとMySQLに対する数百万のINSERT文を30秒以内に完了しなければならないことを意味します。

INSERT文を分割しての並列でのINSERT等さまざまな方法を試しましたが、なかなかうまくいかず、ここでタイムアップを迎えました。

戦いを終えて

ぼくがMySQLへのマイグレーションで苦しんでいる間に、チームメイトの二人は着々と高速化を行い、上位1/4くらいに入る感じで終わりました。

強い課題感を感じたのは、やはり実装から業務仕様を読み取るスピードです。

例えば、ぼくが解決できなかった初期データの件は、ISUCON12 予選問題の解説と講評 : ISUCON公式Blogにて以下のような解決法が示されています。

さて、ここで player_score の処理と初期状態のデータをよく観察すると、アップロードされてくるCSVには無駄な行(同一の参加者の複数のスコア)が大量に含まれていることが分かります。

アプリケーションの仕様上、各参加者ごとにCSV内で出現する最後の行がリーダーボードで有効になるため、その1行のみを保存すれば十分です。

アプリケーションは、CSVを読みながら各参加者の最後の1行だけを覚えておき、INSERTするように変更できます。

初期データでは、各参加者について row_num が一番最大の行のみを抽出して、player_score テーブルを作り直すことができます。

きちんと実装から仕様を読み取れれば「なるほどな」となるのですが、限られた時間の中で高速に仕様を読み解き、その仕様からチューニング方法を思いつくスキル・スピードが全く足りない。 大きな課題を見つけて、今年のぼくのISUCONは終了したのでした。

課題はあれど、振り返ってみてもISUCONに参加したのはめちゃくちゃ楽しかったです。 自分の足りないところをなんとかすべく、1年間またやっていこうと思います。

Javaにおける依存関係を可視化できるjarvizを試してみる

モノリス的なJavaアプリケーションにおいて、各クラス・メソッドがどのように依存し合っているのかを見極めたいというのは しばしば生じるニーズです。 アプリケーションが巨大になれば分割したいと考えるのは自然の摂理でもあり、 分割面がどこなのかを見極めることもできる可能性があります。

このような用途で使えるツールはないものかと、Jarvizを試してみました。

Jarvizとは

Jarvizは、あるメソッドが他のメソッドを呼び出すことを依存関係と定義し、その依存関係を解析・可視化するツールです。 依存関係はByte Codeから読み取るので、JarファイルやWarファイルがあれば解析可能です。

このあたりは、以下のTech Blogエントリに詳細な記載があります。

また、Jarviz自体にもMavenが統合されているため、手元にJar/Warがなかった場合はMaven Repositoryから収集してくれます。 このあたりは便利ですね。

実際に使ってみる

Jarvizは3つのツールから構成されます。このうち、ユーザの意識する必要があるのはjarviz-cliのみでしょう。

ツール名 概要
jarviz-lib byte codeから依存関係を解析し、解析結果をjsonlとして出力する。Java製。
jarviz-graph jsonlファイルに定義された依存関係をwikipedia:力学モデルで可視化する
jarviz-cli Jarvizをユーザとして利用するためのインタフェースとなるコマンドラインツール。

jarviz-cliはサブコマンドを取るCLIツールとなっており、以下のようにgraphサブコマンドを使えば可視化が行われます。

$ ./jarviz graph -f samples/nablarch-filter.json -a samples/nablarch-artifacts.json
(略)
Processed 586 rows and wrote to disk "/Users/kiririmode/src/github.com/ExpediaGroup/jarviz/jarviz-cli/jarviz-results-20220424-114612"
Dependency analysis data were saved to: jarviz-results-20220424-114612/jarviz-dependency-data.jsonl
Dependency graph was saved to: jarviz-results-20220424-114612/jarviz-dependency-data.html

引数を2つ渡していますが、-fで与えるのがフィルタ、-aで渡すのが解析対象のアーティファクトを定義するJSONファイルです。

フィルタ

フィルタで定義されるのは、「依存関係として解析する対象の定義」です。 例えば以下ではnablarch.testパッケージからの依存関係を解析対象とする一方、 nablarch.testからnablarch.testパッケージ内への自己参照的な依存関係は含めません。

このフィルタファイルにより、具体的にどのような依存関係を可視化するのかを指定します。

{
  "include": {
    "sourcePackage": "^(nablarch\\.test)$"
  },
  "exclude": {
    "targetPackage": "^(nablarch\\.test).*$"
  }
}

アーティファクト

アーティファクトは、その名前の通り具体的に解析するアーティファクトを定義します。 例えば以下では、nablarch-corenablarch-testingを依存関係として定義しています。

{
  "appSetName": "Nablarch",
  "applications": [
    {
      "appName": "nablarch",
      "artifacts": [
        {
          "groupId": "com.nablarch.framework",
          "artifactId": "nablarch-core",
          "version": "1.1.0"
        }
      ]
    }
  ],
  "applications": [
    {
      "appName": "nablarch-testing",
      "artifacts": [
        {
          "groupId": "com.nablarch.framework",
          "artifactId": "nablarch-testing",
          "version": "1.4.0"
        }
      ]
    }
  ]
}

出力結果

JSONLファイルでは、アーティファクトとフィルタによって定義される依存関係が格納されます。 具体的に中身をみると以下のような形になります。

$ head -2 jarviz-dependency-data.jsonl | jq
{
  "appSetName": "Nablarch",
  "applicationName": "nablarch-testing",
  "artifactFileName": "nablarch-testing-1.4.0.jar",
  "artifactId": "nablarch-testing",
  "artifactGroup": "com.nablarch.framework",
  "artifactVersion": "1.4.0",
  "sourceClass": "nablarch.test.AbstractStringMatcher",
  "sourceMethod": "<init>",
  "targetClass": "org.hamcrest.BaseMatcher",
  "targetMethod": "<init>"
}
{
  "appSetName": "Nablarch",
  "applicationName": "nablarch-testing",
  "artifactFileName": "nablarch-testing-1.4.0.jar",
  "artifactId": "nablarch-testing",
  "artifactGroup": "com.nablarch.framework",
  "artifactVersion": "1.4.0",
  "sourceClass": "nablarch.test.AbstractStringMatcher",
  "sourceMethod": "describeTo",
  "targetClass": "org.hamcrest.Description",
  "targetMethod": "appendValue"
}

可視化結果

可視化結果は以下のようなものになります。 2つ貼り付けましたが、前者がアプリケーション単位、後者が依存先をベースとした可視化結果になります。 具体的にどう違うのかはドキュメントにも定義されておらず、ソースを読まない限りわかりそうにありません。

なお、グラフ自体はreact-force-graphを用いて記述されていました。 従って、グラフの生成アルゴリズムはwikipedia:力学モデル (グラフ描画アルゴリズム)のようです。

感想

解析結果としてのJSONLファイルについてはかなり使えそうな印象を持ちました。 JSONLなので解析もしやすいとともに、メソッドの呼び出し関係をここまで楽に解析できるのは非常に気持ちが良いです。 メソッドを修正するときの依存関係の確認にも使えそうですね(もちろん、IDEを使えばコールスタックはほぼ辿れるわけですが…)。

一方で、可視化結果としての応用は効きづらい、というのが正直な印象です。 上記のNablarchの解析例をみてもわかる通り、アプリケーション単位の解析結果はほぼ円状になり、ここから何らかの示唆を導くことは困難です。 また規模が大きいアプリケーションの場合は描画負荷が大きくなり、フィルタを適切に定義しないと可視化結果の確認すら叶いません。

そういうわけで当初の目的は果たせなかったわけですが、JSONLファイルの方はまだ応用が効きそうなので、 自前で可視化していければと考えています。

GitLabでJavaのソースコードのテストカバレッジを可視化する

テストを書こうと言っても、精神論ではなかなかその文化は育ちません。 これを行おうとすると、自分の記述したテストがどのようにプロジェクトやチームに貢献しているのかを可視化する必要があります。

今日のゴールは2つあり、1つはMerge Requestのファイルタブ上でテストのカバー範囲を可視化することです。

そしてもう1つは、Merge Request上でカバレッジが何%なのかを表示することです。

JaCoCoの組み込み

Javaにおいてカバレッジを解析してくれるライブラリにJaCoCoがあります。

JaCoCoについてはMaven Plug-inが存在しています。 このため、pom.xmlで設定すれば、カバレッジのレポートを取ることが可能になります。

以下がjacoco-maven-pluginを設定するpom.xmlです。

diff --git a/app/movie-uploader/pom.xml b/app/movie-uploader/pom.xml
index 07b9927..82e2305 100644
--- a/app/movie-uploader/pom.xml
+++ b/app/movie-uploader/pom.xml
@@ -180,6 +180,26 @@
           </dependency>
         </dependencies>
       </plugin>
+
+      <plugin>
+        <groupId>org.jacoco</groupId>
+        <artifactId>jacoco-maven-plugin</artifactId>
+        <version>0.8.7</version>
+        <executions>
+          <execution>
+            <goals>
+              <goal>prepare-agent</goal>
+            </goals>
+          </execution>
+          <execution>
+            <id>report</id>
+            <phase>test</phase>
+            <goals>
+              <goal>report</goal>
+            </goals>
+          </execution>
+        </executions>
+      </plugin>
     </plugins>
   </build>

mvn testを実行すればカバレッジレポートが出力されるようになります。

$ mvn test
(略)
[INFO] --- jacoco-maven-plugin:0.8.7:report (report) @ movieuploader ---
[INFO] Loading execution data file /Users/kiririmode/src/github.com/kiririmode/hobby/app/movie-uploader/target/jacoco.exec
[INFO] Analyzed bundle 'movieuploader' with 6 classes

テストカバレッジの可視化

GitLabのTest Coverage Visualization機能

GitLabには、Merge Requestで表示されるソース行をテストがカバーしたのか否かを可視化してくれる機能があります。

この機能は、.gitlab-ci.ymlにてCovertura形式のファイルをartifacts:reports:coverage_reportで指定することにより有効になります。

JaCoCoからCovertura形式への変更

しかし冒頭に記載した通り、ぼくが使っているのはJaCoCoです。従って、JaCoCoからCovertura形式のカバレッジレポートへと変換しなければなりません。

JaCoCo形式からCovertura形式への変換機能を有するコンテナはGitLabやDockerHubでホストされているので、これを利用すれば良いでしょう。

Dockerイメージ上に含まれているのはcover2cover.pyで、引数は以下のようでした。

  1. JaCoCoのレポートへのパス
  2. Javaのソースルートへのパス

これをgitlab-ci.ymlに落とすと以下のcoverageジョブのようになります。

unittest:
  stage: test
  image: maven:3.8.4-openjdk-17-slim
  (略)
  script:
    - mvn ${MAVEN_CLI_OPTS} -f app/movie-uploader/pom.xml test
  (略)
  artifacts:
    paths:
      - app/movie-uploader/target/site/jacoco/jacoco.xml

coverage:
  stage: visualize
  image: registry.gitlab.com/haynes/jacoco2cobertura:1.0.8
  variables:
    jacocoreport: app/movie-uploader/target/site/jacoco/jacoco.xml
  script:
    - python /opt/cover2cover.py ${jacocoreport} ${CI_PROJECT_DIR}/app/movie-uploader/src/main/java/ > cobertura.xml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "app/**/*"
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: cobertura.xml

これを実行すると、きちんとテストのカバー範囲が表示されるようになりました。

Merge Requestへのカバレッジの表示

さらにMerge Request上で、現在のテストカバレッジが何%なのかという表示も行ってみます。

JaCoCoのXML形式のレポートには、末尾にC1やC2カバレッジの元となる数値が記録されています。

$ xmllint --format target/site/jacoco/jacoco.xml | tail -8
  </package>
  <counter type="INSTRUCTION" missed="266" covered="123"/>
  <counter type="BRANCH" missed="10" covered="4"/>
  <counter type="LINE" missed="71" covered="25"/>
  <counter type="COMPLEXITY" missed="23" covered="9"/>
  <counter type="METHOD" missed="18" covered="7"/>
  <counter type="CLASS" missed="4" covered="2"/>
</report>

これをxmllintで取り出せば、容易にC0やC1カバレッジが計算できるでしょう。ここでは、XPathで値を抽出し、C1カバレッジを計算することにしました。

$ jacocoreport=target/site/jacoco/jacoco.xml
$ covered=$(xmllint --xpath 'string(/report/counter[@type="BRANCH"]/@covered)' ${jacocoreport})
$ missed=$(xmllint --xpath 'string(/report/counter[@type="BRANCH"]/@missed)' ${jacocoreport})
$ coverage=$(awk -vmissed=$missed -vcovered=$covered 'BEGIN{ printf("%.1f\n", covered/(covered+missed)*100 ) }')
$ echo "C1 coverage=${coverage}%"
C1 coverage=28.6%

これを.gitlab-ci.ymlに組み込めば良いです。 GitLabの場合、カバレッジはジョブの標準出力に出力し、それをキャプチャする仕組みがありました。 最近はこのキャプチャ範囲を.gitlab-ci.ymlで書けるようになったようです。

coverage:
  stage: visualize
  image: registry.gitlab.com/haynes/jacoco2cobertura:1.0.8
  variables:
    jacocoreport: app/movie-uploader/target/site/jacoco/jacoco.xml
  before_script:
    # for xmllint
    - apk --no-cache add libxml2-utils
  script:
    - python /opt/cover2cover.py ${jacocoreport} ${CI_PROJECT_DIR}/app/movie-uploader/src/main/java/ > cobertura.xml
    - covered=$(xmllint --xpath 'string(/report/counter[@type="BRANCH"]/@covered)' ${jacocoreport})
    - missed=$(xmllint --xpath 'string(/report/counter[@type="BRANCH"]/@missed)' ${jacocoreport})
    - coverage=$(awk -vmissed=$missed -vcovered=$covered 'BEGIN{ printf("%.1f\n", covered/(covered+missed)*100 ) }')
    - echo "Test Coverage=${coverage}%"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "app/**/*"
  coverage: '/Test Coverage=\d+\.\d+/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: cobertura.xml

結果がこちら。

Checkstyle、SpotBugsのエラー(Violation)を、Reviewdogを使ってGitLabのMergeRequestにコメントする

CheckstyleやSpotBugsのエラーを、Reviewdogを使ってGitLabのMerge Requestにコメントできるようにしました。 それぞれハマりポイントがあって、かなり苦戦しました。このため、それぞれに関する内容を記載しておきます。

Checkstyle

Reviewdog自体はネイティブでCheckstyleのレポートフォーマットに対応しています。

reviewdog also accepts checkstyle XML format as well. If the linter supports checkstyle format as a report format, you can use -f=checkstyle instead of using 'errorformat'.

checkstyle format

一方で、Checkstyleに関するハマりポイントは、Checkstyleのレポートの中のファイルパスが絶対パスであることです。 reviewdogはファイルのパス・行と対応づけてコメントをしてくれるので、reviewdogに認識させるパスはGitLabの認識しているパスと一致させなければなりません。 この一致を図るため、sedで変換させるようにしています。

  script:
    - mvn ${MAVEN_CLI_OPTS} -f app/movie-uploader/pom.xml test
    # Checkstyleのレポートを相対パスに変更
    - sed --in-place "s|${CI_PROJECT_DIR}/||g" app/movie-uploader/target/checkstyle-result.xml
    # CheckStyleのViolationをMerge Requestのディスカッションにコメント
    - >-
      cat app/movie-uploader/target/checkstyle-result.xml 
      | bin/reviewdog -reporter=gitlab-mr-discussion -name="checkstyle" -f checkstyle -tee

これにより、Merge Requestに対して以下のようなコメントが可能になります。

SpotBugs

SpotBugsについてはさまざまな課題があります。

XML形式のエラーレポートが解析しづらい問題

SpotBugsのXML形式のエラーレポートはパースの難易度がかなり高いです。

前提として、Reviewdogがコメントをするために最低限必要とするのは以下の3つという認識です。

  • エラーメッセージ
  • ファイスパス
  • 対象行

SpotBugsのXMLレポートでは、エラーメッセージは//BugInstance/LongMessage、対象行は//BugInstance/SourceLine/@startといった形です。 問題はBugInstance要素とSourceLine要素が1対多関係になり得ることで、シェルスクリプトでこれらを解析するのはかなり厳しい。

SARIF形式という解決策

一方で、SpotBugsはSARIF形式でのレポート出力が可能です。

なお、SARIFというのは静的解析レポートの標準としてOASISが定義したフォーマットです。これから流行りますかね…。

SARIF形式はJSONなので、これをjqで解析すれば良いでしょう。

解決策

以下のように、SARIF形式のSpotBugsレポートをjqで解析してCSV化し、それをReviewdogの-efmオプションで解析するという方法を取りました。 spotbugs-maven-pluginでは、 SARIF形式のレポートはspotbugs:spotbugsゴールでないと出力されません。 このため、Mavenのtestフェーズに当該ゴールを組み込む形を取りました。

.gitlab-ci.yml上でのジョブ定義の一部を抜粋すると以下の通りです。

  script:
    - mvn ${MAVEN_CLI_OPTS} -f app/movie-uploader/pom.xml test
    # SpotBugsのViolationをMerge Requestのディスカッションにコメント
    - >- 
      cat app/movie-uploader/target/spotbugsSarif.json
      | jq -r '.runs[].results[] | [ "`" + .ruleId + "`: " + .message.text, "app/movie-uploader/src/main/java/" + .locations[].physicalLocation.artifactLocation.uri, .locations[].physicalLocation.region.startLine ] | @csv'
      | bin/reviewdog -reporter=gitlab-mr-discussion -efm '"%m","%f",%l' -name="spotbugs" -tee

具体的にSARIF形式をjqで処理すると以下のようになります。これをreviewdogで解析し、GitLabにコメント投稿させています。

$ cat app/movie-uploader/target/spotbugsSarif.json \
| jq -r '.runs[].results[] | [ .message.text, "app/movie-uploader/" + .locations[].physicalLocation.artifactLocation.uri, .locations[].physicalLocation.region.startLine ] | @csv'
"既知の定数の雑な値を見つけた","app/movie-uploader/de/kiririmo/memory/keymapper/QuickTimeCreationTimeKeyMapper.java",75
"ログの潜在的な CRLF インジェクション","app/movie-uploader/de/kiririmo/memory/keymapper/QuickTimeCreationTimeKeyMapper.java",60
"hashCode メソッドを定義して Object.equals() を使用しているクラス","app/movie-uploader/de/kiririmo/memory/keymapper/QuickTimeCreationTimeKeyMapper.java",71

最終形

unittest:
  stage: test
  image: maven:3.8.4-openjdk-17-slim
  variables:
    GIT_STRATEGY: clone
    GIT_DEPTH: 0
    MAVEN_OPTS: "-Dmaven.repo.local=${CI_PROJECT_DIR}/.m2/repository -Duser.language=ja"
    MAVEN_CLI_OPTS: "--batch-mode"
  before_script:
    - curl -sfL https://raw.githubusercontent.com/reviewdog/reviewdog/master/install.sh | sh -s ${REVIEWDOG_VERSION}
    - apt-get update && apt-get -y install jq git
  script:
    - mvn ${MAVEN_CLI_OPTS} -f app/movie-uploader/pom.xml test
    # Checkstyleのレポートを相対パスに変更
    - sed --in-place "s|${CI_PROJECT_DIR}/||g" app/movie-uploader/target/checkstyle-result.xml
    # CheckStyleのViolationをMerge Requestのディスカッションにコメント
    - >- 
      cat app/movie-uploader/target/checkstyle-result.xml 
      | bin/reviewdog -reporter=gitlab-mr-discussion -name="checkstyle" -f checkstyle -tee
    # SpotBugsのViolationをMerge Requestのディスカッションにコメント
    - >- 
      cat app/movie-uploader/target/spotbugsSarif.json
      | jq -r '.runs[].results[] | [ "`" + .ruleId + "`: " + .message.text, "app/movie-uploader/src/main/java/" + .locations[].physicalLocation.artifactLocation.uri, .locations[].physicalLocation.region.startLine ] | @csv'
      | bin/reviewdog -reporter=gitlab-mr-discussion -efm '"%m","%f",%l' -name="spotbugs" -tee
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - .m2/repository
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "app/**/*"

GitLabでMavenのローカルリポジトリをcacheしようとすると`WARNING: .m2/repository: no matching files`でキャッシュが効かない

問題

GitLabを使ってJavaのプロジェクトのCIを回す時、Mavenによるアーティファクトのダウンロード時間が問題になります。 CIにおいては、これはMavenのローカルリポジトリをキャッシュすることによって解決される問題です。

ぼくは.gitlab-ci.ymlに以下の様なキャッシュの設定を行っていました。しかし、なぜかキャッシュが効いておらず、アーティファクトがダウンロードされ続けている。

unittest:
  variables:
    MAVEN_CLI_OPTS: "--batch-mode"
  script:
    - mvn ${MAVEN_CLI_OPTS} -f app/movie-uploader/pom.xml test
    (snip)
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - .m2/repository

原因

この原因は、GitLabにおけるcache:pathsディレクティブは、CI_PROJECT_DIR内のパスを指定する必要があると言う仕様の問題でした。

An array of paths relative to the project directory ($CI_PROJECT_DIR).

cache:paths

CI_PROJECT_DIRは、GitLab Runnerの中でリポジトリがクローンされるディレクトリです。

The full path the repository is cloned to, and where the job runs from.

Predefined variables reference

GitLabのShared RunnerでCIを実行していたのですが、この場合のローカルリポジトリの場所は/root/.m2/repositoryになっており、$CI_PROJECT_DIR外でした。

解決策

maven.repo.local${CI_PROJECT_DIR}内を指すように指定しましょう。 MAVEN_OPTS環境変数に設定しておけば、mvnを実行するときにも意識せずに済みます。

unittest:
  variables:
    MAVEN_OPTS: "-Dmaven.repo.local=${CI_PROJECT_DIR}/.m2/repository"
    MAVEN_CLI_OPTS: "--batch-mode"
  script:
    - mvn ${MAVEN_CLI_OPTS} -f app/movie-uploader/pom.xml test
    (snip)
  cache:
    key: ${CI_COMMIT_REF_SLUG}
    paths:
      - .m2/repository

これにより、以下のRunnerのログのようにキャッシュが正常に構築されます。

reating cache feature-memories-bucket...
.m2/repository: found 3142 matching files and directories 
Uploading cache.zip to https://storage.googleapis.com/gitlab-com-runners-cache/project/xxxxxxxx/yyyyyyy
Created cache

QuickTimeの日時情報は32-bit integerであり未設定の場合は1904/01/01 00:00:00 (UTC)扱いになる

メタデータであるCreation Timeを削除したQuickTimeファイルから、Creation Dateを読み出そうとすると例外が発生するように実装しました。 使用しているライブラリはこちらです。

public class QuickTimeCreationTimeKeyMapper implements MovieFileKeyMapper {
    @Override
    public String map(File movieFile) {
        Metadata metadata = null;
        try {
            // 録画日時の抽出
            metadata = ImageMetadataReader.readMetadata(movieFile);
            Collection<QuickTimeDirectory> directories = metadata.getDirectoriesOfType(QuickTimeDirectory.class);
            Date creationDate = directories.stream()
                    .map(d -> d.getDate(QuickTimeDirectory.TAG_CREATION_TIME))
                    .filter(d -> d != null)
                    .findAny()
                    .orElseThrow(() -> new MovieParseException(
                            String.format("Error occurred in parsing %s metadata", movieFile.getPath()),
                            movieFile));

            // snip
        } catch (ImageProcessingException | IOException e) {
            throw new MovieParseException("Failed to generate Object Key for S3", movieFile, e);
        }
    }
}

しかし、Creation Timeを削除したファイルに対して例外が発生することをテストしても、当該のテストがなぜかFailするという問題。 どうも、CreationTimeが格納されていない場合であっても、d.getDate(QuickTimeDirectory.TAG_CREATION_TIME)はnullを返さないようです。

QuickTimeにおける日時情報の仕様

「Creation Timeが格納されていない」というのはQuickTimeフォーマットではどういうことを意味するのか、QuickTimeの仕様を確認しました。 QuickTimeでは日時情報を、1904/01/01からの経過秒として32-bit integerで表現するとあります。また、それはUTCとして保持すべきとされています。

QuickTime movies store date and time information in Macintosh date format: a 32-bit value indicating the number of seconds that have passed since midnight January 1, 1904. This value does not specify a time zone. Common practice is to use local time for the time zone where the value is generated. It is strongly recommended that all calendar date and time values be stored using UTC time, so that all files have a time and date relative to the same time zone.

Basic Data Types

必要な実装

従って「Creation Time」が格納されていない場合は、QuickTimeフォーマット上では32-bit integerとして0が格納されているでしょう。 Javaだと1904/01/01 00:00:00.0Dateが返却されると想定できます。

この情報を元に、有効日時が返却されているかを確認するPredicateとしてhasValidDateを用意します。

public class QuickTimeCreationTimeKeyMapper implements MovieFileKeyMapper {

    protected static final Date ZERO_VALUE_DATE;
    static {
        ZonedDateTime utcDateTime = ZonedDateTime.of(1904,1,1,0,0,0,0, ZoneId.of("UTC"));
        ZERO_VALUE_DATE = Date.from(utcDateTime.toInstant());
    }

    protected Logger logger = LoggerFactory.getLogger(QuickTimeCreationTimeKeyMapper.class);

    protected boolean hasValidDate(final Date date) {
        if (date == null) {
            return false;
        }

        // QuickTime仕様におけるCreation Timeは、1904/01/01 (UTC)からの経過秒を32-bit integerで表現するため、
        // Creation Timeが格納されていない場合は、1904/01/01 00:00:00(UTC)が返却される
        if (ZERO_VALUE.equals(date)) {
            return false;
        }
        return true;
    }
    
    // snip
}

このhasValidDateでfilterを行うことで、無事にCreation Timeが格納されていないファイルに対して例外が発生するようになりました。

    Date creationDate = directories.stream()
        .map(d -> d.getDate(QuickTimeDirectory.TAG_CREATION_TIME))
        .filter(d -> hasValidDate(d))
        .findAny()
        .orElseThrow(() -> new MovieParseException(
            String.format("Error occurred in parsing [%s]'s creation date", movieFile.getPath()),
            movieFile));

# Creation Time未設定データの準備

$ exiftool -CreationDate= -CreateDate= -ModifyDate= target_date.mov

MOVファイルから撮影日時を抽出する

いろいろと撮影した動画が溜まってきたので管理をしたい。そこで必要になるのが撮影日付です。 なんとかシステマチックに抽出できないかと思って、試しておりました。

コマンドラインで抽出する

ffmpegを使うと、対照ファイルからmetadataが抽出できます。 そこから、作成日時を指すcom.apple.quicktime.creationdateで検索しましょう。

$ ffmpeg -i target.MOV -f ffmetadata pipe:1 2>/dev/null | grep com.apple.quicktime.creationdate
com.apple.quicktime.creationdate=2021-01-17T16:12:43+0900

com.apple.quicktime.creationdateは動画ファイルの作成日が格納されるキーとして仕様に定義されているものです。

また、ExifToolでも同様の情報が抽出できます。

$ exiftool -CreationDate target.MOV
Creation Date                   : 2021:01:17 16:12:43+09:00

Javaで抽出する

様々なメディアファイルからメタデータを抽出できるmetadata-extractorがあるので、これを利用します。

このライブラリを使うと、以下のような形でcreationDateが抽出できます。 ライブラリのソースコードを見ても、com.apple.quicktime.creationdateが登場しているのが確認できますね。

package de.kiririmo.keymapper;

import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.metadata.Metadata;
import com.drew.metadata.mov.QuickTimeDirectory;
import de.kiririmo.MovieUploadException;

import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Date;

public class QuickTimeCreationTimeKeyMapper implements MovieFileKeyMapper {

    @Override
    public String map(File movieFile) throws RuntimeException {
        Metadata metadata = null;
        try {
            metadata = ImageMetadataReader.readMetadata(movieFile);
            Collection<QuickTimeDirectory> directories = metadata.getDirectoriesOfType(QuickTimeDirectory.class);
            // 作成日付のメタデータ抽出
            Date creationDate = directories.stream()
                    .map(d -> d.getDate(QuickTimeDirectory.TAG_CREATION_TIME))
                    .filter(d -> d != null)
                    .findAny()
                    .orElseThrow(() -> new RuntimeException(String.format("Error occurred in parsing %s metadata", movieFile.getPath())));

            SimpleDateFormat formatter = new SimpleDateFormat("yyyy/MM/dd");
            return String.format("%s/%s", formatter.format(creationDate), movieFile.getName());
        } catch (ImageProcessingException | IOException e) {
            throw new MovieUploadException("Failed to generate Object Key for S3", movieFile, e);
        }
    }
}```