YOLP APIとオープンデータで遊ぶ

最近オープンデータが流行りですが、
これを利用してYOLPのAPI(Yahoo!地図)を使って表示する方法。
自分への備忘録として。

使用するデータベースはPostgreSQLのGIS拡張版。
いわゆるPostGIS。
サーバーサイドはRuby on Railsで。

まず国土数値情報ダウンロードサービスから、
何かしら利用したいデータをダウンロードしてくる。
利用するのはshape形式のファイルだ。

○○.shp
○○.shx
○○.def

の3つのファイルを利用してデータベースを作る。
(EPSGコードは4326を利用)

shp2pgsql -W cp932 -s 4326 ○○.shp テーブル名 | psql DB名

とすることで指定したDBにテーブルが作れる。
テーブルを新規に作るのではなく、既存のものに追加するには、

shp2pgsql -W cp932 -s 4326 -a ○○.shp テーブル名 | psql DB名

と「-a」を追加してやればよい。

このデータがPolygonの場合、これを地図に表示させるには、
Polygonを構成する点のリストが必要になる。

これを広範囲で取ってくると描画処理が大変なので、
地図に表示する範囲のデータのみを取って来たい。

そこで、地図の中心点の緯度、経度とズームレベル、
縦、横のピクセル数から、四隅の緯度、経度を取得する方法を考える。

map4

YOLPのAPIから色々調べてみると、
ズームレベルが1の時はwidthが256px。
これがズームレベルが増えるにつれて、2倍ずつになっている。

360度 = 256pxなので、ズームレベルが1の時
1px = 1.40625px。
ズームレベルをzoomとして、縮尺zを求めると。

z = 2 ** (zoom - 1)

中心位置の経度をlongitude、
左端(西端)をlongitude_left、
右端(東端)をlongitude_rightとすると、
地図の横幅がwidth pxの時は。


longitude_left = longitude - width / 2 * 1.40625 / z
longitude_right = longitude + width / 2 * 1.40625 / z

さて、問題は緯度だ。
メルカトル図法だと、北に行くに従って、
実際の寸法よりも拡大されていく。

そこでWikipediaでメルカトル図法の投影法について調べてみる。

…こういう時に学生の時にもっとちゃんと勉強していたら、
と思うのだけど、とりあえず、

緯度から座標を求める => 逆グーデルマン関数(角度)
座標から緯度を求める => グーデルマン関数(座標)

で求められそうだ。
グーデルマン関数とは何だかわからないので、
再びWikipediaの内部リンク先のグーデルマン関数を調べる。

アークタンジェントとか、exとか出てきて頭が痛くなるが、
rubyのMathモジュールに実装されているので、
グーデルマン関数、逆グーデルマン関数はこんなに簡単に実装できる。

グーデルマン関数


def gudermannian(x)
  return 2 * Math.atan(Math.exp(x)) - Math::PI / 2
end

逆グーデルマン関数
(Math.tanの引数の単位はラジアン:角度×π/180)


def inverse_gudermannian(x)
  return Math.asinh(Math.tan(x * Math::PI / 180))
end

これで緯度の計算がなんとかなるメドがついた。
ここでの座標は、地球を半径1の単位円とした場合の座標。
YOLPの場合はズームレベル1の時が256pxなので、
ズームレベル1の時の半径をrとすると、
円周の公式から、2πr = 256pxとなるので、

r = 128 / Math::PI

1px当たりの座標の変化量は、

1 / r / z

となる。

地図の上端(北端)、下端(南端)と中心との座標の変化量から、
上端(北端)、下端(南端)の座標を求め、
それを経度に変換してやればよさそうだ。

中心の緯度をlatitudeから中心の座標center_yを求めるには、

center_y = inverse_gudermannian(latitude)

地図の縦幅をheight pxとすると、
上端(北端)の座標top_yは、

center_y + height / 2 / r / z

下端(南端)の座標bottom_yは、

bottom_y = center_y - height / 2 / r / z

これらをグーデルマン関数を通して、
上端(北端)の座標latitude_topと、
下端(南端)の座標latitude_bottomを求めてやる。
(ラジアンから角度へ戻すのも含めて)


latitude_top = gudermannian(top_y) * 180 / Math::PI
latitude_bottom = gudermannian(bottom_y) * 180 / Math::PI

これで4隅の座標が出るので、
描画領域のPolygonをPostGISで作成する。


polygon = ::DB::Table.find_by_sql("
  select
    ST_GeomFromText('
      POLYGON((
        " + longitude_left.to_s + " " + latitude_top.to_s + ",
        " + longitude_left.to_s + " " + latitude_bottom.to_s + ",
        " + longitude_right.to_s + " " + latitude_bottom.to_s + ",
        " + longitude_right.to_s + " " + latitude_top.to_s + ",
        " + longitude_left.to_s + " " + latitude_top.to_s + "
      ))
    ', 4326) as view_area
").first

この描画領域と重なるPolygonの構成点を取得。


points = ::DB::Table.find_by_sql("
  select
    gid,
    ST_X((dp).geom) as longitude,
    ST_X((dp).geom) as latitude
  from
    (
      select
        gid,
        ST_DumpPoints(ST_Intersection(geom, '" + polygon.view_area + "')) as dp
      from
        Table
      where
        (
          ST_Transform(geom, 32653)
          && ST_Transform('" + polygon.view_area + "', 32653)
        )
        and ST_DWithin(
          ST_Transform(geom, 32653),
          ST_Transform('" + polygon.view_area + "', 32653),
          0
        )
      order by
        gid, dp
    ) as foo
")

この結果をJSONなどで出力し、
地図の描画領域が変わった時に取得して、
YOLPのPolygon表示方法に合わせて、
緯度、経度のリストを渡してあげればよい。

最近オープンデータは流行りなので、
国土数値情報ダウンロードサービスなどの
利用を検討している方がいらっしゃるようでしたら、
相談に乗りますのでお気軽にご連絡ください。

最近はYOKUMOKUのシガールが大好きです。

クラウドの落とし穴

上手に使えば良いのだけどね。
変な使い方をしていると必要以上にお金がかかっていることも…。

a0002_011580

弊社が使用しているデータセンターでは、
「○○Mbps共有」というようなプランで契約しているので、
通常は転送量で料金が変わることはない。

ただ、「○○Mbps」の30%を超える当たりになると、
上位プランへ移行してくださいね、ということだ。

今年に入ってから、
そろそろ上位プランへの移行を検討してくださいね、
という話をされはじめてきた。
移行すると差額は20万円/月程度となるので、
できれば現行のままで抑えたいと。

そこでサイトの状況をチェックすると、
本来小さな画像で良いところに、
大きな画像をHTMLタグで縮めて表示している箇所が多数。
(転送量は大きな画像のサイズ)

使用している場所ごとにサイズがまちまちなので、
その分のサイズを全部作っていくと、今度はIOがキツくなる。。。

そこでまず、画像を動的にリサイズして表示させる仕組みを作る。
これには10万円/月程度のコストは掛かるが、
転送量の値段よりだいぶマシだし、
苦労していた画像の運用の問題も解決するので、まぁ良いだろうと。
やり方は次の記事に書いた通りだ。

Nginxを改造して欲しかった画像サーバーを作った

…とりあえず、5Mbps程度を削ることができ、急場はこれで凌げたのだけど、
もう少し落とせるはずだったんだけどなぁ…と。
それにコンテンツが量産され続けていて、PVも増えて来ている。
近々また転送量が問題になることが予想される。

そこで、最終的に画像を返しているNginxのアクセスログをチェックして、
転送量が大きい画像をチェックしてみる。
sortコマンドで出力サイズの大きい順に表示だ。

sort -n -k10 -r access.log | more

…出るわ出るわ。
1MB超の画像があるわ。300KB~600KBの画像も多数…。

「何でこんなの使ってるの?」と。
どうしても綺麗な画像を使いたい箇所以外は、
JPEGのクオリティは落とすように徹底してくれと。

JPEGのクオリティとファイルサイズの関係については、
こちらのサイトなどに詳しく書かれている。

jpg,png,gifの違いと比較と簡単に分かる最適な使い分け方

こういうのをきちんと注意して最適な設定にすれば、
転送量はかなり節約できる。

あとは弊社では当たり前のように利用しているけど、
画像以外のファイルはmod_deflateなどで圧縮できる。
こちらの記事などでも詳しく紹介されている。

mod_deflateによるコンテンツの圧縮転送

思うのだけど、この手のマネージメントって、
役割で言うと誰の担当になるんだろうね?

Webディレクター?デザイナー?プログラマー?
インフラエンジニア?

個々が気を付けなければならないのだけど、
最終的には統括しているWebディレクターが、
チェックする必要あるんじゃないかなぁと。

僕はディレクターやプロデューサー、
経営者がプログラミングができることは必須だと思わないけど、
コストに関する知識はきちんと持っていないと
ダメだろうなぁと思う。
利益出さなきゃいけないでしょ?

弊社の場合は通信料は定額だから良かったけど、
これが従量課金だったらと思うとゾッとする。

転送量に無頓着で、
必要以上のインフラコストをかけている会社って、
結構多いんじゃないかなぁと。

案件によっては、転送料金を削る、
成果報酬のコンサルティングなんかもアリかもね。

件数多かったり、作業が発生するならビジネスにするけど、
ビール一杯(発泡酒、第三のビールも可)奢ってもらえれば
相談に乗りますので、お気軽にご連絡ください。

Nginxを改造して欲しかった画像サーバーを作った

スマートフォンサイトに向けての最適化だったり、
Webページのレイアウトの変更だったりで、
同じ画像でも様々なサイズを利用することがある。

サイトの運用を続けていると、
同じ画像でも数通りのファイルを保存していて、
ファイル数が大量になってIOがキツくなってきた。

数多くなるとIO辛いから、
ファイル増やさないように気をつけてね、
と社内で言い続けていたら…

Webサイトでは、本来小さい画像で良いところに
大きい画像を使用して、
JavaScriptでリサイズする処理が多用されはじめ、
今度は転送量が増加しはじめて、
これ以上増えるとデータセンターの月額費用が
増加してしまいそうに…。

なんとかせねば!と対応をはじめるわけです。

そこでまず最初にやったのが、
本当は速いImageMagick
と同じように、Apacheモジュールを自作して、
ImageMagickでリサイズする構成。

構成1

画像がアップされた時に、
RailsからWebDavで画像サーバーにファイルをアップ。

画像サーバーでは、

/resize/100x100q80/hoge/hoge.jpg
/crop/100x100q80/hoge/hoge.jpg

などとして、パスの前にresizeかcropかを明記し、
width×heightと、必要であればJPEGのクオリティ指定も。
Apacheで動的に処理させるので、
保存する画像は1枚だけで良いし、
サイズやクオリティの調整で転送量も調整できる。

これはすばらしい!と思いながら、
こちらへの移行を開始しはじめたのだが…。

大量に処理をさせると、CPU負荷が辛くなってきた。
そこでキャッシュを入れる仕組みを検討する。

また、画像処理については、
データセンターのサービスでVMWare Vcenterを
安く借りられるプランがあったので、
そちらを利用して分散することにする。

そして、
簡単!リアルタイム画像変換をNginxだけで行う方法
を参照して、画像処理はNginxの、
http_image_filter_moduleを使用するように。

考えていた構成はこんな感じだ。

構成2

しかし、この構成には一つ問題があり、
採用に踏み切れなかった。

画像のキャッシュ削除の方法がないのである。

Nginxのcache_purgeモジュールなどで削除する方法はあるが、
リサイズしたすべてのファイルを抽出することはできない。

Nginxはキャッシュファイル名をmd5化する。
md5は不可逆なので、キャッシュファイル名から、
元のファイル名に関連するものを
ピックアップするのは不可能だ。

違う方法も検討したけど、
Nginxのcache managerは古いファイルを自動削除してくれるので、
余計なファイルが大量生成されるのを防ぐために、
どうしてもNginxのキャッシュの仕組みを利用したいなぁと。

そこで、ファイルの更新があった場合に、
/var/spool/nginx/update というディレクトリを作り、
その下に元ファイルのmd5データを入れておき、
キャッシュファイルの更新日時がそれより古い場合は、
キャッシュHITさせないように、
Nginxの処理を変更させてやろうと思った。

具体的な処理は次のような感じだ。

ロジック

最初はNginxのモジュールを書こうと思ったのだが、
色々調べているうちに、
src/http/ngx_http_upstream.c
に直接手を入れた方がやりやすそうだったので、
ソースコードをいじることにした。
(セットアップしていたのが少し古くて1.5.6ベース)

※改造したngx_http_upstream.cはこちら

まずキャッシュファイル名の生成に使うので、
ngx_md5.hをincludeしてやる。

#include <ngx_md5.h>

独自関数の定義。

static ngx_int_t ngx_http_upstream_sanzero_cache(ngx_http_request_t *r);

ngx_http_upstream_cachの中で
NGX_DECLINEDを返せばキャッシュを読まなくなるので、
750行目あたりの、
rc = ngx_http_file_cache_open(r);
の後に独自のチェックへの処理を追加してやる。


if (ngx_http_upstream_sanzero_cache(r) == NGX_DECLINED) {
  u->cache_status = NGX_HTTP_CACHE_BYPASS;
  return NGX_DECLINED;
}

独自の関数の処理。


static ngx_int_t ngx_http_upstream_sanzero_cache(ngx_http_request_t *r)
{
  ngx_str_t                    *key;
  ngx_md5_t                     md5;
  ngx_uint_t                    n;
  ngx_uint_t                    i;
  ngx_file_info_t               fi;
  ngx_file_info_t               cache_fi;
  ngx_http_cache_t             *c;
  ngx_regex_compile_t           rc;
  ngx_regex_compile_t           rc2;
  ngx_http_file_cache_t        *cache;
  int                           ovector[21];
  int                           ovector2[24];
  int                           result;
  int                           result2;
  time_t                        now;
  size_t                        j;
  size_t                        level;
  size_t                        len;
  u_char                        errstr[NGX_MAX_CONF_ERRSTR];
  u_char                        errstr2[NGX_MAX_CONF_ERRSTR];
  u_char                        original_key[NGX_HTTP_CACHE_KEY_LEN];
  u_char                       *p;
  uint32_t                      crc32;

#if (NGX_PCRE)
  ngx_str_t pattern = ngx_string("^/(resize|crop)/([0-9]+)x([0-9]+)(q([0-9]+))?(/.+)$");
  ngx_str_t pattern2 = ngx_string("^(.*)?(/resize|/crop)/([0-9]+)x([0-9]+)(q([0-9]+))?(/.+)$");

  ngx_memzero(&rc, sizeof(ngx_regex_compile_t));

  rc.pattern = pattern;
  rc.pool = r->pool;
  rc.options = NGX_REGEX_CASELESS;
  rc.err.len = NGX_MAX_CONF_ERRSTR;
  rc.err.data = errstr;

  if (ngx_regex_compile(&rc) != NGX_OK) {
    return NGX_ERROR;
  }

  result = ngx_regex_exec(rc.regex, &r->unparsed_uri, ovector, 21);

  if (result < 7) {
    return NGX_OK;
  }

  c = r->cache;
  key = c->keys.elts;

  len = 0;

  ngx_memzero(&rc2, sizeof(ngx_regex_compile_t));

  rc2.pattern = pattern2;
  rc2.pool = r->pool;
  rc2.options = NGX_REGEX_CASELESS;
  rc2.err.len = NGX_MAX_CONF_ERRSTR;
  rc2.err.data = errstr2;

  if (ngx_regex_compile(&rc2) != NGX_OK) {
    return NGX_ERROR;
  }

  ngx_crc32_init(crc32);
  ngx_md5_init(&md5);

  for (i = 0; i < c->keys.nelts; i ++) {
    result2 = ngx_regex_exec(rc2.regex, (ngx_str_t *)&key[i], ovector2, 24);

    if (result2 >= 8) {
      int original_len = key[i].len - ovector2[14] + ovector2[4];
      u_char *original_path = ngx_pstrdup(r->pool, &key[i]);
      u_char *original_data = ngx_pnalloc(r->pool, original_len);

      if (original_data == NULL) {
        return NGX_ERROR;
      }

      if (ovector2[4] > 0) {
        ngx_memcpy(original_data, key[i].data, ovector2[4]);
      }

      original_path += ovector2[14];

      ngx_memcpy(original_data + ovector2[4], original_path, key[i].len - ovector2[14]);

      ngx_str_t original_key_str = { original_len, original_data };

      ngx_crc32_update(&crc32, original_data, original_len);
      ngx_md5_update(&md5, original_key_str.data, original_key_str.len);
    }
  }

  ngx_crc32_final(crc32);
  ngx_md5_final(original_key, &md5);

  cache = c->file_cache;

  ngx_str_t update_path = ngx_string("/var/spool/nginx/update");

  ngx_str_t original_file_name = {
    update_path.len + 1 + cache->path->len + 2 * NGX_HTTP_CACHE_KEY_LEN,
    ngx_pnalloc(r->pool, update_path.len + 1 + cache->path->len + 2 * NGX_HTTP_CACHE_KEY_LEN + 1)
  };

  if (original_file_name.data == NULL) {
    return NGX_ERROR;
  }

  ngx_memcpy(original_file_name.data, update_path.data, update_path.len);

  p = original_file_name.data + update_path.len + 1 + cache->path->len;
  p = ngx_hex_dump(p, original_key, NGX_HTTP_CACHE_KEY_LEN);
  *p = '\0';

  len = original_file_name.len;
  j = update_path.len + 1;

  original_file_name.data[update_path.len + cache->path->len] = '/';

  for (n = 0; n < 3; n ++) {
    level = cache->path->level[n];

    if (level == 0) {
      break;
    }

    len -= level;
    original_file_name.data[j - 1] = '/';

    ngx_memcpy(&original_file_name.data[j], &original_file_name.data[len], level);
    j += level + 1;
  }

  if (ngx_file_info(original_file_name.data, &fi) == NGX_FILE_ERROR) {
    return NGX_OK;
  }

  now = ngx_time();

  if (now - fi.st_mtime > cache->inactive) {
    ngx_delete_file(original_file_name.data);
    return NGX_OK;
  }

  if (ngx_file_info(c->file.name.data, &cache_fi) == NGX_FILE_ERROR) {
    return NGX_OK;
  }

  if (cache_fi.st_mtime > fi.st_mtime) {
    return NGX_OK;
  }
#else
  return NGX_OK;
#endif

  return NGX_DECLINED;
}

そして、/var/spool/nginx/update
にファイルを置くための独自モジュールを作成。

ngx_http_sanzero_purge_module.c

nginx.confにはこんな感じで書けばOK。


proxy_temp_path /var/spool/nginx/temp;
proxy_cache_path /var/spool/nginx/cache keys_zone=cache:512m levels=2:2:2 inactive=1d max_size=100g;

upstream resize {
  server 192.168.0.1
  server 192.168.0.2
  server 192.168.0.3
  server 192.168.0.4
}

server {
  listen 80;

  location ~ /purge(/.*) {
    # 削除を許すIPアドレス
    allow 192.168.0.0/24

    deny all;

    proxy_cache cache;
    proxy_cache_key "$host:/$1";

    sanzero_purge;
  }

  location / {
    allow all;

    proxy_cache cache;
    proxy_cache_key "$host:/$request_uri";
    proxy_cache_valid 200 1d;

    proxy_pass http://resize;
  }
}

これで /purge/hoge/hoge.jpg にGETリクエストを送ると、
/hoge/hoge.jpg 由来のキャッシュは、
順次更新されるようになる。

最終的な構成はこんな感じだ。

構成3

あとは削除されたファイルは、
/var/spool/nginx/update
に残り続けることがあるので、
キャッシュの有効期間(例えば1日)以前のものは、
findコマンドなどで定期的に消してやれば良い。

運用の仕方によりそうだけど、
更新、削除されるファイルはそれ程多くないだろうから、
こちらの処理はそんなに重たくはならないだろう。

今のところ順調に稼働しているので、
これでしばらく画像のインフラに悩まず過ごせそうだ。