ゼロから始めるRails位置ゲーサーバ(その1)

こんにちは。クライアントチームサーバサイドエンジニア、コウです。最近、 Rails で位置ゲーのサーバサイドを実装しました。
なので、 Rails で位置ゲーサーバの実装方法について、3つの Part に分けて紹介させていただきます。

ポーリング API で、スマホのリアルタイム位置情報にもとづいて、何キロ範囲内の全てのマーカーデータをデータベースからとるには、いくつの実装方法があります。

Geohash

最初におすすめされたのは Geohash です。
Geohash は経緯度に基づくジオコーディング方法の一つです。
Geohash の gem を使えば、データベースは MySQL のままでも、実装できます。
でも、 Geohash の gem が大分古かったので、使えるかどうか試しませんでした。
なぜなら、もっと簡単な方法があるからです!

Geohashに興味のある方は、杉山さんの記事「サーバーで付近の情報を通知するサービスのつくり方」がおすすめです。

ElasticSearch

ElasticSearch はオープンソース全文検索エンジンです。
ElasticSearch はリアルタイム位置情報検索が得意だそうです。
あまり詳しくないので、今回はスルーさせてください。
なので、ElasticSearch Geolocation のドキュメント URL だけを貼りますね。

RedisとMongoDB

Redis 3.2 から Geo メソッドがいくつか追加されました。 Sorted Set で Geo 検索できます。2016年にリリースされたばかりなので、使った例がおおくないです。
リアルタイム位置情報のアップデートとセレクトが多い場合、MongoDB の Geospatial がよく使われています。スピードがかなり速いです。
でも今回のマーカーデータは全部事前に登録できる上に、リレーションデータもあります。
MongoDB を使うメリットはそんなに大きくないです。

MySQL Spatial Data

そういうわけで、もう既に MySQL をインストールしたので、 MySQL のSpatial Data についても調べました。
最初は、 MySQL の Spatial Data がいいと思いました。
インデックスも付けられるし、点と点の間の距離計算できます。
だけど!
「ユーザーから N キロメートル範囲内の全てのマーカーを取り出す」という簡単なことができるけど!
SELECT文が(私にとって)かなりわかりづらいです。

SELECT
    *, (
      6371 * acos (
      cos ( radians(78.3232) )
      * cos( radians( lat ) )
      * cos( radians( lng ) - radians(65.3234) )
      + sin ( radians(78.3232) )
      * sin( radians( lat ) )
    )
) AS distance
FROM markers
HAVING distance < 10
ORDER BY distance;

ref: stackoverflow (英語, published at 2014/02/18)

SET @user = ST_GeomFromText('POINT(139.777254 35.713768)');
SELECT *, ST_Distance_Sphere(@user, lonlat) AS distance
    FROM markers
    WHERE ST_Distance_Sphere(@user, lonlat) <= 10000
    AND ST_Within(
      lonlat,
      ST_Buffer(@user, DEGREES(300/(6370986*COS(RADIANS(ST_Y(@user))))),
      ST_Buffer_Strategy('point_square'))
    )
    ORDER BY distance;

ref: Shogo’s Blog (日本語, published at 2017/03/28)

sin cos acos radians が多くないでしょうか?
6371 は何の数字でしょうか?
ST_Distance_Sphere は遅い、Geometry 型しか使えないことも記事に書いてあります。
そんなこと思いつつ、別の解決方法を探り始めました。

PostgreSQL の PostGIS

「世界で最も人気なオープンソースデータベース」の MySQL から離れて、 ようやく、「世界で最も先進的なオープンソースのデータベース」の PostgreSQL までたどり着きました。 簡単に PostgreSQL + PostGIS のメリットを紹介しましょう。

  • リレーションデータベース
  • ドキュメント ( HTML / PDF / EPUB ) が完璧
  • PostGIS の使いやすさ (Geography 対応)
  • RDS の PostgreSQL に PostGIS のイクステンションがあらかじめインストールされている
  • ActiveRecord 専用 gem activerecord-postgis-adapter あり、その上、ずっとメンテナンスされている
  • 何よりセレクトが速い!!!

ベンチマークの数字からPostGISの性能のよさを証明しましょう。
下記は「railsでユーザーの緯度経度から10キロ以内のレコードを取り出して、距離でオーダーする」のセレクト文のベンチマークです:

User
  .select("users.*, st_distance(location, 'point(116.458104 39.966293)') as distance")
  .where("st_dwithin(location, 'point(116.458104 39.966293)', 10000)")
  .order("distance")
レコード件数 10w 20w 50w 100w 260w 1000w
かかった秒数 1.2ms 1.4ms 1.8ms 2.5ms 4.2ms 15.3ms

ref: Ruby China #22059 (中国語, published at 2014/10/15)

もう一度いいます!
何よりセレクトが速いです!

結論

なので、今回の仕様に一番ぴったりな解決方法は、 PostgreSQL の PostGIS で実装することでした。
めでたし〜めでたし〜

次回の Part 2 で、 上記にも出てた謎な英単語 「 Geography 」、 と「 Geometry 」について、簡単に話します。
お楽しみにしてください〜(<ゝω・)☆


(/・ω・)/ ★☆★☆★ サーバサイドエンジニア大絶賛募集中 ★☆★☆★ \(・ω・\)

【お詫びと訂正】iOS11でついにSafariからカメラにアクセスできるようになりました。(コピペで動くサンプルコード付き)

お詫びと訂正:(2017/06/08 23:32)
本記事内に掲載しているスクリーンショットが、
Apple.Incの開発者利用規約(APPLE BETA SOFTWARE PROGRAM AGREEMENT APPLE INC.)に抵触しているというご指摘をいただきました。
Apple.Incの開発者利用規約から、掲載内容を不適切と判断し、該当箇所を削除させていただきました。

Apple.Inc及び読者の皆様に深くお詫び申し上げます。

追記:(2019/02/17)
Navigator.getUserMediaが非推奨となったため、MediaDevices.getUserMediaを使うように修正しました。

Navigator.getUserMedia - Web API | MDN
MediaDevices.getUserMedia() - Web API | MDN

ざっくり1行でまとめると

  • iOS11のSafariがカメラにアクセスできるようになったから試しました。^ ^

f:id:kimizuka:20170608231415p:plain

はじめに

こんにちは。フロントエンジニアの @ki_230 です。
WWDC、盛り上がりましたね。寝不足の方も多いのではないでしょうか。

iMac Pro、iPad Pro 10.5インチモデル、HomePodとハードウェア系の発表もてんこ盛りでしたが、
個人的には、マークアップを担当することが多いのでiOS11のSafariの性能がとても気になっていました。

早速、手元の検証機をiOS11にしてSafariの設定画面を確認してみると。。。


f:id:kimizuka:20170608231415p:plain

むむむ。

「カメラとマイクのアクセス」という項目が!



Safari 11.0の新機能を確認してみると。。。

New in Safari 11.0 – Camera and microphone access.
 Added support for the Media Capture API.
 Websites can access camera and microphone streams from a user's device (user permission is required.)

https://developer.apple.com/library/content/releasenotes/General/WhatsNewInSafari/Safari_11_0/Safari_11_0.html より引用


むむむむむ。

どうやらカメラとマイクにアクセスできるようになった模様です!

個人的には特にカメラがうれしい。
FileAPIで撮影した写真にアクセスしたりするのではなく、
カメラにリアルタイムにアクセスできるとなると夢が広がりますね。
なので、今回は早速カメラにアクセスしてサイト上でプレビューするコードを書いてみましょう。
※ 簡単に試すためにマイクにはアクセスしていません。

ちなみに、httpsの環境でないと動作しないので注意が必要です。


ソースコード

ちなみに今回のコード(と、試行錯誤した様子のコミットログ)は こちら にアップされています。

github.com


それでは順に解説していきましょう。

1. getUserMediaでstreamを取得してvideoタグでプレビューする

まずは単純にカメラにアクセスしてブラウザでリアルタイムにプレビューすることを目指します。

HTML
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>Video</title>
  <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, shrink-to-fit=no" />
  <link rel="stylesheet" href="index.css" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
</head>
<body>
  <video id="video" autoplay playsinline></video>
  <script src="index.js"></script>
</body>
</html>
JavaScript
const medias = {
  audio : false,
  video : true
};
const video = document.getElementById("video");
const promise = navigator.mediaDevices.getUserMedia(medias);

promise.then(successCallback)
       .then(errorCallback);

function successCallback(stream) {
  video.srcObject = stream;
};

function errorCallback(err) {
  alert(err);
};
CSS
body {
  margin: 0;
  background: #000;
}

#video {
  display: block;
  width: 100%;
}


なにも考えなければ、これでOKです。

サンプルURLとQRコード

https://goo.gl/ijYwQs
f:id:kimizuka:20170606161103p:plain

ちなみにgetUserMediaのmediasのvideoにtrueを渡すとフロントカメラを取得するようです。
もしもリアカメラを取得したい場合は、

const medias = {audio : false, video : true},

の部分を、

const medias = {audio : false, video : {
    facingMode : {
      exact : "environment"
    }
  }},

に変更すればリアカメラにアクセスできます。

ただし、リアカメラのストリームをvideoタグにいれるとポートフォリオで見たときに、何故か天地が逆転してしまったので、CSSでvideoタグをひっくり返しておきました。

追記:(2019/02/17)
iOSのバグだったようでいつのまにか反転しなくなってました。

Navigator.getUserMedia - Web API | MDN
MediaDevices.getUserMedia() - Web API | MDN

サンプルURLとQRコード

https://goo.gl/rQLDt9
f:id:kimizuka:20170606161304p:plain

f:id:kimizuka:20170608231415p:plain

リアルタイムプレビューできましたね。^ ^
(もっと良い動画が取れると良かったんですが。。。)

フロントカメラを起動すると自分の顔ばかりが映っておもしろくないので、
これからのサンプルはリアカメラを起動していこうと思います。

2. getUserMediaでstreamを取得してvideoタグでプレビューし、CanvasにdrawImageする

次にカメラのリアルタイムの映像をCanvasに書き出し続けてみます。
videoタグをdisplay: noneにすると動かなかったので、Canvasの上にワイプのように表示してみました。

HTML
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>Canvas</title>
  <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, shrink-to-fit=no" />
  <link rel="stylesheet" href="index.css" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
</head>
<body>
  <canvas id="canvas"></canvas>
  <div id="videobox">
    <video id="video" autoplay playsinline></video>
  </div>
  <script src="index.js"></script>
</body>
</html>
JavaScript
const medias = {
  audio: false,
  video: {
    facingMode: {
      exact: "environment"
    }
  }
};
const video = document.getElementById("video");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const promise = navigator.mediaDevices.getUserMedia(medias);

promise.then(successCallback)
       .catch(errorCallback);

function successCallback(stream) {
  video.srcObject = stream;
  requestAnimationFrame(draw);
}

function errorCallback(err) {
  console.log(err);
  alert(err);
}

function draw() {
  canvas.width  = window.innerWidth;
  canvas.height = window.innerHeight;
  ctx.drawImage(video, 0, 0);

  requestAnimationFrame(draw);
}
CSS
body {
  margin: 0;
  background: #000;
}

#videobox {
  position: absolute;
  top: 10px; left: 10px;
  transform-origin: left top;
  transform: scale(.1);
}

#video {
  display: block;
  transform: rotateZ(180deg); /* なぜか天地反転するので天地反転し返す */
}

#canvas {
  display: block;
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
}


サンプルURLとQRコード

https://goo.gl/AhKN6t
f:id:kimizuka:20170606162607p:plain

f:id:kimizuka:20170608231415p:plain

(見た目上はわからないですが)Canvasになりましたね。^ ^

3. getUserMediaでstreamを取得してvideoタグでプレビューし、CanvasにdrawImageしつつ、モノクロにする

折角Canvasにしたのでピクセルデータにアクセスして色を変更してみました。

HTML
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>Monochrome</title>
  <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, shrink-to-fit=no" />
  <link rel="stylesheet" href="index.css" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
</head>
<body>
  <canvas id="canvas"></canvas>
  <div id="videobox">
    <video id="video" autoplay playsinline></video>
  </div>
  <script src="index.js"></script>
</body>
</html>
JavaScript
const medias = {
  audio: false,
  video: {
    facingMode: {
      exact: "environment"
    }
  }
};
const video  = document.getElementById("video");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const promise = navigator.mediaDevices.getUserMedia(medias);

let imgData;
let data
let ave;

promise.then(successCallback)
       .catch(errorCallback);

function successCallback(stream) {
  video.srcObject = stream;
  requestAnimationFrame(draw);
};

function errorCallback(err) {
  alert(err);
};

function draw() {
  canvas.width  = window.innerWidth;
  canvas.height = window.innerHeight;
  ctx.drawImage(video, 0, 0);

  imgData = ctx.getImageData(0, 0, canvas.width,  canvas.height);
    data = imgData.data;

    for (let i = 0; i < data.length; i += 4) {
      ave = (data[i + 0] + data[i + 1] + data[i + 2]) / 3;

      data[i + 0] = 
      data[i + 1] = 
      data[i + 2] = (ave > 255 / 2) ? 255 : (ave > 255 / 4) ? 127 : 0;
      data[i + 3] = 255;
    }

  ctx.putImageData(imgData, 0, 0);
  requestAnimationFrame(draw);
}

CSS

body {
  margin: 0;
  background: #000;
}

#videobox {
  position: absolute;
  top: 10px; left: 10px;
  transform-origin: left top;
  transform: scale(.1);
}

#video {
  display: block;
  transform: rotateZ(180deg); /* なぜか天地反転するので天地反転し返す */
}

#canvas {
  display: block;
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 100%;
}


サンプルURLとQRコード

https://goo.gl/huZORf
f:id:kimizuka:20170606163232p:plain

f:id:kimizuka:20170608231415p:plain

モノクロになりましたね。^ ^

おわりに

今回はとにかく手っ取り早くカメラのリアルタイムプレビューを試したかったので非常に簡単なサンプルをつくるにとどまりましたが、
Canvasに取り込みさえすればこっちのものなので、簡単なARアプリぐらいはつくれるかもしれません。
iOS11のリリースまではまだ時間がありそうなので、引き続き追っていこうと思います!
(後回しにしたマイクアクセスも追っていこうと思います)

カヤックでは一緒に新しいことにチャレンジしてくれるエンジニアも募集しているので、一緒に追ってくれる方絶賛お待ちしています!
www.kayac.com
www.kayac.com