tpc23-2

TOPIC 23|3D都市モデルを使った位置情報共有ゲームを作る[2/2]|サーバーを使ったアプリを公開する

位置情報をオンラインでやり取りしたり、サーバーで処理してサービスを提供したりするときの参考になるように、PLATEAUの3D都市モデルを活用した位置情報ゲームを作りながら、位置情報の扱い方を説明します。ここからは、サーバーを使って完成した基本のゲームをアプリとしてリリースする方法を解説します。

Share

位置情報をオンラインでやり取りしたり、サーバーで処理してサービスを提供したりするときの参考になるように、PLATEAUの3D都市モデルを活用した位置情報ゲームを作りながら、位置情報の扱い方を説明します。ここからは、サーバーを使って完成した基本のゲームをアプリとしてリリースする方法を解説します。

【目次】

23.3  サーバーに位置情報を送る

 23.3.1  サーバーの準備

 23.3.2  データベースの準備

 23.3.3  WebサーバーAPIの作成

 23.3.4  Webサーバーの公開

 23.3.5  サーバーへの位置情報の送信

 23.3.6  サーバーでの面積の計算

 23.3.7  QGISで塗った場所の表示

23.4  アプリとしてリリースする

 23.4.1  APIキーの扱いについて

 23.4.2  Google Playでアプリを配信する

 23.4.3  AppStoreでアプリを配信する

ゲームの完成図

23.3 _ サーバーに位置情報を送る

Unity側の実装はここまでにして、次に、位置情報を送る先のサーバーを作ります。

実装するのは、HTTP通信するシンプルなAPIサーバーで、JSON形式の位置情報をクライアントとやり取りするものです。サーバーで位置情報を受信したときには、それをPostGISの空間情報データとしてデータベースに格納し、空間クエリで面積を計算してクライアントに結果を返すようにします。

サーバーには、さまざまな技術がありますが、ここではフレームワークにはPythonのFlaskを使い、PostgreSQL+PostGISをバックエンドデータベースとして使います。また、動作させるインフラとして、AWSを使うこととします。

これらの構成はあくまでもこのサンプルでの説明用で、同じようなことをやる場合でもさまざまな選択肢があります。

今回はなるべく汎用的かつ基本的で、読者が自分の環境に読み替えて考えやすい作り方を目指しました。言語もフレームワークもクラウドもデータベースも多くの選択肢があるので、自分の慣れているものに読み替えてください。

なお、HTTPでやり取りするAPIは、リアルタイムでの同期には向いていません。FirebaseなどのmBaaSや、Photonなどのリアルタイム通信基盤など、まったく別の考え方の選択肢もあります。本稿では詳細を説明しませんが、作りたいサービスに合わせて選択してください。

23.3.1 _ サーバーの準備

AWSでEC2インスタンスを立ち上げます。

AWSを使うには、アカウントの登録が必要です。今回は、無料枠の範囲内でできるように構成していますが、設定のミスなどで予想外の金額が請求されることもあります。アカウントの2段階認証を設定するなど、セキュリティにも十分気を付けてください。

本稿は、ある程度AWSを使い慣れている前提で進めます。わからないことがある場合はAWSのドキュメントなどを参照してください。

なお、内容の中には、セキュリティの側面において、本番環境で動作させるには適さない部分もあります。本トピックで作成するアプリは、あくまでも解説用のサンプルです。実際のサービスなどを構築していく際には十分考慮してください。

◾️EC2インスタンスの構成

EC2インスタンスは、いわゆるクラウドの仮想マシンです。

クラウドの使い方としては旧式なものですが、なるべく基本的なところから説明してわかりやすくという前提で説明します。サーバーレスなど最近のモダンな構成を試したい方は、ぜひチャレンジしてみてください。

OSは、著者が使い慣れていることもあり、Ubuntu 22.04を使います。

インスタンスタイプは、現時点では、単価が安いt3a.nanoを使っていきますが、無料枠の範囲で試したい人は、t2.microを選択してください。

キーペアなどは、適切に設定してください。セキュリティグループは、SSHとHTTPSが通るように設定してください。

動作確認目的でHTTPを通してもいいと思いますが、スマホアプリからのアクセスでは、HTTPSが必須である場合が大半なので、基本はHTTPSを使います。ただし、本番環境ではHTTPは外にさらさないほうがいいでしょう。

【メモ】

インスタンスタイプとは、サーバーのスペックの種類のことです。キーペアとは、SSH接続に用いる鍵情報のことです。セキュリティグループは、EC2インスタンスなどに構成する、ファイアウォールの設定のことです。

図 23-41 EC2インスタンスの起動

◾️環境構築

AWSにおけるEC2インスタンスの起動とセキュリティグループの設定については、本稿では説明しません。適切なセキュリティグループを設定したEC2インスタンスができ、そこにSSHで接続できることを前提として進めます。

SSHで接続したら、環境構築を進めていきます。

最初に、PostgreSQLとその地理空間拡張のPostGISをインストールします。ついでにシステムのアップデートも一緒にしておきましょう。

$ sudo apt update
$ sudo apt upgrade
$ sudo apt install postgresql postgis

上記のコマンドを入力して、PostgreSQLがインストールできたら、まず、ログインできるようにパスワードを設定します。

$ sudo -u postgres psql -c "ALTER USER postgres WITH PASSWORD '設定したいパスワード';"

このままだと、PostgreSQLのPeer認証になっているので、postgresユーザーしかアクセスできません。この後の作業に不便なので、Peer認証を変更します。

以下のコマンドでPostgreSQLの設定ファイルを開きます(エディタはvimでもEmacsでもお好みのものを使ってください。例ではnanoを使います)。

$ sudo nano /etc/postgresql/14/main/pg_hba.conf

下記の行を変更して、保存します。

【変更前】

# Database administrative login by Unix domain socket
local   all             postgres                                peer

【変更後】

# Database administrative login by Unix domain socket
local   all             postgres                                scram-sha-256

変更を反映するため、PostgreSQLのサービスを再起動します。

$ sudo service postgresql restart

次のようにpsqlコマンドをubuntuユーザーで実行し、先ほど設定したパスワードでPostgreSQLのプロンプトに入れれば、変更が適用されています。

【メモ】

本来は、安全のため、データベースの操作権限を絞ったユーザーを別に作るのが適切ですが、ここでは説明を簡易にするため、管理者ユーザーであるpostgresユーザーを使って操作していきます。

$ psql -U postgres
Password for user postgres:
psql (14.9 (Ubuntu 14.9-0ubuntu0.22.04.1))
Type "help" for help.


postgres=#

23.3.2 _ データベースの準備

次にデータベースを作成します。データベースの名前は、placeplateauとします。PostgreSQLの詳しい使い方については、公式ドキュメントなどを参考にしてください。

postgres=# create database placeplateau;
postgres=# \c placeplateau

※上記リスト内、「¥」表記の箇所はフォントの設定によっては半角バックスラッシュとなります。なお、文字コードとしては同じですので、リストからコピーのうえ使っていただいて構いません。

次のコマンドを入力し、PostGISを有効にします。

placeplateau=# CREATE EXTENSION postgis; CREATE EXTENSION postgis_topology;

そして、アプリ用のテーブルを作ります。placedataというテーブルを作りました。

placeplateau=# create table placedata (
  id SERIAL PRIMARY KEY,
  userid VARCHAR(255) NOT NULL,
  side INTEGER NOT NULL,
  created_at TIMESTAMP NOT NULL,
  geom GEOMETRY(POLYGON,4326) NOT NULL);

各列の意味は、次のとおりです。

idプライマリキーとなる、連番ID
userid各プレイヤーを表すID
sideプレイヤーの所属する陣営を表す数値
created_atデータの作成日時
geomプレイヤーが作成した区画を表すジオメトリ(幾何形状)

23.3.3 _ WebサーバーAPIの作成

続いて、WebサーバーAPIを作っていきます。

◾️Pythonなどのインストール

まずは、Python3とその周辺環境をインストールします。uWSGI経由でnginxをWebサーバーに使います。

$ sudo apt install python3 python3-pip python3-venv nginx

◾️プロジェクトの作成

Pythonのvenvを設定し、Flaskと必要なライブラリをインストールします。作業用のディレクトリは、自分のホーム直下のplaceplateau_webとします。

$ sudo apt install libpq-dev
$ mkdir placeplateau_web
$ cd placeplateau_web/
$ pwd
/home/ubuntu/placeplateau_web
$ python3 -m venv venv
$ source venv/bin/activate
(venv) $ pip install Flask uwsgi geoalchemy2 flask_sqlalchemy psycopg2 geoalchemy2[shapely] pyjwt cryptography

◾️WebサーバーAPIのコードを書く

次のPythonプログラムを、エディタなどで作成します。app.pyという名前にします。

from flask import Flask,request
from flask_sqlalchemy import SQLAlchemy
from geoalchemy2 import Geometry
from geoalchemy2.shape import to_shape
from datetime import datetime,date,timedelta
from sqlalchemy.sql import func, and_
import json
import jwt
import time

app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://postgres:設定したパスワード@localhost/placeplateau'
db = SQLAlchemy(app)


# テーブルのスキーマ定義
class PlaceData(db.Model):
    __tablename__ = 'placedata'
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    userid = db.Column(db.String)
    side = db.Column(db.Integer)
    created_at = db.Column(db.Time)
    geom = db.Column(Geometry('POLYGON'))


    def getdict(self):
        return {"id":self.id,
                "userid":self.userid,
                "side":self.side,
                "created_at":str(self.created_at),
                "geom":to_shape(self.geom).wkt}


# 動作確認 テスト用
@app.route('/')
def hello():
    return 'PlacePLATEAU API SERVER'


# 当日のデータ取得
@app.route('/getarea')
def getArea():
    today = date.today()
    placedatas = db.session.query(PlaceData).where(PlaceData.created_at >= func.date(func.now())).order_by(PlaceData.id)
    datas = []
    for placedata in placedatas:
        print(placedata.id)
        datas.append(placedata.getdict())
    return json.dumps(datas)


# 新しい領域の作成と未読データの取得
@app.route('/makearea', methods=['POST'])
def makeArea():
    data = request.json


    lastid=data['lastid']
    userid=data['userid']
    side=data['side']
    newareaWKT=data['newarea']


    newarea = PlaceData(userid=userid, created_at=func.now(),side=side, geom=newareaWKT)
    db.session.add(newarea)
    db.session.commit()


    placedatas = db.session.query(PlaceData).filter(and_(PlaceData.created_at >= func.date(func.now()),PlaceData.id >= lastid)).order_by(PlaceData.id)
    datas = []
    for placedata in placedatas:
        print(placedata.id)
        datas.append(placedata.getdict())
    return json.dumps(datas)


if __name__ == '__main__':
    app.run(host='0.0.0.0')

リスト 23-2 app.py

◾️プログラムの動作

プログラムのうち、重要なポイントとなるPostgreSQLにデータを記録しているところを説明します。

Webフレームワークは、Flaskを使用しています。FlaskはPythonの軽量なWebフレームワークで、ここで示したサンプルプログラムのように、各URLに対する処理をメソッドとして記述することで、Webサーバーとして動作します。

このサンプルでは、ORMとして、SQLAlchemyGeoAlchemyという地理情報拡張を組み合わせて使っています。

スキーマ定義で、Geometry型を使うことで、PostGISの地理情報型を利用できます。

# テーブルのスキーマ定義
class PlaceData(db.Model):
    __tablename__ = 'placedata'
    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    userid = db.Column(db.String)
    side = db.Column(db.Integer)
    created_at = db.Column(db.Time)
    geom = db.Column(Geometry('POLYGON'))

新規の領域を作る(SQLのInsertを発行する)際は、WKT(Well Known Text)形式でジオメトリ(幾何形状)のデータを渡します。

newarea = PlaceData(userid=userid, created_at=func.now(),side=side, geom=newareaWKT)
    db.session.add(newarea)
    db.session.commit()

ここでは使っていませんが、空間検索なども実行できます。 GeoAlchemyの公式ドキュメントなどを参照してください。

◾️動作確認

最後に動作確認をします。app.pyを保存したディレクトリで、以下のコマンドを実行します。

$ python app.py

開発サーバーである旨の警告が出ますが、IPアドレスとポートが表示され動作します。

curlで動作確認をします。以下コマンドを実行すると、動作確認用のメッセージが出力されます。この際、IPアドレスとポートはapp.pyを動作させたときに表示されるものを使います。

$ curl http://127.0.0.1:5000/

また、以下のコマンドを実行すると、データベースに新しいエリアが作成され、既存のエリアのリストがJSONで返ってきます。

$ curl -X POST -H "Content-Type: application/json" -d '{"lastid":"0","userid":"12345","side":"0","newarea":"POLYGON((1 0,3 0,3 2,1 2,1 0))"}' http://127.0.0.1:5000/makearea

23.3.4 _ Webサーバーの公開

ここまでは、AWS上の仮想マシンで、外からのアクセスがされない状態でのテストでした。

次にnginxを設定して、外部に公開する設定をします。

uwsgi用の設定ファイルを作成します。app.pyと同じ場所にapp.iniというファイル名で以下のファイルを作成します。

[uwsgi]
module = app
callable = app
master = true
processes = 1
socket = /tmp/uwsgi.sock
chmod-socket = 666
vacuum = true
die-on-term = true
wsgi-file = /home/ubuntu/placeplateau_web/app.py
logto = /home/ubuntu/placeplateau_web/app.log

リスト 23-3 app.ini

nginxの設定ファイルを修正します。以下のコマンドを入力し、root権限で設定ファイルを開いて編集します。

$ sudo nano /etc/nginx/nginx.conf

httpのセクションに以下の二行があるので、sites-enabledの行をコメントアウトします。

include /etc/nginx/conf.d/*.conf;
#include /etc/nginx/sites-enabled/*;

nginxのconf.d以下にuwsgi.confファイルを作成します。

$ sudo nano /etc/nginx/conf.d/uwsgi.conf

uwsgi.confの内容は、以下のとおりとします。

server {
    listen    443 ssl;
    ssl_certificate         ※SSL証明書へのパス;
    ssl_certificate_key     ※SSL秘密鍵へのパス;
    server_name ※サーバーのドメイン名;
    location / {
        include uwsgi_params;
        uwsgi_pass unix:///tmp/uwsgi.sock;
    }
}

リスト 23-4 uwsgi.conf

ここでは詳細を割愛しますが、SSL証明書を作っていることを前提とし、そのパスをssl_certificateやssl_certificate_keyに設定します。

ドメイン名の取得は、有料になることがほとんどです。SSL証明書は、テスト用と割り切るならLet’s Encryptなどを使って取得してもよいでしょう。

以上の設定をしたら、nginxを再起動します。

$ sudo service nginx restart

以下のコマンドを入力してuwsgiを起動して、外部からアクセスして確認します。

$ uwsgi --ini app.ini

ブラウザにURLを入力すると、アクセスを確認できます。

図 23-42 ブラウザでアクセスして確認する
コラム:ドメイン取得とSSL

Unityでは、デフォルトではAndroidやiOSでビルドした場合、httpでのアクセスを制限しています。これは、プラットフォームの制限です。

そのため、すでにドメインやSSLの証明書を保有している前提で話を進めています。

実際のサービスを運用するためには、SSLを使ったアクセスや、正式にドメインを取得してのさまざまな設定が必要ですが、本トピックの内容を超えるので、本稿では説明しません。

また、サーバーでのサービスの自動起動にも触れません。

23.3.5 _ サーバーへの位置情報の送信

ここまでで、サーバーのAPIができました。

次にUnityのクライアントプログラムと連携させます。

まずはUnityクライアント側のプログラムを作成します。

Paint in 3Dでは、IHitPointを継承したクラスを作成し、HandleHitPointを実装することで、塗った場所の座標を取得できます。

これを使って、サーバーへ位置情報を送信する機能を作成したのが次のプログラムです。これをP3DHitScreenと同じGameObjectに追加します。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;
using PaintIn3D;
using Google.XR.ARCoreExtensions;
using System.Text;
using Newtonsoft.Json;

public class GeoPaintManager : MonoBehaviour, IHitPoint
{
    private List<GameObject> polygons = new List<GameObject>();
    private int lastid = 0;
    private int side = 0;
    private string userid = "test";


    private List<GeoPoint> tempPolygon = new List<GeoPoint>();
    private Vector3 firstPoint;
    private bool isFirst = true;


    private bool isInitialized = false;


    public AREarthManager arEarthManager;
    public Transform arOrigin;
    public Transform arcam;


    public GameObject polygonLineRender;
    public GameObject areaParent;


    private const float DISTTHR = 10;
    private const float POLYGON_HEIGHT = 40;


    private const string url = "設置したサーバーアドレス";


    public void HandleHitPoint(bool preview, int priority, float pressure, int seed, Vector3 position, Quaternion rotation)
    {
        if (!preview)
        {
            Debug.Log("HitDetect Pos " + position + "Rot " + rotation);


            if (arEarthManager.EarthTrackingState == UnityEngine.XR.ARSubsystems.TrackingState.Tracking)
            {
                Pose p = new Pose(position, rotation);
                GeospatialPose geoPose = arEarthManager.Convert(p);
                Debug.Log(geoPose);


                if (isFirst)
                {
                    firstPoint = position;
                    tempPolygon.Add(new GeoPoint(geoPose.Latitude, geoPose.Longitude));
                    isFirst = false;
                }
                else if (tempPolygon.Count > 2 && Vector3.Distance(firstPoint, position) < DISTTHR)
                {
                    // Loop Close
                    StringBuilder sb = new StringBuilder();
                    foreach (GeoPoint gp in tempPolygon)
                    {
                        sb.Append($"{gp.lon} {gp.lat},");
                    }
                    GeoPoint fpgp = tempPolygon[0];
                    sb.Append($"{fpgp.lon} {fpgp.lat}");
                    string wkt = sb.ToString();


                    string reqjson = $"{{\"lastid\":\"{lastid}\",\"userid\":\"{userid}\",\"side\":\"{side}\",\"newarea\":\"POLYGON(({wkt}))\"}}";


                    StartCoroutine(SendNewArea(reqjson));


                    tempPolygon.Clear();
                    isFirst = true;
                }
                else
                {
                    tempPolygon.Add(new GeoPoint(geoPose.Latitude, geoPose.Longitude));
                }
            }
        }
    }


    IEnumerator SendNewArea(string json)
    {
        UnityWebRequest req = new UnityWebRequest(url + "makearea", "POST");
        byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(json);
        req.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw);
        req.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
        req.SetRequestHeader("Content-Type", "application/json");


        yield return req.SendWebRequest();


        if (req.result == UnityWebRequest.Result.Success)
        {
            Debug.Log(req.downloadHandler.text);
            ParseRecord(req.downloadHandler.text);
        }
        else
        {
            Debug.LogError("Error sending POST request: " + req.error);
        }
    }


    IEnumerator GetArea()
    {
        UnityWebRequest req = new UnityWebRequest(url + "getarea", "GET");
        req.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();


        yield return req.SendWebRequest();


        if (req.result == UnityWebRequest.Result.Success)
        {
            Debug.Log(req.downloadHandler.text);
            ParseRecord(req.downloadHandler.text);
        }
        else
        {
            Debug.LogError("Error sending POST request: " + req.error);
        }
    }


    void ParseRecord(string json)
    {
        List<Record> records = JsonConvert.DeserializeObject<List<Record>>(json);
        foreach (Record record in records)
        {
            var points = new List<Vector3>();
            string geom = record.geom.Replace("POLYGON ((", "").Replace("))", "");


            foreach (string coord in geom.Split(","))
            {
                var tokens = coord.Trim().Split(" ");
                double x = double.Parse(tokens[0].Trim());
                double y = double.Parse(tokens[1].Trim());
                GeospatialPose geoPose = new GeospatialPose();
                geoPose.Latitude = y;
                geoPose.Longitude = x;
                geoPose.Altitude = POLYGON_HEIGHT;
                geoPose.Heading = 0;
                geoPose.EunRotation = Quaternion.identity;
                Pose pose = arEarthManager.Convert(geoPose);
                points.Add(pose.position);
            }
            GameObject polygon = Instantiate(polygonLineRender, areaParent.transform);
            var linerenderer = polygon.GetComponent<LineRenderer>();
            linerenderer.SetPositions(points.ToArray());
            linerenderer.positionCount = points.Count;


            if(record.id > lastid)
            {
                lastid = record.id;
            }
        }
    }


    // Update is called once per frame
    void Update()
    {
        if(!isInitialized && arEarthManager.EarthTrackingState == UnityEngine.XR.ARSubsystems.TrackingState.Tracking)
        {
            isInitialized = true;
            StartCoroutine(GetArea());
        }
    }
}


public struct GeoPoint
{
    public double lat, lon;


    public GeoPoint(double lat, double lon)
    {
        this.lat = lat;
        this.lon = lon;
    }
}

public class GeoPolygon
{
    public List<GeoPoint> polygon = new List<GeoPoint>();
    public int side;
}

[JsonObject(MemberSerialization.OptIn)]
public class Record
{
    [JsonProperty]
    public int id { get; set; }
    [JsonProperty]
    public string userid { get; set; }
    [JsonProperty]
    public int side { get; set; }
    [JsonProperty]
    public string created_at { get; set; }
    [JsonProperty]
    public string geom { get; set; }
}

リスト 23-5 GeoPaintManager.cs

※上記リスト内、「¥」表記の箇所はフォントの設定によっては半角バックスラッシュとなります。なお、文字コードとしては同じですので、リストからコピーのうえ使っていただいて構いません。

AREarthManagerのConvertメソッドを使うと、Unity座標と地理座標とを相互変換できます。

APIとのやり取りはUnityWebRequestを使い、JSON形式で送受信しています。JSON.Netを使っているので、別途Unity Package Managerでインストールしておいてください。

APIへのデータ送信のタイミングは、始点から閾値距離内の点を塗ったときに、閉じたと判定して行っています。

陣営の選択やUserIDの入力などは別途シーンなどを作り、変更できるようにします。

23.3.6 _ サーバーでの面積の計算

ここまでで、Unityアプリとサーバーで情報を連携させることができました。

アプリから作成した地理情報はPostGISに記録されるので、PostGISの機能を使った処理が可能です。ここでは、簡単なクエリを実行して、面積の計算をしてみます。

サーバーにSSHでログインして、psqlコマンドを実行し、PostgreSQLのコンソールに入ります。\cを使って操作対象のデータベースを切り替えます。

$ psql -U postgres
...
postgres=#
postgres=# \c placeplateau

※上記リスト内、「¥」表記の箇所はフォントの設定によっては半角バックスラッシュとなります。なお、文字コードとしては同じですので、リストからコピーのうえ使っていただいて構いません。

次のクエリを実行します。各エリアの面積(㎡)が計算されます。

SELECT *, ST_Area(Geography(ST_Transform(geom,4326))) FROM placedata;

ここで、ST_Transformに指定している4326という数字は、EPSG:4326の座標参照系つまりWGS84のことを指します。座標系を設定することで正しい面積計算ができます。

SQLの機能をもっと使って集計してみましょう。次のクエリを実行し、陣営(side)単位で面積を集計します。

placeplateau=# SELECT side, sum(ST_Area(Geography(ST_Transform(geom,4326)))) FROM placedata GROUP BY side;
 side |        sum
------+--------------------
    0 | 1360.2750182356685
    1 |  1773.828494577203
(2 rows)

さらに、陣営(side)・ユーザー(userid)単位で面積を集計してみましょう。

placeplateau=# SELECT side,userid, sum(ST_Area(Geography(ST_Transform(geom,4326)))) FROM placedata GROUP BY side,userid;
 side | userid |         sum
------+--------+----------------------
    0 | test   |   1320.7396999783814
    0 | test3  |   39.535318257287145
    1 | test2  |   1773.8014401495457
    1 | test4  | 0.027054427657276392
(4 rows)

このように、アプリで作成したデータをSQLで自由自在に分析することができます。データベースを適切に設計すれば、さらにさまざまな活用ができます。

23.3.7 _ QGISで塗った場所の表示

QGISには、PostGISに接続する機能があります。サーバーのPostGISに接続して、地図上に表示してみます。

◾️PostgreSQLとトンネリング接続する

サーバー上で動作しているPostgreSQLには、作業中のPCから直接つなぐことができません。

そこで、SSHのトンネリングを利用して接続します。

次のコマンドを実行すると、-Lオプションで、手元のPCの63333ポートがサーバーの5432ポートとトンネリングされ、手元PCの63333ポートにアクセスすることで、サーバー側のPostgreSQLの5432ポートとやり取りできるようになります。

【メモ】

ここではコマンドで例示しますが、ほとんどのSSHクライアントアプリで、同様の設定をできます。

$ ssh -i ~/.ssh/SSH鍵ファイル -L 63333:localhost:5432 ubuntu@サーバーアドレス

◾️QGISで可視化する

SSHで接続したらQGISを起動し、新規プロジェクトを作成します。

次のように操作することで、PostGISに接続して、その記録されたデータを可視化できます。

【手順】PostGISに接続して可視化する

[1]OpenStreetMapを追加する

XYZレイヤを追加し、OpenStreetMapなどのベースマップを追加します。

図 23-43 XYZレイヤを追加する
図 23-44 OpenStreetMapを追加する

[2]PostGISに接続する

[レイヤ]メニューから[レイヤを追加]―[PostGISレイヤを追加]を選択します。

図 23-45 PostGISレイヤを追加する

接続画面が表示されたら、先ほどSSHで設定したポート番号などを設定します。

図 23-46 接続設定を入力する

[OK]をクリックすると、認証情報を入力するダイアログが開くので、ユーザー名に「postgres」、パスワードに設定したパスワードを、それぞれ入力して、[OK]をクリックします。

図 23-47 ユーザー名とパスワードを入力する

[接続]をクリックすると、接続できるテーブルが表示されるので、選択して[追加]をクリックします。

図 23-48 テーブルを選択する

[3]PostGISの地理情報が表示された

以上の操作で、地図上に、PostGISの地理情報が表示されました。

このデータはQGIS上で通常のベクターデータと同様に扱うことができ、編集や分析が可能です。

図 23-49 地理情報が表示された
コラム:チート対策

地理情報を扱うアプリ、とくにゲームなどでは、チート対策が重要です。

しかし、スマホのアプリではGPS座標を詐称するようなツールもあり、地理情報のチート対策は簡単ではありません。

例えば、現実にはあり得ないスピードで移動した場合を判定することや、システム側に現実的に侵入不可能な場所の地図を持っておいて、異常な場所にいないか判定するなどの方法がありますが、確実にチートを判定することは困難です。

そのため、継続的にデータを取得しておき、統計的に怪しそうな挙動をフィルタリングするなどの後手の対策が主となります。また、取得できる報酬の上限を決めておくなどの対策も必要です。

コラム:プライバシーについて

チート対策とも関わりますが、個人に紐づく地理情報は個人情報となります。他人に見られないように管理すること、匿名化して統計処理することなど、取り扱いに注意すること、プライバシーポリシーの提示や取得する情報の利用目的などの明確化など、一般的な個人情報と同様な取り扱いに気を付ける必要があります。

23.4 _ アプリとしてリリースす

アプリ、とくにiOSやAndroidにインストールするようなモバイル向けアプリとしてリリースするには、多くのハードルがあります。

もちろん、きちんとしたアプリとして使えるようにデザインやUXを作りこむことも大事ですし、 サーバーと連携するものでは、インフラの準備なども必要です。

ここでは、一般的な流れの簡単な説明とポイントの説明、そしてGeospatial APIのAPIキーの扱いについて説明します。

各プラットフォームの規約への適合など一般的な詳細については、各プラットフォームの公式ドキュメントの最新版などを参照してください。

また、ここで説明する手順は執筆時の各プラットフォームの仕様に基づいています。これらの手順が更新されている場合は、各プラットフォームの最新のドキュメントに従ってください。

23.4.1 _ APIキーの扱いについて

外部のAPIを使う場合に、APIキーをどう扱うかが課題となります。

アプリ内のプログラムに埋め込んで使うこともできますが、アプリの実行バイナリはダウンロードされたユーザーの端末で実行されるものなので、解析されてAPIキーが不正に取得されてしまう可能性があります。実行ファイルの難読化などの手法はありますが、100%守れる保証はありません。

仮にAPIキーが流出した場合、不正にAPIアクセスされ、情報流出や不正な利用による課金などが起こる可能性があります。

対策としては、流出したAPIキーを無効にするなどの方法もありますが、アプリに組み込んでいると、すべて一律のAPIキーを使うことになるので、一般の利用者も使えなくなり、アプリに新しいAPIキーを含めて配信するタイミングや、ユーザーがアップデートするタイミングなどを調整しなければなりません。

まず大前提として、APIキーの権限範囲を絞っておく(Geospatial APIの場合はARCore APIの範囲に限定するのがよい)というのが重要ですが、各プラットフォームでそれぞれAPIキーを秘匿して管理するための方法が用意されているので、それぞれ解説します。

23.4.2 _ Google Playでアプリを配信する

Android端末用のGoogle Playへの配信について説明します。

◾️Keyless認証の設定

Google提供のサービスのAPIキーを管理する場面において、AndroidではKeyless認証を使えます。

公式ドキュメントの手順に沿って設定します。

【手順】Keyless認証を用いる

[1]Keylessを設定する

最初に、[Project Settings]の[XR Plug-in Management/ARCore Extensions]の設定項目において、[Android Authentication Strategy]を[Keyless(recommended)]に設定します。

図 23-50 Keylessに設定する

[2]キーストアを作成する

次に、[Project Settings]の[Player/Android/Publishing Settings]の[Keystore Manager]ボタンをクリックし、Keystore Managerを開きます。

図 23-51 Keystore Managerを開く

[Create New/Anywhere]を選択して、プロジェクト内にキーストアを作成します。このキーストアには秘密鍵が記録されるので、うっかりリポジトリに入れてGitHubで全世界に公開しないように管理してください。

図 23-52 キーストアを作成する

[3]キーを作りフィンガープリント文字列を取得する

パスワードやエイリアスなどを適切に設定して[Add Key]をクリックして、キーを作成します。

そして、作成したキーのフィンガープリントを表示します。コマンドラインでkeytoolが動作することを確認して次のコマンドを実行してください。

表示されたフィンガープリントは、このあと必要になるので、コピペしておいてください。

【メモ】

keytoolは、JDKに含まれています。Unityのインストール時にJDKが入っていなければ、別途JDKをインストールすることでも使えるようになります。

$ keytool -list -v -keystore キーストアファイルのパス
キーストアのパスワードを入力してください:
キーストアのタイプ: PKCS12
キーストア・プロバイダ: SUN


キーストアには1エントリが含まれます


別名: forgeospatialapi
作成日: 2023/09/18
エントリ・タイプ: PrivateKeyEntry
証明書チェーンの長さ: 1
証明書[1]:
所有者: O=Oho
発行者: O=Oho
シリアル番号: 23bfbe40
有効期間の開始日: Mon Sep 18 16:37:06 GMT+09:00 2023終了日: Tue Sep 05 16:37:06 GMT+09:00 2073
証明書のフィンガープリント:
         SHA1: 3E:C8:E4:F9:C6:29:EE:65:6E:B5:BE:B6:9C:4C:35:8F:19:78:ED:AD ※←この文字列がフィンガープリント
         SHA256: 1F:B4:9B:D1:B6:67:B4:00:AB:EE:95:5D:E8:7D:23:5C:9C:3E:D0:76:07:38:87:82:67:A3:AA:A3:40:90:6E:25
署名アルゴリズム名: SHA1withRSA (弱)
サブジェクト公開キー・アルゴリズム: 2048ビットRSAキー
バージョン: 3


*******************************************
*******************************************


Warning:
<forgeospatialapi>はSHA1withRSA署名アルゴリズムを使用しており、これはセキュリティ・リスクとみなされます。このアルゴリズムは将来の更新で無効化されます。

[4]Keyless認証を設定する

フィンガープリントの文字列をコピーしたら、Google Cloud Consoleを開きます。適切なプロジェクトを選択していることを確認して、[APIとサービス/認証情報]のページを開きます。[認証情報の作成]から[OAuthクライアントID]を選びます。

図 23-53 OAuthクライアントIDを選択する

コピーしたフィンガープリントを貼り付け、パッケージ名などを設定し、[作成]ボタンをクリックします。

【メモ】

OAuth同意画面の設定などが表示された場合は、必須項目を適切に設定してください。

図 23-54 OAuthクライアントIDを設定する

これで、ビルドするとKeyless認証でGeospatial APIの認証が成功するようになります。

キーストアのパスワードは、Unityのプロジェクトを再度開くときに消えている場合があるので、うまくいかないときは入力されている状態か確認してください。

◾️Google Playでの配信

Google Play Consoleにアクセスし、[Play Consoleに移動]からログインします。

このとき、はじめての場合はユーザー登録から始まります。個人でアプリを公開する場合は、個人ユーザーの選択肢を選ぶとよいでしょう。

連絡先の情報などを入力します。登録の中で$25の登録料の支払いが必要になるので注意してください。また、実際にストアにアプリを配信する場合、本人確認の手続きが必要です。

ユーザー登録ができたら、アプリの情報を登録していきます。

【手順】Google Playで配信する

[1]アプリを作成する

[すべてのアプリ]ページで、[アプリを作成]ボタンをクリックします。

図 23-55 アプリを作成

アプリの名前などの情報を登録し、[アプリを作成]をクリックします。

図 23-56 アプリ情報の登録

[2]内部テスト版の作成

まずは内部テスト版を作成します。[テスト/内部テスト]から[新しいリリースを作成]をクリックします。

図 23-57 内部テストのリリース版を作成

[3]ビルド

Unityのビルド設定で、[Build App Bundle (Google Play)]にチェックを付け、リリース設定でビルドします。

図 23-58 Unityのビルド設定

[4]アップロード

できた.aabファイルをアップロードします。また、リリース名などの情報も入力し、[次へ]をクリックします。

図 23-59 aabファイルのアップロード

エラーや警告が表示されたときは、アップロードしたアプリや開発者ユーザーのアカウントの状況で異なるので、適宜メッセージを確認しながら解決します。

図 23-60 エラーや警告が発生したとき

同様の手順を製品版リリースで行い、Googleの審査に合格すると、製品版としてGoogle Playストアで公開できます。

Google Play Consoleでは、アプリのリリースだけでなく、広告や課金の設定や、インストール数の確認、レビューの確認などのさまざまな機能があります。使いこなしてアプリをよりよいものに改善してください。

23.4.3 _ AppStoreでアプリを配信する

iOS端末用のAppStoreへの配信を説明します。

◾️JWTでのトークン認証

iOS端末では、トークン認証を使います。これは、ユーザーごとの短期間の認証トークンをサーバー側で生成することで、アプリ内にAPIキーを内包しなくてよい仕組みです。

サービス側のサーバーに認証用の秘密鍵を置き、APIなどで、サーバー側の処理で秘密鍵から有効期限が短い認証トークンを発行。それをクライアント側で受け取って、Google APIの認証に使います。

こうすることで、サーバー側の認証により、正しいユーザーのみGoogleのAPIを適正に使うことができます。また、短有効期間のトークンなので、不正に入手されても利用には大きな制限がかかります。

一方で、サーバーの用意が必要なことなど、APIキーによる認証より開発・運用に手間がかかります。公式ドキュメントを参考にしてください。

次の手順で操作します。

【手順】認証トークンを用いる

[1]サービスアカウントを作成する

最初に、Google Cloud Consoleで、サービスアカウントを作成します。[APIとサービス/認証情報]ページから[認証情報を作成]の[サービスアカウント]を選択します。

サービスアカウント名など、必要な情報を入力して進めます。[ロール]は、[サービス アカウント トークン作成者]とします。

図 23-61 サービスアカウントを作成する

[2]キーを作成する

認証情報ページに戻ると、リストに作成されたサービスアカウントが表示されているので、クリックして表示されたページで、[キー]を選択します。

ここで、[鍵を追加/新しい鍵を作成]で[JSON]を選び、作成します。自動的にJSONファイルがダウンロードされるので、秘密鍵として権限のない人がアクセスできないように保管します。

図 23-62 キーの作成

[3]サーバー側のプログラムの作成

次に、サーバー側のプログラムを作成します。前節で作成したPythonのプログラムに、トークンを生成する機能を追加します。次のように、importの追加、初期化コードとメソッドの追加をします。

from datetime import datetime,date,timedelta
import jwt

.....

with open ('testgeospatialapi-165ee1b61a8f.json','r') as f:
    service_account_info = json.load(f)

private_key = service_account_info['private_key']
client_email = service_account_info['client_email']

.....

# GeospatialAPIの認証トークンの作成(※実際にサービスに使用する際は認証の仕組みが必要)
@app.route('/token', methods=['GET'])
def token():
    data = request.args.get('key','')
    if data != 'password':
        return 'error'

    current_time = datetime.now()
    expiration_time = current_time + timedelta(hours=1)

    payload = {
        'iss': client_email,
        'sub': client_email,
        'iat': int(current_time.timestamp()),
        'exp': int(expiration_time.timestamp()),
        'aud': 'https://arcore.googleapis.com/',
    }


    token = jwt.encode(payload, private_key, algorithm='RS256')
    return token

リスト 23-6 トークンを生成する機能を追加する

ここでは認証は、keyが文字列passwordと合致するかを確認するだけの形だけのものですが、実際のサービスでは「必ず」ユーザー単位の、きちんとした認証をして、トークンの発行処理をしてください。

この状態でサーバーにアクセスすると、認証用トークンの文字列が返ってきます。

図 23-63 得られた認証用トークン

[4]クライアント(Unity側)のプログラムの作成

これをUnity側で取得して、API認証をします。GeoPaintManager.csに、次のメソッドを追加します。

トークン認証はiOSのときだけなので、#ifでiOSのときだけ有効になるようにしています。

#if UNITY_IOS
    public static IEnumerator GetToken(ARAnchorManager arAnchorManager)
    {
        UnityWebRequest req = new UnityWebRequest(url + "token?key=password", "GET");
        req.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();


        yield return req.SendWebRequest();


        if (req.result == UnityWebRequest.Result.Success)
        {
            var token = req.downloadHandler.text.Trim();
            Debug.Log(token);
            ARAnchorManagerExtensions.SetAuthToken(arAnchorManager,token);
        }
        else
        {
            Debug.LogError("Error sending GET request: " + req.error);
        }
    }
#endif

また、「Assets/Samples/ARCore Extensions/1.38.0/Geospatial Samples/Scripts/」にある、GeospatialController.csを修正します。

下記のように、AvailabilityCheckメソッド内にトークンを取得し認証するコードを追加します。

ここが連携していないと、GeospatialController.csのほうで認証が通っていないとされてしまうため、このように認証が通った後にGeospatial APIの有効性チェックが動作するように流れを修正します。

            if (Input.location.status != LocationServiceStatus.Running)
            {
                Debug.LogWarning(
                    "Location services aren't running. VPS availability check is not available.");
                yield break;
            }


            // Update event is executed before coroutines so it checks the latest error states.
            if (_isReturning)
            {
                yield break;
            }


# ---------------ここから
#if UNITY_IOS
            yield return GeoPaintManager.GetToken(AnchorManager);
#endif
# ---------------ここまで追加


            var location = Input.location.lastData;
            var vpsAvailabilityPromise =
                AREarthManager.CheckVpsAvailabilityAsync(location.latitude, location.longitude);
            yield return vpsAvailabilityPromise;


            Debug.LogFormat("VPS Availability at ({0}, {1}): {2}",
                location.latitude, location.longitude, vpsAvailabilityPromise.Result);
            VPSCheckCanvas.SetActive(vpsAvailabilityPromise.Result != VpsAvailability.Available);

[5]iOSのトークン認証を有効にする

最後に、ARCore Extensionsの設定で、iOSのトークン認証を有効にします。

図 23-64 トークン認証を有効にする

[6]ビルドして確認する

以上でビルドして動作することを確認しましょう。

なお、Google Cloud Consoleでは、[IAMと管理/サービスアカウント]で個々のサービスアカウントを選択し、指標タブを見ると、APIが使用されているかを確認できます。ぜひ活用してみてください。

図 23-65 さまざまな指標の確認

◾️App Storeでの配信

App Storeで配信するためには、Apple Developer Programへの登録が必要です。個人としての登録で、年間登録料として1万円ほどかかります。ここではApple Developer Programへの登録が完了している前提でアプリの登録作業を見ていきます。

まず、Appleの開発者サイトで必要なファイルを作成します。

図 23-66 Apple開発者サイト

必要なものは、証明書、アプリID、プロファイルの3つです。開発用にはDeveloper、配布用にはDistributeのものが必要になります。公式のドキュメントに詳しい方法が載っているので、そちらを参照してください。

同様に、App Store Connectでも設定を進めます。

【手順】Apple Storeで配信する

[1]アプリを作成する

[アプリ]ページでアプリを新規作成します。App Store Connectについても公式ドキュメントを参照してください。

図 23-67 App Store Connect

[2]ビルドとアップロード

AppStoreはGoogle Playと違い、Xcodeから作成したアプリのパッケージファイルを直接アップロードします。

UnityでRelease設定でのiOS向けのビルドをします。

図 23-68 UnityのiOSビルド設定

Unityのビルドが成功したら、Xcodeでプロジェクトを開きます。

このとき、事前にDeveloperサイトやApp Store Connectで設定したバンドルID、ダウンロードしたProvisioning Profileが設定され、証明書がインストールされて自分のチームが設定されていることを確認します。

ひととおり設定を確認したら、一度正常にビルドできるか確かめてみます。問題ないようなら、Xcodeのメニューから[Product/Archive]を選び、App Store Connectにアップロードするためのビルドを作ります。

パッケージ作成が終わるとArchivesのウィンドウが開くので、[Distribute App]ボタンをクリックして、画面の指示に従って、ビルドをApp Store Connectにアップロードします。

図 23-69 Archiveでパッケージができたところ

[3]テストと審査提出

しばらくすると、App Store Connectにアップロードしたビルドが表示されます。

最初はTestFlightにアップロードしたビルドが表示されます。この状態であらかじめ招待した少数のテスターで行う内部テストを実施することができます。また、審査に提出することで、不特定多数に向けた外部テストを行うこともできます。

図 23-70 TestFlight画面

ここから、App Storeページで、スクリーンショットやアプリの説明文などのアプリ情報や各種設定を行うことで、審査に提出できるようになります。

最後にAppleの審査が承認されたらApp Storeにリリースします。

機能だけでなく、App Store Reviewガイドラインや、関連するAppleのガイドラインに沿うようにアプリにすることも重要です。公式のドキュメントをよく読み、進めてください。

【文】

於保俊(株式会社ホロラボ)

【協力】

大澤文孝