Let's write β

プログラミング中にできたことか、思ったこととか

Rails5 + MySQLでArmgとDBコネクタの拡張を組み合わせて位置情報を扱う

背景

サービスで位置情報を色々なところで扱うのですが MySQLで扱うためには当然Geometry型のカラムを用意してActiveRecordとつなぐ必要があり PostGisを使っているケースが多く、MySQL周りのライブラリはあまり活発ではありませんでした。

RGeoのMySQL2コネクタは停滞している

それっぽいライブラリは有るんですが 更新があまりされていなくて、Rails5への対応もずっと止まっていて少し不安です。

なので、対応した別のライブラリを探すか、これを使うなら自分でPRを凄く送る等が必要かなと思います。

救世主Armg

そんなことをモヤモヤ前から考えていて、自作は流石に大変そうだなと思ってたのですが、 Armgというライブラリが見つかって助かりました Railsで位置情報を扱ってる情報の流通量がそもそも少ないので新しいライブラリの情報を見つけづらいというのはありますね。

データベースへのシリアライズ

Armgでは、というか一般的に独自のカラム型をActiveRecordに追加するには Active Record::Type::Valueを継承する必要があります。 Armgでも以下のように:geometry型を新規に定義しています。

class Armg::MysqlGeometry < ActiveModel::Type::Value
  def type
    :geometry
  end

  def deserialize(value)
    if value.is_a?(::String)
      Armg.deserializer.deserialize(value)
    else
      value
    end
  end

  def serialize(value)
    if value.nil?
      nil
    else
      Armg.serializer.serialize(value)
    end
  end
end

このように定義して、

ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::NATIVE_DATABASE_TYPES[:geometry] = { name: 'geometry' }
ActiveRecord::Type.register(:geometry, Armg::MysqlGeometry, adapter: :mysql2)

のようにして、データ型をActiveRecordに登録してやっています。

Armg.serializer

Active Record::Type::Valueでは、値をActiveRecordの情報から、 DBへのQueryに埋め込む形式に変えるために、serializeというメソッドが呼び出されます。 拡張なしのActiveRecordで対応しているのは

  • String
  • Numeric
  • Date
  • Time
  • Symbol
  • true
  • false
  • nil

くらいだと思います、なので、通常新規の型を作るなら、これらへの変換をserializeの中でやる必要があります。

Armgへの登録タイミング

ArmgではカスタムのSerializerを定義できるので、 SRIDを適切にGPSの地理座標系に指定するために以下のようにSerializer, Deserializer合わせて定義しました。

require 'rgeo'
require 'armg'

class GeometryDeserializer
  def initialize
    factory = ::RGeo::Geographic.spherical_factory(srid: 4326)
    @base = ::Armg::WkbDeserializer.new(factory: factory)
  end

  def deserialize(mysql_geometry)
    @base.deserialize(mysql_geometry)
  end
end
class GeometrySerializer
  def initialize
    factory = ::RGeo::Geographic.spherical_factory(srid: 4326)
    @base = ::Armg::WkbSerializer.new(factory: factory)
    @wkt_parser = ::RGeo::WKRep::WKTParser.new(
      factory, 
      default_srid: 4326)
  end

  def serialize(value)
    if value.is_a?(String)
      value = @wkt_parser.parse(value)
    end
    @base.serialize(value)
  end

このようなカスタムのSerializerを定義してやりました。 これをArmgに

Armg.serializer = GeometrySerializer.new

登録してやる必要があるのですが、このタイミングが重要です。 initializersのなかで適当に上記の行を実行しようとするとArmg周りの定義で死にます。 実はArmgは

ActiveSupport.on_load(:active_record) do
....
end

というブロックの中でrequire等が行われているので、initializerの段階ではまだArmgの型等がUndefinedだったりします。 そんな状況でnewしようとするので当然死にます。

なので、以下のように

require 'active_support/lazy_load_hooks'
require 'rgeo'
require 'armg'

ActiveSupport.on_load(:active_record) do
    Armg.serializer = ::GeometrySerializer.new
    Armg.deserializer = ::GeometryDeserializer.new
    ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(::ArmgConnectionAdapterExt)
end

こちらの登録についても同様にハンドラに登録してやって遅延させる必要があります。

ログが死ぬ

ここまでで本来は問題ないのですが、ここで問題が起こります。 RailsSQLを実行するとそのログを表示しようとするのですが、この表示時に 上記のserializerではWKBと呼ばれるバイナリ形式をつかってSQLにgeometryデータを流し込もうとします。 これがRails上でログ解析時に異常なUTF-8文字列だと認識されて例外を吐くようになります。

SQLにも問題はなく、Serialize、Deserialize普通にできるのですが、 FactoryGirl等で書き込むときに例外を吐かれるとテストが通りません。

AdapterExtを利用してSQLへのエンコーディングの方法を変える

ST_GeomFromWKBを使いたい

WKBで送ろうとすると\x01....的な文字列(に見える形)で送られ、こいつがrubyに引っかかるので このような文字列形式じゃない形で送る必要があります。そこで利用するのが

ST_GeomFromWKB(0x01......., <SRID>)

という16進数とSRIDを指定する形式です。これならエラーは出ないでしょう。

そこで、正常にRubyが処理できる形式でSQLに織り込んでやる必要があります。 前述のように新規のデータ型を追加するには、Active Record::Type::Valueを継承してやって serializeの中で適宜変換してやる必要があるのですが、SQLへの埋め込みは実際にはそれではうまく行きません。

そもそも前述のようにserializeから本来返せるデータ型には制限があります。 今回使うのに適切そうなのはStringだと思いました。 ST_GeomFromWKBのSQL関数文字列を素直にserializeから返してやればいいと。

しかし、そもそもserializeは文字列型で渡されるとSQLには「その文字列をそのまま文字列としてSQLに渡そう」とします。 (当然の挙動ですが)なので、単にST...という文字列を返してしまうとSQLには

UPDATE INTO .... VALUES("ST_GeomFromWKB(....)")

というふうに文字列形式で吐き出されてしまってSQLの関数呼び出しではなくなってしまいます。

AdapterExtでquoteに手を入れる

そのため、Type::Valueのレベルではハンドリングできないことがわかりました。 では、ユーザーはSQLは理解してくれるはずの独自形式を作れないのでしょうか?

そのようなときに使うのがDBへのコネクタへの拡張です。

上のコードで

ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter.prepend(::ArmgConnectionAdapterExt)

の用にしているのがソレです。

これは、DBへのアダプタのチェインにクラスを追加するもので、ベースとなる DBへのコネクタが値をSQLに変換するまでの過程に手を加えることができます。

そのなかでも、quote(value_, column_=nil)というメソッドが重要になってきます。 このメソッドは ある値をSQL そのまま埋め込んで良い文字列に変換するためのメソッドです。 イメージとしては返された文字列が直接SQLに結合されるというイメージです。

つまりたとえば"hoge"という文字列を返したときにSQL上で"hoge"と引用符がついた状態で 埋め込まれるのではなくhogeSQLの一部として埋め込まれるのです。

これを使えば、SQLの中に直接先程のST_GeomFromWKBを埋め込むことができます。

そこで、先程のGeometrySerializerを以下のように変更します。

lass GeometrySerializer
  def initialize
    factory = ::RGeo::Geographic.spherical_factory(srid: 4326)
    @base = ::Armg::WkbSerializer.new(factory: factory)
    @wkt_parser = ::RGeo::WKRep::WKTParser.new(
      factory, 
      default_srid: 4326)
  end

  def serialize(value)
    if value.is_a?(String)
      value = @wkt_parser.parse(value)
    end
    return value
  end
end

WKBエンコードされた文字列を返すのではなく、一旦RGeoのGeometryデータ型を そのまま返すようにしました。

そして、ArmgConnectionAdapterExtを以下のようにRGeoのデータを特別扱いするように 定義して、ソレ以外は親に任せるように定義してやります。

module ArmgConnectionAdapterExt
    def quote(value_, column_=nil)
          if ::RGeo::Feature::Geometry.check_type(value_)
            "ST_GeomFromWKB(0x#{::RGeo::WKRep::WKBGenerator.new(hex_format: true, little_endian: true).generate(value_)},#{value_.srid})"
          else
            super
          end
    end
end

このようにすることで、RGeoのデータをSQLに書き込むときにWKBの文字列形式で書き込まれるのではなく ラップされた形で書き込まれるようになります。 例えば以下のような感じです。

UPDATE `prohibited_areas` SET `geo_polygon` = ST_GeomFromWKB(0x01010000000000000000003e400000000000003e40,4326), `updated_at` = '2017-10-25 09:18:44' WHERE `prohibited_areas`.`id` = 1

これで無事にログ上にも正常にSQLが表示されるようになり、例外は発生することはなくなりました。

Rails 5.1で

github.com

quoteの二番目の引数であった _column が削除されましたので、Rails 5.1で利用する場合は上のコードの引数も修正してください。

Rails 5.2 で

initialize_type_mapperに渡ってくる引数

Rails 5.2移行ではなくなってしまったのでarmgの対応が必要です

github.com

これをとりこんである物を私のリポジトリに用意しています

gem 'armg', github: 'pocket7878/armg', branch: 'feature/rails-5.2'

Quoteのvalueがラップされている

Rails 5.2になって、quoteに渡ってくる値が ActiveRecord::Relation::QueryAttribute にラップされている事があるようになりました。 元の値については#value_for_database で取りだせるようなので

module ArmgConnectionAdapterExt
  def quote(value)
    if value.respond_to?(:value_for_database)
      db_value = value.value_for_database
      if ::RGeo::Feature::Geometry.check_type(db_value)
        "ST_GeomFromText(\'#{::RGeo::WKRep::WKTGenerator.new(convert_case: :upper).generate(db_value)}\', #{db_value.srid})"
      else
        super
      end
    elsif ::RGeo::Feature::Geometry.check_type(value)
      "ST_GeomFromText(\'#{::RGeo::WKRep::WKTGenerator.new(convert_case: :upper).generate(value)}\', #{value.srid})"
    else
      super
    end
  end
end

といった形で取りだしてやる事で対処できます。

得られるおまけの利益

このようにRGeoのオブジェクトをSQLに安定して埋め込めるようになると 以下のようなscopeも書けるようになります。

scope :contain_latlng, ->(lat, lng) {
    factory = RGeo::Geographic.spherical_factory(srid: 4326)
    where('ST_CONTAINS(geo_polygon, ?)', factory.point(lng, lat))
}

この?の部分で先程のquoteが使われています。 なので、

ServiceArea.contain_latlng(100, 40)
  ServiceArea Load (0.4ms)  SELECT `service_areas`.* FROM `service_areas` WHERE (ST_CONTAINS(geo_polygon, ST_GeomFromWKB(0x010100000000000000000044400000000000805640,4326)))

このように、きちんと正しいSQLが安定して発行されるようになり、 自然にSQLで位置情報を扱えるようになりました。