JavaFXのウィンドウやSVG画像への図の描画

本記事はMuroran Institute of Technology Advent Calendar 2019 12/14の記事です.

adventar.org

概要

本記事では

  1. JavaFXウィンドウへの図の描画,
  2. SVG画像への図の描画,
  3. JavaFXウィンドウとSVG画像への同じ図の描画

について具体的なGradleプロジェクトの例を用いて説明します.

JavaFXJavaGUIライブラリの一つです. JavaFXはJava8で標準ライブラリに追加されてJDKの一部となりましたが,Java11からはJDKに同梱されなくなり,代わりにOpenJFXを利用することができるようになりました. 1.では,JavaFXCanvasクラスに図の描画を行うためにorg.jfreeのFXGraphics2DというライブラリのFXGraphics2Dクラスを利用します. FXGraphics2Dクラスは標準ライブラリのGraphics2Dクラスを継承しているため,Graphics2Dクラスと同じように扱うことができます.

SVG画像はベクタ画像(品質の劣化なくサイズを変更可能な形式の画像)の一つです. SVG画像の形式はXML形式で,仕様はW3Cによって勧告されています. 2.では,SVG画像に図の描画を行うためにorg.jfreeのJFreeSVGというライブラリのSVGGraphics2Dクラスを利用します. SVGGraphics2Dクラスも標準ライブラリのGraphics2Dクラスを継承しており,Graphics2Dクラスと同じように扱うことができます.

3.では,FXGraphics2DクラスとSVGGraphics2DクラスがGraphics2Dクラスを継承していることを生かたポリモーフィックなプログラムによって,JavaFXウィンドウとSVG画像への同じ図の描画を行います.

1.,2.,3.はOpenJFX,FXGraphics2D,JFreeSVGといった標準のJDKに含まれないライブラリを利用しますが,これらのライブラリの管理を含めたプロジェクトのビルドを行うために,本記事ではGradleというビルドツールを使用します. Gradleはbuild.gradleという設定ファイルに従って,コンパイル,ライブラリの用意,実行といった操作を自動的に行うソフトウェアです.

以下では,まず,本記事の実行環境について説明し,Graphics2Dクラスの基本的な使い方を示します. その後1.,2.,3.についてそれぞれ,Gradleプロジェクトの例を示します. 本記事ではJavaの環境としてJava13を利用しました.

実行環境

本記事のプログラムは,次のようにGitHubリポジトリからクローンして実行することができます.

git clone https://github.com/Jumpaku/AdventCalendar_2019-12-14.git
cd AdventCalendar_2019-12-14

# JavaFXウィンドウへの図の描画
cd example1
./gradlew run
# ./gradlew.bat run (Windowsの場合) 

# SVG画像への図の描画
cd ../example2
./gradlew run
# ./gradlew.bat run (Windowsの場合) 

# JavaFXウィンドウとSVG画像への同じ図の描画
cd ../example3
./gradlew run
# ./gradlew.bat run (Windowsの場合) 

ただし,Java13とGitがインストールされていることが前提となります.

このリポジトリは以下のように3つのプロジェクトexample1,example2,example3で構成されています. f:id:Jumpaku:20191213230441p:plain 例えば,example1ディレクトリで./gradlew runを実行すると,Gradleがexample1/build.gradleに書かれたプロジェクトの設定に従ってプロジェクトをビルドし,起動します. プログラムが起動されるとexample1/src/main/java/example1/Main.javamainメソッドが呼ばれます. このmainメソッドはFXGraphics2Dクラスを利用します. FXGraphics2Dクラスはorg.jfreeのFXGraphics2Dというライブラリに含まれています. 必要となるライブラリをbuild.gradleに書いておくと,ビルドする時にGradleによってダウンロードされてライブラリを利用できるようになります.

Graphics2Dの基本的な使い方

Graphics2Dの基本的な使い方として

  • 基本図形のストローク描画とフィル描画
  • パス操作
  • 領域操作
  • アフィン変換

を示します.

基本図形のストローク描画とフィル描画

    public static void exampleDrawAndFill(Graphics2D g) {
        // 楕円を表現するEllipse2Dオブジェクトを生成する
        // 基本図形は他にLine2D(線分),Rectangle2D(長方形),Arc2D(弧)などもある
        Ellipse2D ellipse = new Ellipse2D.Double(0.0, 25.0, 100.0, 50.0);
        // Ellipseオブジェクトの内部の塗りつぶし色を設定する
        g.setColor(new Color(255, 75, 0));
        // Ellipseオブジェクトの内部を塗りつぶす
        g.fill(ellipse);
        // Ellipseオブジェクトの輪郭線の色を黒に設定する
        g.setColor(Color.BLACK);
        // Ellipseオブジェクトの輪郭線を太さ1の線に設定する
        g.setStroke(new BasicStroke(1f));
        g.draw(ellipse);
    }

パス操作

    public static void examplePath(Graphics2D g) {
        // 複雑なパス図形を表現できるPath2Dオブジェクトを生成する
        Path2D curve = new Path2D.Double();
        // Path2Dオブジェクトの開始点を設定する
        curve.moveTo(125.0, 25.0);
        // Path2Dオブジェクトの開始点に,3次Bezier曲線を接続する
        curve.curveTo(200.0, 25.0, 100.0, 75.0, 175.0, 75.0);
        // Path2Dオブジェクトの輪郭線を描画する
        g.setColor(new Color(3, 175, 122));
        g.setStroke(new BasicStroke(1f));
        g.draw(curve);
        // Path2Dオブジェクトを生成する
        Path2D polyline = new Path2D.Double();
        // Path2Dオブジェクトの開始点を設定する
        polyline.moveTo(125.0, 25.0);
        // Path2Dオブジェクトの開始点に,線分を接続する
        polyline.lineTo(200.0, 25.0);
        // Path2Dオブジェクトに,さらに線分を接続する
        polyline.lineTo(100.0, 75.0);
        // Path2Dオブジェクトに,さらに線分を接続する
        polyline.lineTo(175.0, 75.0);
        // Path2Dオブジェクトの輪郭線を黒い太さ1線に設定し,描画する
        // BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUNDは線分の端点や接続部分を丸くするための設定
        g.setColor(Color.BLACK);
        g.setStroke(new BasicStroke(1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        g.draw(polyline);
    }

領域操作

    public static void exampleArea(Graphics2D g) {
        // 3個のEllipse2Dオブジェクトを生成する
        Ellipse2D ellipse0 = new Ellipse2D.Double(0.0, 100.0, 100.0, 50.0);
        Ellipse2D ellipse1 = new Ellipse2D.Double(0.0, 100.0, 50.0, 100.0);
        Ellipse2D ellipse2 = new Ellipse2D.Double(0.0, 100.0, 50.0, 50.0);
        // 3個のEllipse2Dオブジェクトの和集合領域を表現するAreaオブジェクトを生成する
        Area union = new Area(ellipse0);
        union.add(new Area(ellipse1));
        union.add(new Area(ellipse2));
        // 3個のEllipse2Dオブジェクトの共通集合領域を表現するAreaオブジェクトを生成する
        Area intersection = new Area(ellipse0);
        intersection.intersect(new Area(ellipse1));
        intersection.intersect(new Area(ellipse2));
        // 和集合領域と共通集合領域を塗りつぶす
        g.setColor(new Color(77, 196, 255));
        g.fill(union);
        g.setColor(new Color(0, 90, 255));
        g.fill(intersection);
        // 和集合領域と共通集合領域の輪郭線を描画する
        g.setColor(Color.BLACK);
        g.setStroke(new BasicStroke(1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        g.draw(union);
        g.draw(intersection);
    }

アフィン変換

    public static void exampleTransform(Graphics2D g) {
        // 弧を表現するArc2Dオブジェクトを生成する
        Arc2D arc = new Arc2D.Double(-1.0, -1.0, 2.0, 2.0, -45.0, 270.0, Arc2D.PIE);
        // アフィン変換を表現するAffineTransformオブジェクトを生成する
        AffineTransform t = new AffineTransform();
        t.translate(150.0, 150.0); // 平行移動
        t.rotate(Math.PI / 2); // 回転
        t.scale(50.0, 50.0); // 拡大
        // Arc2Dオブジェクトにアフィン変換した図形オブジェクトを生成する
        Shape transformed = t.createTransformedShape(arc);
        // アフィン変換した図形オブジェクトを塗りつぶす
        g.setColor(new Color(255, 241, 0));
        g.fill(transformed);
        // アフィン変換した図形オブジェクトの輪郭線を描く
        g.setColor(Color.BLACK);
        g.setStroke(new BasicStroke(1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
        g.draw(transformed);
    }

JavaFXウィンドウへの図の描画

JavaFXウィンドウへの図の描画を行うプロジェクトの設定ファイル,メインクラス,実行結果を以下に示します.

build.gradle

plugins {
    id 'java'
    id 'application'
    // JavaFXを利用するためにOpenJFXのプラグインを追加する
    id 'org.openjfx.javafxplugin' version '0.0.8'
}
group 'jumpaku'
version 'SNAPSHOT'
// 使用するライブラリのダウンロード元としてMavenセントラルリポジトリを指定する
repositories {
    mavenCentral()
}
// JavaFXに関する設定をする
javafx {
    // 使用するJavaFXのバージョンを指定する
    version = "13.0.1"
    // JavaFXで使用するモジュールを指定する(モジュールはJava9の機能)
    modules = ['javafx.controls', 'javafx.swing']
}
// example2.Mainクラスをメインクラスとして指定する
mainClassName = 'example1.Main'
// FXGraphics2Dライブラリを追加する
dependencies {
    implementation group: 'org.jfree', name: 'fxgraphics2d', version: '1.8'
}

Main.java

package example1;
/*import...*/
public class Main {
    /**
     * JavaFXウィンドウに図を描画する
     * さらに描画した図をpng画像としてファイルに書き出す
     * @param args
     */
    public static void main(String[] args) {
        // Appクラスを指定してアプリケーションを起動する
        Application.launch(App.class, args);
    }
    public static class App extends Application {
        /**
         * アプリケーション起動時に呼び出される
         * @param primaryStage
         */
        @Override
        public void start(Stage primaryStage) throws IOException {
            // Canvasオブジェクトを用意する
            int width = 320;
            int height = 240;
            Canvas canvas = new Canvas(width, height);
            // CanvasオブジェクトのGraphicsContext2DオブジェクトをFXGraphics2Dクラスのコンストラクタに渡す
            GraphicsContext ctx = canvas.getGraphicsContext2D();
            Graphics2D g = new FXGraphics2D(ctx);
            // Graphics2Dクラスを通してCanvasオブジェクトに図を描画する
            exampleDrawAndFill(g);
            examplePath(g);
            exampleArea(g);
            exampleTransform(g);
            // Canvasオブジェクトをシーングラフに追加し,ウィンドウを表示する
            primaryStage.setScene(new Scene(new Pane(canvas)));
            primaryStage.show();
            // CanvasオブジェクトのPNG画像を書き出す
            SnapshotParameters params = new SnapshotParameters();
            WritableImage img = canvas.snapshot(params, new WritableImage(width, height));
            File file = new File("example.png");
            ImageIO.write(SwingFXUtils.fromFXImage(img, null), "png", file);
        }
    }
    public static void exampleDrawAndFill(Graphics2D g) {/*...*/}
    public static void examplePath(Graphics2D g) {/*...*/}
    public static void exampleArea(Graphics2D g) {/*...*/}
    public static void exampleTransform(Graphics2D g) {/*...*/}
}

実行結果

example1ディレクトリにおいて,./gradlew runを実行して表示されたウィンドウを以下に示します.

f:id:Jumpaku:20191212024459p:plain

また,書き出されたPNG画像(example1/example.png)を以下に示します.

f:id:Jumpaku:20191212024023p:plain

SVG画像への図の描画

SVG画像への図の描画を行うプロジェクトの設定ファイル,メインクラス,実行結果を以下に示します.

build.gradle

plugins {
    id 'java'
    id 'application'
}
group 'jumpaku'
version 'SNAPSHOT'
// 使用するライブラリのダウンロード元としてMavenセントラルリポジトリを指定する
repositories {
    mavenCentral()
}
// example2.Mainクラスをメインクラスとして指定する
mainClassName = 'example2.Main'
// JFreeSVGライブラリを追加する
dependencies {
    implementation group: 'org.jfree', name: 'jfreesvg', version: '3.4'
}

Main.java

package example2;
/*import...*/
public class Main {
    /**
     * SVG画像に図を描画しファイルに書き出す
     * @param args
     */
    public static void main(String[] args) {
        // SVGGraphics2Dオブジェクトを用意する
        SVGGraphics2D g = new SVGGraphics2D(320, 240);
        // SVGGraphics2Dオブジェクトに図を描画する
        exampleDrawAndFill(g);
        examplePath(g);
        exampleArea(g);
        exampleTransform(g);
        // SVGGraphics2DオブジェクトからSVG画像の文字列を取得しファイルに書き出す
        String svg = g.getSVGDocument();
        try (PrintWriter writer = new PrintWriter(new File("./example.svg"))) {
            writer.print(svg);
        } catch (FileNotFoundException exp) {
            exp.printStackTrace();
        }
    }
    public static void exampleDrawAndFill(Graphics2D g) {/*...*/}
    public static void examplePath(Graphics2D g) {/*...*/}
    public static void exampleArea(Graphics2D g) {/*...*/}
    public static void exampleTransform(Graphics2D g) {/*...*/}
}

実行結果

example2ディレクトリにおいて,./gradlew runを実行して書き出されたSVG画像(example2/example.svg)を以下に示します.

JavaFXウィンドウとSVG画像への同じ図の描画

JavaFXウィンドウとSVG画像への同じ図の描画を行うプロジェクトの設定ファイル,メインクラス,実行結果を以下に示します.

build.gradle

plugins {
    id 'java'
    id 'application'
    // JavaFXを利用するためにOpenJFXのプラグインを追加する
    id 'org.openjfx.javafxplugin' version '0.0.8'
}
group 'jumpaku'
version 'SNAPSHOT'
// 使用するライブラリのダウンロード元としてMavenセントラルリポジトリを指定する
repositories {
    mavenCentral()
}
// JavaFXに関する設定をする
javafx {
    // 使用するJavaFXのバージョンを指定する
    version = "13.0.1"
    // JavaFXで使用するモジュールを指定する
    modules = ['javafx.controls', 'javafx.swing']
}
// example3.Mainクラスをメインクラスとして指定する
mainClassName = 'example3.Main'
// FXGraphics2DライブラリとJFreeSVGライブラリを追加する
dependencies {
    implementation group: 'org.jfree', name: 'fxgraphics2d', version: '1.8'
    implementation group: 'org.jfree', name: 'jfreesvg', version: '3.4'
}

Main.java

package example3;
/*import...*/
public class Main {
    /**
     * JavaFXウィンドウ上にドラッグ軌跡を描画するPNG画像に書き出す
     * JavaFXウィンドウに描画されたものと同じドラッグ軌跡をSVG画像にも書き出す
     */
    public static void main(String[] args) {
        // Appクラスを指定してアプリケーションを起動する
        Application.launch(App.class, args);
    }
    public static class App extends Application {
        /** 直前のドラッグ位置 */
        Point2D previousPoint;
        /**
         * アプリケーション起動時に呼び出される
         * @param primaryStage
         */
        @Override
        public void start(Stage primaryStage) {
            int width = 640;
            int height = 480;
            // Canvasオブジェクトを生成する
            Canvas canvas = new Canvas(width, height);
            // FXGraphics2Dオブジェクトを生成する
            FXGraphics2D fxGraphics2D = new FXGraphics2D(canvas.getGraphicsContext2D());
            // SVGGraphics2Dオブジェクトを生成する
            SVGGraphics2D svgGraphics2D = new SVGGraphics2D(width, height);
            // FXGraphics2DオブジェクトとSVGGraphics2Dオブジェクトをまとめたリストを生成する
            List<Graphics2D> graphics2DList = Arrays.asList(fxGraphics2D, svgGraphics2D);
            // Canvasオブジェクトにマウスプレス時のイベントハンドラを設定する
            canvas.setOnMousePressed(e -> {
                // マウスプレス時には図をクリアして現在のカーソル位置を保存する
                graphics2DList.forEach(g -> {
                    g.setBackground(Color.WHITE);
                    g.clearRect(0, 0, width, height);
                });
                previousPoint = new Point2D.Double(e.getX(), e.getY());
            });
            // Canvasオブジェクトにマウスドラッグ時のイベントハンドラを設定する
            canvas.setOnMouseDragged(e -> {
                // マウスドラッグ時には直前のカーソル位置と現在のカーソル位置の間に線分を描画して現在のカーソル位置を保存する
                Point2D.Double currentPoint = new Point2D.Double(e.getX(), e.getY());
                graphics2DList.forEach(g -> g.draw(new Line2D.Double(previousPoint, currentPoint)));
                previousPoint = currentPoint;
            });
            // Canvasオブジェクトにマウスリリース時のイベントハンドラを設定する
            canvas.setOnMouseReleased(e -> {
                // マウスリリース時にはSVG画像とPNG画像の書き出しを行う
                // PNG画像を書き出す
                SnapshotParameters params = new SnapshotParameters();
                WritableImage img = canvas.snapshot(params, new WritableImage(width, height));
                try {
                    File file = new File("example.png");
                    ImageIO.write(SwingFXUtils.fromFXImage(img, null), "png", file);
                } catch (IOException ex) {
                    ex.printStackTrace();
                }
                // SVG画像を書き出す
                String svg = svgGraphics2D.getSVGDocument();
                try (PrintWriter writer = new PrintWriter(new File("./example.svg"))) {
                    writer.print(svg);
                } catch (FileNotFoundException exp) {
                    exp.printStackTrace();
                }
            });
            // Canvasオブジェクトをシーングラフに追加し,ウィンドウを表示する
            primaryStage.setScene(new Scene(new Pane(canvas)));
            primaryStage.show();
        }
    }
}

実行結果

example3ディレクトリにおいて,./gradlew runを実行しました. 表示されたウィンドウにドラッグ軌跡が描画される様子を以下に示します.

書き出された(example3/example.png)を以下に示します. f:id:Jumpaku:20191213233934p:plain 書き出されたSVG画像(example3/example.svg)を以下に示します.

まとめ

  • Graphic2Dクラスの基本的な使い方として,基本図形のストローク描画とフィル描画,パス操作,領域操作,アフィン変換を示した.
  • JavaFXのウィンドウやSVG画像に図を描画する例を示した.
  • FXGraphics2DやSVGGraphics2Dを利用しGraphic2Dクラスを通して統一的なプログラムで図の描画を行う例を示した.
  • Java13で,JavaFX,FXGraphics2D,JFreeSVGといったライブラリを利用するGradleプロジェクトの設定例を示した.
  • JavaFXJDKに同梱されなくなって,準備が面倒だと思った.

ICPC 2019 Asia Yokohama Regionalに参加

本記事はMuroran Institute of Technology Advent Calendar 2019 12/13の記事です.

adventar.org

はじめに

私はこれまで,2016年,2017年,2018年のICPC国内予選に選手として参加しています. 今回も選手として参加したかったのですが,今回は選手としての参加資格がありませんでした. 他の参加選手からコーチになるように頼まれたので,引き受けることにしました. 今回,私の大学から5チームが国内予選に参加し,そのうち1チームが横浜のアジア地区予選に出場することとなり,コーチとして一緒に行ってきました. 以下ではICPC 2019 Asia Yokohama Regionalに参加した感想を時系列順に述べます.

ICPC 2019 Yokohama Regional 国内予選

準備

コーチの最も重要な任務はチーム登録と実行委員会からのメールの伝達です. 特にチーム登録は意外と大変でした. 選手はアカウントを作成し,これをコーチに伝えます. コーチは選手を検索し,作成したチームに選手を追加します. その後,選手たちは登録情報の補充をします. あるチームでは,アカウント登録の完了がなかなか終わらずチーム登録を完了できません. また,あるチームでは,チーム登録まで完了しても,登録情報の補充が完了しない選手がいます. 催促をしてもなかなか反応がないこともありました. 登録期限が迫る中,私は選手登録とチーム登録が完了できるか心配でした. 最終的には全員参加できたので良かったです. 私が,過去にICPC国内予選に参加した時のコーチも同じような思いをしていたと思うと,改めて感謝の念を抱きました.

他にも,選手たちと一緒に監督を引き受けてくれる先生を探し,引き受けてくれるようにお願いしました. 監督の仕事は,試験監督のような感じで,国内予選当日に問題のコピーを配り,選手が不正しないようにを監視することです. また,監督は必要に応じて審判団との連絡も行います. 私の指導教員が引き受けてくれてありがたかったです.

本番

ICPC国内予選当日,コーチの仕事はありませんでした. 室蘭工業大学からはSataniChoが横浜のアジア地区予選に出場することとなりました.

問題を解いた

選手たちと同じモチベーションで打ち上げに臨むためには,同じ問題を解いて同じ熱を纏う必要があります. 研究室でICPCに参加していないメンバ2人を誘って問題を解く会を開催しました. 私たちは正式な参加ではないため,パソコンは1人1台使用可,インターネット接続可,複数ディスプレイ可で解きました. 他の2人がA,Bを担当し,私はCを担当しました. 最初C++で解こうと思いましたが,組み合わせを全て列挙すれば良いことに気がついてPythonのitertoolsを使うことにしました. 他の2人がA,Bを解き終わってからは3人で協力して解きました. ほぼ3時間以内に解き終わることができました. Pythonが遅すぎたため,最後はPyPyで実行しました. 協力しながら解きごたえのある問題を解くことができて楽しかったです.

ICPC 2019 Asia Yokohama Regional

横浜で開かれるアジア地区予選に出場するSataniChoの選手たちには旅費が支給されます. なんと,コーチにも旅費が支給され,一緒に行くことができました. 日程は3日間で,1日目はリハーサル,2日目は本番と立食パーティ,3日目は会社見学でした.

1日目

5時半くらいに起床し,荷物の準備をしました. 6:15に待ち合わせをして,選手の車に乗せていただき,室蘭工業大学から新千歳空港まで行きました. 自転車では半日かかる約70kmの道のりですが,車だったため9:30出発の飛行機に余裕を持って到着しました. 昼過ぎに羽田空港に到着し蒙古タンメンを食べに行きました. f:id:Jumpaku:20191213033148j:plain 客が並んでいたため,リハーサルの集合時刻に間に合うか際どいところでしたが,結果的には間に合ったと思います. 会場の横浜産貿ホール マリネリアに到着すると英語で質問されました. 大学名をきかれたのだと解釈し,"Muroran Institute of Technology"と回答しましたが,ピンとこなかったようだったため,"From Hokkaido"と言い直したら,受付へ通されました. あとで考え直すと,もしかしたら北海道大学と勘違いされていたかもしれません. アジア地区予選の公用語は英語なのかと察しました. 受付では,スタッフにStudent IDを見せてくれと英語で言われましたが,よく聞き取れずにいるともう1人のスタッフが"Gakusei shou"と分かりやすい英語で教えてくれました.

リハーサル会場にはチームごとにブースが設けられており,水,弁当,しおり,Tシャツ,参加証明書,ネームタグが支給されました. Tシャツとネームタグは大会の間はずっと付けなければいけないようでした. 今まで選手登録の度に,なぜTシャツのサイズが要求されるのか疑問でしたが,会場で支給されるからだと知ることができました. また,朝時間がなくて3日間の着替えの準備が不十分だったのですが,Tシャツが支給されたことによりこの問題が解決しました.

リハーサルでは,まず諸注意があり,デモンストレーションがあり,その後,選手たちが実際にリハーサル問題を解きます. 国内予選を勝ち抜いた選手たちからは,多くのことを学びました. C++ラムダ式は変数に代入した上で,引数で自分自身を受け取ることで再帰することができることを知った時は衝撃を受けました. また,アジア地区予選ではジャッジシステムを用いるため,実行時間に何時間も使って強引に解くことはできないことも知りました. そして,ACする度にスタッフが風船を持ってやってきて,ブースの机の横に引っ掛けていきます. これはとてもにぎやかな感じがしました. しかし,コーチがその場でするべきことは特にありません. 自分はなぜここにいるのか,という当初から存在していた答えの無い問いと向き合う決心をしました. 起床が早朝だったため,気がつくとウトウトしていました.

リハーサル後,私たちは会場の近くの中華街へ夕食を食べにいきました. まず,インターネットで調べて人気そうだった中華料理屋に並びました. 行列に並ぶのが嫌になったため,その向かいの中華料理屋へいくことになりました. その店は2階建てで,2階のテーブルに案内されました. そこにはスタッフが1人いました. 2階には客用のテーブルが並んでいましたが,厨房などは無いようでした. いったい料理はどこから来るのか,食器はどこへ帰っていくのかと思いましたが,料理用エレベータで上ってきた料理を客に出し,片付けた食器を料理用エレベータで下ろしていたのでした. 私は色々な料理を食べることのできるコース料理を注文しようかとも思いましたが,高かったため,回鍋肉定食を注文しました. とても美味しく,また,お腹も満たされました. f:id:Jumpaku:20191213033435j:plain

ホテルに着いて21時になるとAtCoder Beginner Contest 145が始まりました. そのE問題のTLEを修正することができないまま終わってしまいました. atcoder.jp 提出したプログラムはソートとDPを組み合わせた解法で,計算量も解法も想定解法と同じはずでしたがTLEとなってしまいました. 原因の究明をしたいとは思いましたが,あまり寝るのが遅くなって寝坊したら選手たちに申し訳ないため,すぐに寝ました.

2日目

本番では,コーチは会場である横浜産貿ホール マリネリアに入ることができず,会場横の神奈川県民ホールで待機することになっていました. ここで,遂にコーチが活躍する機会が訪れました. 選手たちは時計やスマートフォンなどを会場に持ち込めないため,コーチが預かることになったのです. 選手の役に立つことができました.

神奈川県民ホールに着いたのは8:50くらいでしたが,開館時刻が9:00と書いてあったため待つこととしました. 入り口前で待っているとスタッフが1人やってきました. 公用語が英語だと察した私は,挨拶は"Good morning"か"Hello"どちらが良いかと迷いましたが,「おはようございます」言われました. そこは日本語なのか,と混乱した私から1呼吸遅れて出てきたのはカタコトの"Ohayou gozaimasu"でした. そのスタッフが言うには,神奈川県民ホールは開館が9:00であるため,そこから準備が始まるらしく,大変なんだなと思いました.

開館後どうして良いか分からなかった私は周りの人についていくことにしました. みんなの向かう先には,ステージのある大きなホールがあり,多くの椅子が並んでいました. 座っていると着々と準備が進んでいき,ステージ上で大会の中継が始まりました. しばらくすると問題用紙も配布されました. コーチはこのホールで大会が終わるまで中継を見たり,問題を読んだりしながら待機するのかと思いかけたところで,周りを見渡すと,あまりにも人が少ないことに気がつきました. 少し飽きた私はお手洗いのついでに,ホールの外のロビーの様子を見に行くことにしました.

ロビーには軽食が並べられており,問題用紙を広げながら楽しそうに議論をしている他のコーチたちが,何人かいました. ロビーの椅子の方が座りやすため,そこで軽食をいただきながら昨日のAtCoderのE問題を解き直そうと思いました. しかし,AtCoder後に充電するのを忘れていたため,私のMacBookProの充電は風前の灯火でした. 使用可能な電源がないようだったため,パソコンの使用は諦めることにしました. 大会終了までまだまだ時間があります. やることが無くて困った私はSataniChoのその時点での順位の確認をしながら,気がつくと鞄を抱き締めたままウトウトしていました. 周りが賑やかになってきたことに気がついて周囲を見ると選手たちがいて,大会が終了したことを知りました.

その後ホールに移動し,解説と最終順位の発表,協賛企業からのお話などを聞きました. f:id:Jumpaku:20191213033021p:plain そして,みんなで横浜産貿ホール マリネリアへ向かい,そこで表彰式と立食パーティがありました. SataniChoも何かの賞を受賞していました.

立食パーティでは,まず,ご飯を食べた後,各企業の紹介ブースを回りました. 初めて見る会社も多くあり視野が広がりました. また,鞄に入り切らないほど多くの景品をいただきました. 他にも,タピオカミルクティを獲得するために数独を解いたり,笑顔度を測定したりしました.

3日目

最終日は会社見学でした. 私たちのグループはHauwei,NECしながわ水族館の順に見学をしました.

Hauwei

8:15分にホテルをチェックアウトし,Hauweiへ向かいました. そこでは,Hauweiの日本の研究所の会社説明を受け,エンジニアたちからそれぞれの仕事内容を聞き,エンジニアと昼食をいただきました. f:id:Jumpaku:20191213033048j:plain 私たちと一緒に昼食を食べたエンジニアは,写真が好きで,カメラに関する仕事をしているということで,写真やカメラに対する熱い想いを感じました.

また,しながわ水族館に先に行った他のグループは昼食をどうしているのだろうか,といった会話をSataniChoのチームメイトとした時に,私が「魚料理でも食べているんじゃないか」とジョークを言うと向かいで座っていた他チームの方が笑ってくれて良い人だなと思いました.

NEC

Hauweiの次にNECへ行きました. 移動は個人行動ということになっていたと思いますが,他の選手たちについて行きました. その選手たちも途中で道が分からなくなり,NECと書かれた大きなビルをに向かって歩きました. 一応,選手の監視役のようなICPCスタッフもいましたが,選手の引率役ではないようでした.

NECではAIの話とセキュリティの話を聞いた後で,顔認証技術を利用したシステムのデモンストレーションを見ました.

AIの話では「あの頃は CHOCOLATE」というお菓子の開発について聞きました. これは各時代の新聞から取り出した単語と単語から連想される味を組み合わせたデータを学習して,ある時代のムードをチョコレートの味で再現するというもののようです. その時に流された動画もとてもお洒落でした. しかし,AIが再現した味が本当にその時代のムードを表現できているのかどうかは,確かめられないのでは? と思い,質問しました. これに対しNECの社員は,待ってましたと言わんばかりに回答を始め,私は一本取られたなと思いました.

また,セキュリティの話ではCTFの紹介があり,興味を持ちました.

顔認証技術を利用したシステムのデモンストレーションでは,まるでPSYCHO-PASS攻殻機動隊のような近未来感のあるシステムが紹介され,とてもワクワクしました.

しながわ水族館

NECの後はしながわ水族館へ向かいました. その時には,私は監視役について行けば,しながわ水族館にたどり着けると気がついていました. 他の選手たちもそのことに気づいたようでした. 私は降りる駅もわからないまま監視役について行きました. まるで,ハンター試験のようだと思いました. しかし,一部の選手たちが途中で違う電車に乗ったり,別の駅で降りたりしようとした際には,監視役は選手たちが間違わないように指示を出し,最後には先頭を歩いてしながわ水族館まで選手たちを導きました. 実は優しい人だったのかと思いました.

また,移動途中の電車内においては,Hauweiで私のジョークに笑ってくれた選手が,僕たちはいつまでこのネームタグをつけていなければいけないのか,という話の中で,「ACした時の風船をつけるよりマシだ」とジョークを言っていて面白いと思いました.

しながわ水族館では,自由行動ということだったので,閉館まで見て回りました. まず,アシカかセイウチのショーを見ました. 水族館スタッフがアシカとセイウチを見分けるには耳を見れば良いと言っていた気がしますが,どっちがどっちかは,もう忘れてしまいました. 他にもクラゲ,イカ,エイ,ペンギン,サメ,ドクターフィッシュなどが印象的でした.

f:id:Jumpaku:20191213033121j:plainf:id:Jumpaku:20191213033543j:plainf:id:Jumpaku:20191213033823j:plainf:id:Jumpaku:20191213033750j:plainf:id:Jumpaku:20191213033217j:plain

特にドクターフィッシュのコーナーでは水槽に手を入れると,ドクターフィッシュが群がってきて意外と高い周波数で手をつつきます. 痛くもなく,痒くもなく,くすぐったくもなく,とても不思議な感覚でした. ちょうどそこで,昼食で私のジョークに笑ってくれて,電車でAC風船のジョークを言っていた方と再会しました. 彼は,ドクターフィッシュの感覚が気に入ったようで,「家でドクターフィッシュを飼って顔を入れたい」とユーモアのあることを言っていました. 彼と話したのそれが最後ですが,TwitterのIDを聞くなど,もっと仲良くなりたかったなと少し後悔しています.

しながわ水族館の会社見学では,ショーの準備をしたり,展示物を工夫したりといった,客を楽ませるための心遣いを感じて,尊敬しました. また,水族館の仕事は展示の他にも環境保全や生態調査もあることを知りました.

帰路

しながわ水族館を出た後は,研究室の在室表のマグネットとして使用できるマグネット付きチンアナゴを記念に買い,その後夕食としてラーメンを食べました.

f:id:Jumpaku:20191213034729j:plainf:id:Jumpaku:20191213033519j:plain

余裕を持って遅い出発時刻の飛行機を予約していたため,時間が余りましたが,早めに羽田空港へ行って,お土産を買ったり,プログラムを書いたりすることにしました.

空港ではさっさと保安検査場を通過した後,お土産としてすいーとぽてたまごを買い,それから搭乗時刻まで一昨日のAtCoderでTLEのまま放置してあったE問題の解き直しました. アルゴリズム自体は想定解法通りであったにも関わらず,実行時間がかかってしまうボトルネックは,DPのメモ化に用いているstd::unordered_mapだろうと考えました. 実際,std::unordered_mapの代わりに2次元にしたstd::vectorを用いるとTLEしませんでした. よく調べてみるとキーの衝突が頻発いていることが分かりました. 私はどうしてもDPのメモ化にハッシュマップを用いたかったため,次の三つを試してみました.

  1. std::unordered_map::reserveを呼んでメモリ確保を最初の一回だけにする.
  2. std::arraystd::mapを組み合わせてシンプルな自前のハッシュマップを実装する.
  3. std::tupleに対して実装しているハッシュ関数を変更する.

まず,1.のみの場合は特に効果を感じませんでした. atcoder.jp 次に,2.のみの場合は,結果はTLEとなりましたが,TLEとなるテストケースが2個に減り,効果があることが分かりました. atcoder.jp さらに,2.と3.を同時に行うと,無事ACできました. atcoder.jp ハッシュ関数は元々Effective Javaを参考に実装していたのですが,よりハッシュ値が分散するようにチューニングしました. 最後に,標準ライブラリがある場合には出来るだけそれを使うべきだと考えて,3.のみの場合についても試してみました. atcoder.jp 結果はTLEでしたが,先ほどの2.のみの場合に残ったTLEのテストケースのうち一つがAC,もう一つがTLEであり,ACした方は実行時間が1986 msだったため,もう一度提出してみると今度は1999 msでACすることができました. atcoder.jp

新千歳空港から室蘭へは再び選手の車に乗せていただきました.

まとめ

  • ICPCアジア地区予選への参加が初めてで分からないことも多かったが,とても楽しく貴重な経験ができた.
  • 今までコーチや監督を引き受けていただいた方達に改めて感謝する.
  • コーチの分の旅費まで出し,大会を運営していただいたICPCに感謝する.
  • コーチを引き受けさせていただいた選手たちに感謝する.
  • std::unordered_mapは意外と遅いことがある.
  • ハッシュマップは意外と簡単に実装できる.
  • ハッシュマップにおいて,ハッシュ関数の設計はとても重要である.

ASP.NETお試し会

本記事はMuroran Institute of Technology Advent Calendar 2019 12/7の記事です.

adventar.org

はじめに

唐突にASP.NETお試し会を開催しました. 参加者は3名(表示名:参加者1,参加者2,参加者3)です. 内容はASP.NETを用いたジャンケンAPIの試作です. 以下では,開催に至った経緯,ASP.NETお試し会でやったこと,試作したジャンケンAPIの動作例,3名の感想を書きます.

開催に至った経緯

参加者1は普段からUnityでゲーム開発をしています. さらに,参加者1はオンラインゲームの開発に挑戦しようとしています. 参加者1は,特にサーバサイドにおける通信を扱うプログラミングについて,参加者2から教えてもらうことにしました. 参加者2は,参加者1がC#に慣れていると考え,ASP.NETを利用してサーバプログラムを開発したら良いと考えました. しかし,参加者2はサーバプログラムの開発経験はKtorを利用した簡単なWebAPIの開発だけであり,ASP.NETについては全く知らないため教えることができません. 参加者2はKtorを用いないサーバプログラム開発にも興味を持っていたため,参加者1と一緒に調べながら簡単なサーバプログラムを試作し,理解を深めることにしました. そこに参加者3が合流して,ASP.NETお試し会が開催されることとなりました.

ASP.NETお試し会でやったこと

ASP.NETお試し会では以下の作業を行いました.

  1. VisualStudioの起動とプロジェクトの作成
  2. ジャンケンAPIの試作
    1. モデルの構成
    2. コントローラーの構成

VisualStudioの起動とプロジェクトの作成

VisualStudioの起動して,「Welcome Page」から「New Project...」,「ASP.NET Core Web API」,「Next」と進み,「Project Name」と「Solution Name」を埋め,「Create」を選択してプロジェクトを作成しました. 実行し,ブラウザからhttp://localhost: 24635/api/valuesにアクセスすると["value1","value2"]と表示されました. ポート番号は人にプロジェクトによって異なるようです. 初期状態ではこのURLにアクセスするとControllers/ValuesController.cspublic IEnumerable<string> Get()メソッドが呼ばれているようです. Controllers/ValuesController.csを以下に示します.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;

namespace Test3.Controllers
{
    [Route("api/[controller]")]
    public class ValuesController : Controller
    {
        // GET api/values
        [HttpGet]
        public IEnumerable<string> Get()
        {
            return new string[] { "value1", "value2" };
        }

        // GET api/values/5
        [HttpGet("{id}")]
        public string Get(int id)
        {
            return "value";
        }

        // POST api/values
        [HttpPost]
        public void Post([FromBody]string value)
        {
        }

        // PUT api/values/5
        [HttpPut("{id}")]
        public void Put(int id, [FromBody]string value)
        {
        }

        // DELETE api/values/5
        [HttpDelete("{id}")]
        public void Delete(int id)
        {
        }
    }
}

ジャンケンAPIの試作

ASP.NETのプロジェクトの初期動作を確認した後,以下のモデルとコントローラを実装し,ジャンケンAPIを試作しました.

モデルの構成

モデルはゲームのリストとゲームを操作するコマンドで構成されます. ゲームはプレイヤ2人の名前と出した手(グー,チョキまたはパー)を保持します. ゲームを操作するコマンドは以下の通りです.

  • make:
    • 入力:プレーヤー0の名前
    • 出力:ルームID
    • 新たなゲームを作成する
  • enter :
    • 入力:入室する部屋のルームID,プレーヤー1の名前
    • 出力:なし
    • 既存の部屋に入室する
  • tryPlay :
    • 入力:ルームID
    • 出力:プレイを開始できるかどうか
    • プレイできるか確認する
  • play :
    • 入力:ルームID,プレーヤー名,グー,チョキまたはパー
    • 出力:なし
    • グー,チョキまたはパーを出す
  • tryResult:
    • 入力:ルームID
    • 出力:勝敗を判定できるかどうか
    • 勝敗を判定できるか確認する
  • result :
    • 入力:ルームID
    • 出力:勝者と敗者
    • 勝敗を確認する

以上のモデルを実装したModelクラスを以下に示します.

using System;
using System.Collections.Generic;

namespace Aspdotnet.Models
{
    public enum Move
    {
        Rock,
        Paper,
        Scissors,
        None
    }

    public class Result
    {
        String Winner;
        String Loser;
        public Result(String winner, String loser)
        {
            Winner = winner;
            Loser = loser;
        }
        public bool IsTie() { return Winner == null && Loser == null; }
        public override string ToString()
        {
            return "Winner: " + Winner + ", " + "Loser: " + Loser;
        }
    }

    class Game
    {
        public String player0;
        public Move move0 = Move.None;
        public String player1;
        public Move move1 = Move.None;

        public bool IsReady()
        {
            return player0 != null && player1 != null && move0 == Move.None && move1 == Move.None;
        }

        public bool HasFinished()
        {
            return player0 != null && player1 != null && move0 != Move.None && move1 != Move.None;
        }

        public Result GetResult()
        {
            if (!HasFinished())
            {
                return null;
            }
            if (move1 == Move.Paper && move0 == Move.Scissors ||
                move1 == Move.Scissors && move0 == Move.Rock ||
                move1 == Move.Rock && move0 == Move.Paper)
            {
                return new Result(player0, player1);
            }
            if (move0 == Move.Paper && move1 == Move.Scissors ||
                move0 == Move.Scissors && move1 == Move.Rock ||
                move0 == Move.Rock && move1 == Move.Paper)
            {
                return new Result(player1, player0);
            }
            return new Result(null, null);
        }
    }

    public class Model
    {
        List<Game> games = new List<Game>();

        static Model instance = new Model();

        public static Model GetInstance() { return instance; }

        Model() { }

        public override string ToString()
        {
            string s = "";
            for (int i = 0; i < games.Count; ++i)
            {
                var g = games[i];
                s += i.ToString() + " : ";
                s += "player0 = " + g.player0 + "; ";
                s += "player1 = " + g.player1 + "; ";
                s += "move0 = " + g.move0 + "; ";
                s += "move1 = " + g.move1 + "; ";
            }
            return s;
        }
        public int Make(String player0)
        {
            if (player0 == null)
            {
                return -1;
            }
            int roomId = games.Count;
            var game = new Game();
            game.player0 = player0;
            games.Add(game);
            return roomId;
        }

        public void Enter(int roomId, String player1)
        {
            if (player1 == null)
            {
                return;
            }
            if (roomId < 0 || roomId >= games.Count)
            {
                return;
            }
            var game = games[roomId];
            game.player1 = player1;
        }

        public bool TryPlay(int roomId)
        {
            if (roomId < 0 || roomId >= games.Count)
            {
                return false;
            }
            var game = games[roomId];
            return game.IsReady();
        }

        public void Play(int roomId, String player, Move move)
        {
            if (roomId < 0 || roomId >= games.Count || move == Move.None)
            {
                return;
            }
            var game = games[roomId];
            if (game.player0 == player && game.move0 == Move.None)
            {
                game.move0 = move;
            }
            if (game.player1 == player && game.move1 == Move.None)
            {
                game.move1 = move;
            }
        }

        public bool TryResult(int roomId)
        {
            if (roomId < 0 || roomId >= games.Count)
            {
                return false;
            }
            return games[roomId].HasFinished();
        }

        public Result Result(int roomId)
        {
            if (roomId < 0 || roomId >= games.Count)
            {
                return null;
            }
            var game = games[roomId];
            if (!game.HasFinished())
            {
                return null;
            }
            return games[roomId].GetResult();
        }
    }
}
コントローラーの構成

クライアントがHTTPのGETメソッドを用いてジャンケンAPIにアクセスして,上のコマンドを実行できるようにします. コマンドへの入力はURLに含めることにしました. これを実装したJankenControllerクラスを以下に示します.

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;

namespace Aspdotnet.Controllers
{
    [Route("api/[controller]")]
    public class JankenController : Controller
    {
        // GET api/janken/make/player0Name
        [HttpGet("make/{player0}")]
        public int GetMake(string player0)
        {
            var model = Models.Model.GetInstance();
            int roomId = model.Make(player0);
            Console.WriteLine(model);
            return roomId;
        }

        // GET api/janken/enter/roomId/player1Name
        [HttpGet("enter/{roomId}/{player1}")]
        public string GetEnter(int roomId, string player1)
        {
            var model = Models.Model.GetInstance();
            model.Enter(roomId, player1);
            Console.WriteLine(model);
            return "";
        }

        // GET api/janken/tryPlay/roomId/
        [HttpGet("tryPlay/{roomId}")]
        public bool GetTryPlay(int roomId)
        {
            var model = Models.Model.GetInstance();
            var isReady = model.TryPlay(roomId);
            Console.WriteLine(model);
            return isReady;
        }

        // GET api/janken/play/roomId/player0Name/Rock
        [HttpGet("play/{roomId}/{player}/{move}")]
        public string GetPlay(int roomId, string player, string move)
        {
            var model = Models.Model.GetInstance();
            Models.Move m = Models.Move.None;
            switch (move)
            {
                case "Rock":
                    m = Models.Move.Rock;
                    break;
                case "Paper":
                    m = Models.Move.Paper;
                    break;
                case "Scissors":
                    m = Models.Move.Scissors;
                    break;
                default:
                    return "";
            }
            model.Play(roomId, player, m);
            Console.WriteLine(model);
            return "";
        }

        // GET api/janken/tryResult/roomId/
        [HttpGet("tryResult/{roomId}")]
        public bool GetTryResult(int roomId)
        {
            var model = Models.Model.GetInstance();
            bool hasFinished = model.TryResult(roomId);
            Console.WriteLine(model);
            return hasFinished;
        }

        // GET api/janken/result/roomId/
        [HttpGet("result/{roomId}")]
        public string GetResult(int roomId)
        {
            var model = Models.Model.GetInstance();
            Models.Result result = model.Result(roomId);
            Console.WriteLine(model);
            return result.ToString();
        }
    }
}

試作したジャンケンAPIの動作例

最後に試作したジャンケンAPIの動作確認をしました. 動作例を以下に示します.

curl http://localhost:24635/api/janken/make/Sankasha1
# => 1
curl http://localhost:24635/api/janken/enter/0/Sankasha2
curl http://localhost:24635/api/janken/tryPlay/0
# => true
curl http://localhost:24635/api/janken/play/0/Sankasha1/Paper
curl http://localhost:24635/api/janken/play/0/Sankasha2/Rock
curl http://localhost:24635/api/janken/tryResult/0
# => true
curl http://localhost:24635/api/janken/result/0
# => Winner: Sankasha1, Loser: Sankasha2

3名の感想

参加者1

ASP.NETを通じて、HTTP通信を組み込んだソフトウェアをどう組み立てるか、どう実装した方が良いかを考える経験が出来ました

参加者2

見様見真似でとりあえず,動作するジャンケンAPIを作成することができた. その点では,やはりVisualStudioとASP.NETはすごいと思った. ただ,実際にVPSなどで動作させる場合にどうすれば良いか想像できなかった. 当たり前だが,今後ASP.NETを利用する場合には使い方をきちんと調べる必要がある.

ジャンケンAPIは,最初は単純なように思えたが,実際に試作してみると状態を管理するのは意外と面倒だった. また,入力が全てURLに含まれているため,不正なプレイが可能かもしれない.

ASP.NETお試し会を開催した結果,アドベントカレンダーを書くこともできため良かった.

参加者3

もう少し難しいと思ってたのですが,実装自体は予想以上に楽だったので驚きました.使い方次第でいろいろ面白いことが出来そうなので,自作ゲームで生かしてみたいです.

Jumpaku Othello

f:id:Jumpaku:20190917185325p:plain

https://othello.jumpaku.net/app/

はじめに

オセロWebサービス Jumpaku Othello を作成し,公開しました. 以下では,

  • Jumpaku Othelloとはどのようなサービスか?
  • なぜ作成したのか?
  • どのような言語,環境で開発したのか?

を述べた上で,関連するリンクを紹介します. 詳細なドキュメントについては,関連するリンクを参照してください.

Jumpaku Othelloとはどのようなサービスか?

Jumpaku Othelloの構成を以下の図に示します.

f:id:Jumpaku:20190917163829p:plain

このサービスはDocker上で動作するDockerコンテナであり,オセロWebアプリケーションとオセロWeb APIを提供します.

オセロWebアプリケーション

ブラウザでhttps://othello.jumpaku.net/app/にアクセスするとAIを相手にオセロの対戦をして遊ぶことができます. オセロのゲーム進行とAIに関する処理はオセロWeb APIを通してサーバ側で行われます.

オセロWeb API

Jumpaku Othelloは,オセロシステムとオセロAIによって構成されています. オセロシステムはオセロのゲーム進行を管理するモジュール,オセロAIは与えられた盤面に対する最善手を探索するモジュールです. それぞれのモジュールの機能の呼び出しはオセロWeb APIを通して行います. オセロWeb APIは,オセロシステムのAPIとオセロAIのAPIによって構成されるWeb APIです.

オセロシステムのAPI

オセロシステムのAPIには以下の3つがあります.

  • /v1/games/?action=make : 新規のゲームを作成する
  • /v1/games/?action=get : 既存のゲームの状態の確認する
  • /v1/games/?action=move : 既存のゲームの盤面に石を置く
オセロAIのAPI

オセロAIのAPIは次の1つだけです.

  • /v1/ai/move : 盤面情報を入力し,最善手を探索する

Dockerイメージ

以上のオセロWebサービスをサーバ上で動作させるためのDockerイメージ jumpaku-othello をDocker Hubで公開しました.

なぜ作ったのか?

Jumpaku Othelloを作成した理由は以下の2つです.

  • 強いオセロAIを作成したかったから
  • 自分のVPSで自作Webサービスを公開したかったから

強いオセロAIの作成

私は以前にもオセロAIの作成に挑戦したことがあります. その時はミニマックス法を理解できず,満足できるオセロAIを作成することができませんでした.

それから数年を経て,私の所属サークルでオセロAIコンテストが開催されることとなりました. 再びオセロAIを作成する機会を得ることとなったのです. 今回はオセロAIをミニマックス法に基づいて開発しました. 私はミニマックス法を理解できる程度に成長していました. 本当はオセロの対戦結果データを学習することも考えていたのですが,ミニマックス法に基づいて開発したオセロAIは既に私よりも強いAIとなっていたため,学習については見送る事としました.

自作Webサービスの公開

私はVPSの契約をして,NextcloudやGogs,Nexus Repository Managerといったサービスを動作させていました. しかし,自作のWebサービスを動作させたことがありませんでした. せっかくVPSの契約をしているのだから,そこで自作Webアプリを公開したい,という思いが常に心にありました. 弱くないオセロAIの開発に成功した私は,この機会に自作Webアプリの公開に挑戦する事としました.

どのような言語,環境で開発したのか?

オセロWebアプリケーション

言語はTypeScriptを用い,ビルドにはwebpackを利用しました. また,描画のためのライブラリとしてReactを利用しました.

オセロWeb API

言語はKotlinを用い,ビルドにはgradleを利用しました. また,WebフレームワークとしてKtorを利用しました.

関連するリンク

まとめ

  1. オセロシステムと弱くないオセロAIを作った
  2. WebアプリとWeb APIを提供するサービスとして自分のVPSで公開した
  3. ソースコードとDockerイメージも公開した

backup-with-nextcloud

概要

ファイル共有サービスの一つであるNextcloudと連携して,自分のサーバのファイルをバックアップするためのDockerイメージ backup-with-nextcloud を作成しました.

Nextcloud (https://nextcloud.com) は自分のサーバで動作させるファイル共有サービスです. 公式のDockerイメージ (https://hub.docker.com/_/nextcloud) もあります.

Docker (https://www.docker.com) はコンテナ型仮想環境のプラットフォームで,これによりサービスの動作環境の構築が行いやすくなります.

以下では

  1. backup-with-nextcloud とは何か?
  2. 何のために backup-with-nextcloud を作ったのか?
  3. backup-with-nextcloud はどんな仕組みで動くのか?

を説明します. また,具体的な動作例も示します.

backup-with-nextcloud とは何か?

backup-with-nextcloud はNextcloudと連携して自分のサーバのファイルをバックアップするためのDockerイメージで,次の機能を持ちます.

  • backup-with-nextcloud はあるサーバのバックアップしたいファイルを別のサーバのNextckoudと同期します.
  • backup-with-nextcloud では指定したスクリプトによってバックアップしたいファイルを生成することができます.
  • backup-with-nextcloud ではこれらの処理がcronによって繰り返し実行されます.

以上により,自分のサーバ上のファイルを別のNextcloudサーバと同期させることが可能となり,サービスのデータのバックアップすることができます.

backup-with-nextcloud のDockerイメージとDockerfileについてはそれぞれDocker Hub,GitHubで公開しています. 具体的な使い方は以下を参照してください.

何のために backup-with-nextcloud を作ったのか?

現在,私は契約しているVPSでいくつかのサービスを動作させており,今後これらを本格的に利用したいと考えています. ここで,これらのサービスを本格的に利用していくためにはデータをバックアップをすることが必須であると考えました.

私のVPSのサービスは全てDockerコンテナとして動作しています. また,サービスのデータはそれぞれのコンテナにマウントされたホストマシンのディレクトリに保存されています. これらのデータのバックアップは,

  1. バックアップデータ作成機能を持たないサービスについてはマウントされたホストマシンのディレクトリ内のファイル
  2. バックアップデータ作成機能を持つサービスについてははその機能を利用して作成したファイル

をNextcloudと同期することで実現できます. そこで,これらを実現するDockerイメージを作成しようと考えました.

backup-with-nextcloud はどんな仕組みで動くのか?

backup-with-nextcloudのDockerfileを以下に示します.

FROM debian:buster-slim

ENV DEBIAN_FRONTEND noninteractive

RUN apt-get update && apt-get install -y \
    cron \
    nextcloud-desktop-cmd \
&& rm -rf /var/lib/apt/lists/*

ENV CRON_EXP="0  *  *  *  *"
ENV CRON_USER="root"

RUN mkdir -p /backup/
RUN echo '#!/bin/bash' > /backup.sh && chmod +x /backup.sh

COPY ./sync-exclude.lst /sync-exclude.lst 
COPY ./entrypoint.sh /entrypoint.sh

CMD [ "/entrypoint.sh" ]

backup-with-nextcloudには定期的なバックアップを行うためのcronとNextcloudサーバとファイル同期を行うためのnextcloud-desktop-cmdがインストールされています. cronは,コマンドの定時実行のスケジュール管理を行うプログラムです. nextcloud-desktop-cmdはNextcloudのクライアント側でコマンドラインによる同期を行うプログラムです.

CRON_EXPはバックアップのスケジュールを設定する環境変数で,デフォルトでは1時間ごとにバックアップが実行されます. CRON_USERはバックアップを実行するユーザを設定する環境変数で,デフォルトではrootとなります. backup-with-nextcloudには他にもNextcloudサーバのURLを指定するNC_URL,Nextcloudのユーザを指定するNC_USER,Nextcloudのユーザのパスワードを指定するNC_PASSWORDという環境変数があります.

/backup/はバックアップするべきファイルを配置するためのディレクトリで,このディレクトリ内のファイルがNextcloudサーバと同期されます. 次の図に示すような構成で,サービスにマウントされたホストマシンのディレクトリを/backup/にもマウントすれば,前節の1.が実現できます.

f:id:Jumpaku:20190907044246p:plain

/backup.shはデフォルトでは何もしないシェルスクリプトですが,Nextcloudサーバとファイル同期を行う直前に実行されます. 次の図に示すような構成で,サービスのデータファイルを/backup/に複製するといったスクリプトを用意し,/backup.shとしてマウントすれば,前節の2.が実現できます.

f:id:Jumpaku:20190907201018p:plain

/entorypoint.shは以下のような/etc/crontabを作成し,cron -fを実行します.

$CRON_EXP $CRON_USER /backup.sh && nextcloudcmd --non-interactive --user $NC_USER --password $NC_PASSWORD --exclude /sync-exclude.lst /backup/ $NC_URL

動作例

Nextcloud

以下のコマンドによりnextcloudを起動します.

docker-compose -f nextcloud/docker-compose.yml up --build -d

この時のnextcloud/docker-compose.ymlを以下に示します.

version: '3'

services: 
  nextcloud:
    image: 'nextcloud:16'
    container_name: 'nextcloud'
    environment: 
      - 'SQLITE_DATABASE=nextcloud'
      - 'NEXTCLOUD_ADMIN_USER=nc_admin_user'
      - 'NEXTCLOUD_ADMIN_PASSWORD=nc_admin_password'
      - 'NEXTCLOUD_TRUSTED_DOMAINS=nextcloud'
    networks: 
      - 'nc_network'
    ports:
      - '8080:80'

networks: 
  nc_network:
    external: true

backup-with-nextcloud

以下のコマンドによりbackupserviceを起動します.

docker-compose -f service/docker-compose.yml up --build -d

この時のservice/docker-compose.ymlを以下に示します.

version: '3'

services: 
  service:
    build: './'
    volumes:
      - './data:/data/'
    networks: 
      - 'nc_network'

  backup:
    image: 'jumpaku/backup-with-nextcloud'
    volumes:
      - './data/:/backup/'
    environment: 
      - 'CRON_EXP=* * * * *'
      - 'CRON_USER=root'
      - "NC_URL=http://nextcloud/remote.php/webdav/"
      - 'NC_USER=nc_admin_user'
      - 'NC_PASSWORD=nc_admin_password'
    networks: 
      - 'nc_network'

networks: 
  nc_network:
    external: true

backupはbackup-with-nextcloudのコンテナ,serviceは毎分date >> /data/date.txtを実行するコンテナです. serviceは以下のservice/Dockerfileにより構築されます.

FROM debian:buster-slim

ENV DEBIAN_FRONTEND=noninteractive

RUN apt update -y && apt-get install -y cron
RUN echo '* * * * * root date >> /data/date.txt' > /etc/crontab

CMD ["cron", "-f"]

以下のコマンドにより,serviceで生成されたファイルがnextcloudにバックアップされていることが確認できます.

cat service/data/date.txt
# => Mon Sep 16 06:07:01 UTC 2019
docker-compose -f nextcloud/docker-compose.yml exec nextcloud cat data/nc_admin_user/files/date.txt
# => Mon Sep 16 06:07:01 UTC 2019

まとめ

Nextcloudと連携して自分のサーバのファイルをバックアップするためのDockerイメージ backup-with-nextcloud を作成しました. backup-with-nextcloud はcronとnextcloud-desktop-cmdを利用してマウントされたディレクトリ内のファイルをバックアップします. backup-with-nextcloud はDocker Hub,GitHubで公開されています.