背景
サービスで位置情報を色々なところで扱うのですが 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
こちらの登録についても同様にハンドラに登録してやって遅延させる必要があります。
ログが死ぬ
ここまでで本来は問題ないのですが、ここで問題が起こります。 RailsでSQLを実行するとそのログを表示しようとするのですが、この表示時に 上記の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"
と引用符がついた状態で
埋め込まれるのではなくhoge
とSQLの一部として埋め込まれるのです。
これを使えば、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で
quoteの二番目の引数であった _column
が削除されましたので、Rails 5.1で利用する場合は上のコードの引数も修正してください。
Rails 5.2 で
initialize_type_mapperに渡ってくる引数
Rails 5.2移行ではなくなってしまったのでarmgの対応が必要です
これをとりこんである物を私のリポジトリに用意しています
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)))