2020.07.31

Geometry型をRailsアプリケーションで扱う話

こんにちは、印刷のラクスルのエリアマーケティングチームです。

本日は、ポリゴン刷新プロジェクトの取り組み(1回目はこちら)・第二回目の本日は「MySQLのGeometry型をRailsアプリケーションで扱う話」についてお話しようと思います。

本日の記事の担当は、サーバエンジニアの畠山です!

Geometry型をRailsで扱うのに必要なライブラリ

MySQLのGeometry型を扱うのに利用したRubyのライブラリは以下の3つです。

Armg

  • https://github.com/winebarrel/armg
  • ActiveRecordへ独自のデータ型としてgeometry型を追加するためのライブラリ
  • 通常は文字列として渡ってくるポリゴンデータを、裏でいい感じにパースしてポリゴンオブジェクトにしてくれる

RGeo

  • https://github.com/rgeo/rgeo
  • 地理空間データを扱うためのライブラリ
  • WKT(ポリゴンデータを表現するためのマークアップ言語)をパースするのに利用

Geokit Rails

Armgの事前準備

MySQLのGeometry型のカラムへいい感じにポリゴンデータをセットしてくれるArmgですが、いくつか事前準備が必要になります。

  • ポリゴンデータ保存と読み込みの際に利用するSRID(測地系の空間参照系識別子)のデフォルト値をセット
  • ポリゴンの点の重複を許容するため uses_lenient_assertions: true をセット

これを行うため以下の設定を config/initializers/armg.rb へ記述しておきます。

# config/initializers/armg.rb

require 'active_support/lazy_load_hooks'
 require 'rgeo'
 require 'armg'
 
 ActiveSupport.on_load(:active_record) do
   module Armg
     class CustomSerializer
       def initialize
         factory = ::RGeo::Cartesian.simple_factory(srid: 4612, uses_lenient_assertions: true)
         @base = ::Armg::WkbSerializer.new(factory: factory)
         @wkt_parser = ::RGeo::WKRep::WKTParser.new(factory, default_srid: 4612)
       end
 
       def serialize(value)
         if value.is_a?(String)
           value = @wkt_parser.parse(value)
         end
         @base.serialize(value)
       end
     end
 
     class CustomDeserializer
       def initialize
         factory = ::RGeo::Cartesian.simple_factory(srid: 4612, uses_lenient_assertions: true)
         @base = ::Armg::WkbDeserializer.new(factory: factory)
       end
 
       def deserialize(mysql_geometry)
         @base.deserialize(mysql_geometry)
       end
     end
   end
 
   Armg.serializer = Armg::CustomSerializer.new
   Armg.deserializer = Armg::CustomDeserializer.new
 end

DBマイグレーションの書き方

polygonsテーブルへGeometry型のカラムを追加するためのDBマイグレーションは以下のように記述します。

# db/migrate/20200729080434_create_polygons.rb

class CreatePolygons < ActiveRecord::Migration[6.0]
  def change
    create_table :polygons do |t|
      # armgにより追加されたgeometry型でpolygonカラムを定義
      t.geometry :polygon, null: false
      t.float :lat, null: false
      t.float :lng, null: false
      t.index [:lat, :lng]
    end
  end
end

ポリゴンデータの読み書きの方法

Geometry型のカラムへデータを書き込む際はWKT形式(ポリゴンデータを記述するためのマークアップ言語)の文字列を渡します。

# ポリゴンデータの書き込み
data = "POLYGON((32 130, 35 130, 35 133, 32 133, 32 130))"
Polygon.create(lat: 32.9913647, lng: 130.4321717, polygon: data)

読み込み時は RGeo::Cartesian::PolygonImpl のインスタンスが返るので(設定でカスタマイズ可能)返ってきたインスタンスからポリゴンの頂点座標などを取得します。

# ポリゴンデータの読み込み
p = Polygon.last

# ポリゴンを表すオブジェクトが返る
p.polygon
#=> #<RGeo::Cartesian::PolygonImpl:0x3fc221e5356c "POLYGON ((32.0 130.0, 35.0 130.0, 35.0 133.0, 32.0 133.0, 32.0 130.0))">

# ポリゴン外周(exterior ring)の頂点座標の取得
p.polygon.exterior_ring.points.map {|point| [point.x, point.y] }
=> [[32.0, 130.0], [35.0, 130.0], [35.0, 133.0], [32.0, 133.0], [32.0, 130.0]]

また、ポリゴンの中にポリゴンが含まれている場合(飛び地などで穴があくことがある)、外側のポリゴン(exterior ring)と内側に含まれる複数のポリゴン(interior ring)を取得することができます。

polygon = <<~DATA
  POLYGON(
    (32 130, 35 130, 35 133, 32 133, 32 130),
    (33 131, 33 132, 34 132, 34 131, 33 131)
  )
 DATA
 Polygon.create(lat: 32.9913647, lng: 130.4321717, polygon: polygon)
 
 # 外周(exterior ring)を取得
 p = Polygon.last
 p.polygon.exterior_ring.points.map {|point| [point.x, point.y] }
 => [[32.0, 130.0], [35.0, 130.0], [35.0, 133.0], [32.0, 133.0], [32.0, 130.0]]
 
 # 内周(interior ring)を取得
 p.polygon.interior_rings.map {|ring| ring.points.map {|point| [point.x, point.y] } }
 => [[[33.0, 131.0], [33.0, 132.0], [34.0, 132.0], [34.0, 131.0], [33.0, 131.0]]]

ここで取得した外周と内周のポリゴンデータを地図へ表示するとこんな感じになります。

MySQLの空間分析関数

ここまでRubyでGeometry型の値を操作する方法を紹介しましたが、MySQLもGeometry型を操作するための空間分析関数を備えています。例えば、先ほどRubyで書いたポリゴンの外周や内周を取得する処理をMySQLの空間分析関数を使って書くと以下のようになります(select の中でMySQLの空間分析関数を使っています)。

 # 外周(exterior ring)を取得
 Polygon.select('ST_AsText(ST_ExteriorRing(polygon)) as exterior_ring').last.exterior_ring
 #=> "LINESTRING(32 130,35 130,35 133,32 133,32 130)"
 
 # 内周(interior ring)を取得
 Polygon.select('ST_AsText(ST_InteriorRingN(polygon, 1)) as interior_ring').last.interior_ring
 #=> "LINESTRING(33 131,33 132,34 132,34 131,33 131)"

空間分析関数を利用したパフォーマンス改善

今回のプロジェクトでは、この空間分析関数を利用することでポリゴン取得APIのパフォーマンスを大きく改善することができました。

空間分析関数を利用する前は下の図のような大量のポリゴン(集合体恐怖症の人は薄目で見てください!)を表示しようとするとポリゴン取得APIでタイムアウトが発生することがたびたびありました。

プロファイラ(StackProf)で調べてみたところ、DBから取得したWKT形式のポリゴンデータのパースがボトルネックとなっており、全体の50%ほどの時間を使っていることが判明、早速この箇所のパフォーマンス改善に取り組みました。

ポリゴン取得APIは内部で以下の処理を行っています。(2) の文字列のパースは比較的重い処理なうえ、WKTのパーサがRubyで書かれているためパフォーマンスもそれほど良くはありません。

  • (1) WKT形式のデータを取得【Ruby】
  • (2) WKTをパース(ここで50%使っている)【Ruby】
  • (3) パースした結果から外周(Exterior Ring)を取得【Ruby】
  • (4) 外周の座標をJSONに変換して返す【Ruby】

この問題を解決するため、上記の「(2) WKTのパース」と「(3) パースした結果から外周を取得」の処理をMySQLの空間解析関数で行うように変更しました。変更後の処理は以下のような感じ通りです。

  • (1) ポリゴンデータから外周を取得【MySQL】
    • MySQLの内部形式のままデータ操作するので上述の「(2) WKTのパース」は不要になる
  • (2) 外周のポリゴンデータをWKT形式で取得【Ruby】
  • (3) WKT形式の外周のデータをRubyのgsubとsplitを使った簡素にパース【Ruby】
    • 簡素なパースにすることで「文字列操作の時間」と「パース結果のオブジェクトの生成時間」を削減
  • (4) 外周の座標をJSONに変換して返す【Ruby】

これにより平均で3.52秒かかっていたポリゴン取得処理が1.74秒とほぼ半分の時間で終わるようになりました。下図のようにポリゴン取得APIのレイテンシも対応前(紫)と対応後(青)で目に見えて改善されています。空間解析関数最高!!

まとめ

以上、MySQLのGeometry型をRailsから使う方法についてのご紹介でした。

ではまた。

ラクスルの新聞折り込み、ポスティング、DMなどの集客支援商材をメインで担当。エンジニアリングマネージャしつつ、自身も開発に携わってます。ラクスルではRailsの開発がメイン。きちんと効果が見える新しいオフライン広告を目指して日々奮闘中!

2020.07.31 #Technology
1146

Geometry型をRailsアプリケーションで扱う話

この記事のURLをコピーする
この記事をシェアする ツイート シェア はてな