読者です 読者をやめる 読者になる 読者になる

ちょうど1年前にチームが崩壊した話

Adways Advent Calendar 5日目の記事です。

http://blog.engineer.adways.net/entry/advent_calendar/archive


どうも、大曲です。
とあるサービスのエンジニアをしています。
ちょうど1年の締めくくりとして、マネジメントの話を書こうかなと思います。

1年前にチームが崩壊した

1年前のこの時期に、立ち上げ時期からサービスを引っ張っていたエンジニアが退職することになりました。 退職したから、チームが崩壊したというよりすでに崩壊していた状態でした。

なぜ、崩壊していた状態なのか?

4歩進んで3歩下がる開発だった

4週間で大きな機能をリリースする。
3週間でその機能のバグ修正をする。

こんな状況が多発してました。
4週間でリリースするのは良いと思いますが 3週間ずっとリリースした機能のバグ修正をするという 品質担保の下手さが良く分かります。

最初の4週間で何を開発したの??と突っ込みどころ満載です。
ちなみにバグがない人は、この期間(リリース後の3週間)はめっちゃ暇になります。 (早く次の開発の話をしたいなぁ〜〜と思っていました。)

結合テストするたびに、必ず失敗する

フロントとバックエンドで開発の担当を分けることが多く 尚且つ、どんなデータを渡すか?どんなバリデーションを実装するか?は全て口頭でやり取りをするので 結合テストしようとすると、そもそも結合できないという意味が分からない状態でした。

Aさん「あれ、このデータってこのAPIで取得できますよね?」
Bさん「取得できないよ!」
Aさん「え!!( ̄ー ̄; ヒヤリ」
Bさん「え??( ̄ー ̄; ヒヤリ」
Cさん「ヤレヤレ ┐(´ー`)┌ マイッタネ」

上記の内容みたいな会話が何度も交わされました。
そして、結合テストのタイミングなので十分にコードを書く時間がないので 急ごしらえでコードを書くのでもちろんコードは見るに堪えない状態で さらに、JSONのAPIでキャメルケースとスネークケースが混ざったAPIが出来てしまうという なぜこうなったと叫びたくなるような状況でした。

崩壊した原因とは?

崩壊した原因は、2点あると考えています。

  • 事業の規模に対して、チームの開発スタイルがマッチしていなかった
  • エンジニア側の責任者を、ディレクターがやっていたこと

事業の規模に対して、チームの開発スタイルがマッチしていなかった

当時の開発スタイルは、各個人が好きなように開発するスタイルでした。
このスタイルでもうまく機能しているチームがいました。
これがなぜダメだったのか考えると二つのチームには違いがありました。 f:id:AdwaysEngineerBlog:20161207180020p:plain

このようにチームの開発の割り振りが、異なっていたので今回のサービスの場合だと
各個人が好きなように開発した機能は同じチームメンバーが開発した機能と結合しなければなりません。
そのため、結合テストで毎回失敗してしまう状況が多発しました。
また、サービス自体複雑になったことや単純に一人でサクッと開発できるシステム構成ではなくなったので 各個人が好きなように開発できなくなっていました。

エンジニア側の責任者を、ディレクターがやっていたこと

責任者がディレクター自体は、別にダメなことではありません。
ダメだったことが、エンジニア側のチームの方向性を決める人がいなかったことが原因でした。
エンジニア側の責任者ディレクターがサービスの品質を守るためにひたすらテストを頑張っていたり コードレビューは時間がかかるからとコードレビューをしないようにしたりと改善のやり方が間違っていました。
これにより、エンジニア側もディレクター側もお互い疲弊する状態になっていました。

f:id:AdwaysEngineerBlog:20161207143806p:plain

現在は、ディレクターなしのエンジニア側はエンジニアがリーダーをやっています。

f:id:AdwaysEngineerBlog:20161207143817p:plain

崩壊した状態からの改善

具体的な原因を書いておいて、具体的な解決策を書こうと思ったのですがやった内容が細かすぎて うまく書けませんでした。すみません。。。

そこで、チームを改善する上で自分なりにチームで大切にした点を書こうかなと思います。 大切にしたのは以下の3点です。

  • 振り返りに力を入れる
  • 改善を当たり前のように行う
  • 公平と不公平を明確にする

ちなみに、大枠でまとめるとチームの立て直しの流れは以下の通りです。

f:id:AdwaysEngineerBlog:20161207143825p:plain

振り返りに力を入れる

チームにとって、一番危険な状態は今のチームの状態把握ができないことだと考えました。
また自分にとって問題だけど、ある人にとって問題ではない場合がありチームにとっての何が問題なのかを明確にする必要がありました。
そのために振り返りを行うことによって、強制的に問題の認識と改善までの手段をチーム内で共有できるようにしたいと思いました。
そこでJIRAを使ったスクラム開発を行うようにしました。

スクラム開発の変更の流れ

  1. 1週間単位でのスプリントを実行
     (振り返り対象: すべての完了したタスク)
  2. 1週間単位でのスプリントで、タスクの見積もりを出して3~4日分のタスクを入れてスプリントを実行
     (振り返り対象: 完了したタスクで、見積もりの150%以上だったタスク)
  3. 2週間単位でのスプリントで、タスクの見積もりを出して6~8日分のタスクを入れてスプリントを実行
     (振り返り対象: バージョン※完了後、見積もりの150%以上だったタスク)
  4. 2週間単位でのスプリントで、タスクの見積もりとストーリーポイントを出して6~8日分のタスクを入れてスプリントを実行
     (振り返り対象: バージョン※完了後、総括して反省する)

約1年近くかけて、ようやく各メンバーの時間(見積もり)と質(ストーリーポイント)の関係が出せるようになりました。
自分たちの中で、やるメリットや意味を理解して少しずつ変更して行ったのであまり副作用的な内容は発生しませんでした。

スプリント閉じ会の会議は、隔週の金曜日で17時〜21時までお寿司を食べながらやっています。(長い時は17時〜23時までやったこともあります)
僕らはあまり会議を作らないチームにしているのでスプリント閉じ会で一気に相談事や共有事項などもやるようにしているため 1回の会議がかなり長いです。

f:id:AdwaysEngineerBlog:20161207143839p:plain

こうやってポイントをそのまま評価に加えると、裁量制みたいで面白そうですね。(大きな機能のリリースの時に一気にストーリーポイント消化が増えたりします!!)

また、半年に一度に大会議と称して1日かけての振り返りの会議をやっています。
ここで、各個人の振り返りや開発フローの大きな変更、サービスと改善のロードマップなどを作って 次の半年の方向性を決めています。 まだ、2回しかやっていませんが自分達のチームの方向性を見直せるので有意義な会議になっています。

※バージョンとは、JIRAのバージョンのことです。 僕らのサービスでは、バージョンごとで大きな開発を分けています。
https://ja.confluence.atlassian.com/servicedeskserver030/organizing-work-with-versions-761768983.html

改善を当たり前のように行う

僕らの考えとして、当たり前のように改善し続けるチームであってほしいと思っています。なので改善に対しての意識も、改善タスクを無意識に出来るようにするしたいとずっと考えてしました。(ここで言う改善タスクは、リファクタリング、チューニング、可視化、新しいツールの導入などです。)

改善をやろうとした時に決めたことが以下の通りです。

  • 改善に割り当てる時間はチーム全体から捻出する
  • 改善をやる人は、各メンバー全員に平等にやれるようにする
  • 改善はなるべく小さく続ける

改善に割り当てる時間はチーム全体から捻出する

改善に使う時間をチーム全体の作業時間の20%とすると

チームメンバーが3人の場合

8(1日の作業時間) * 5(1週間のうちの稼働日数) * 3(メンバーの数)

= 120 * 0.2 = 24

他の2人が通常の仕事をする代わりに、1人は5日ある内の3日間も改善タスクに割り当てられるのでやれる範囲も広がります。
今は、チームメンバーが5人になったので丸々1週間を運用と改善の週として既存の仕事を止めて運用と改善のタスクをこなしています。
(もちろん既存の仕事が遅れていたら、そちらをやります!!)

改善をやる人は、各メンバー全員に平等にやれるようにする

主観的な意見で言えばエンジニアのやる気を上げる仕事とやる気を下げる仕事が存在すると思っています。
改善のタスクは、やる気を上げる仕事(新しいツールなどの導入などがあるので)が多いので 一部のメンバーに偏らないように交代制にするようにしています。

改善はなるべく小さく続ける

改善するタスクの内容は、なるべく細かな粒度で出来るようにしています。
なるべく、だいたい3日以内に終わるタスクの粒度にしています。
(最低でも1週間以内のタスクに落とし込む)
Scalaの導入に関しても、少しずつやれる領域を増やしていきました。
(バッチ処理, デーモン処理, 管理画面のAPI, 配信の基盤 この順番で導入していきました。)

公平と不公平

チームのメンバー内で公平にすべきことと不公平にすべきことを明確に認識してマネジメントしました。

公平にすべきこと

  • 改善タスクの頻度
  • 作業的なタスクと障害対応

不公平にすべきこと

  • 仕事の成果に対する報酬

公平にすべきは、メンバーがやりたいことへのチャンス(改善のタスクの頻度)や誰でも出来る作業の負担(ビジネス側からの質問対応)だと考えています。そのため、こういった内容はチーム内で必ず交代制にしています。 障害対応なども、大半はメンバー全員が対応できるようにしています。

(広告配信が止まったなどのクリティカルな障害は全員で対応しますが 管理画面の挙動がおかしいなどの調査は、その週の障害対応担当の人が調査してくれます)

不公平にすべきは、仕事の成果に対する報酬だと考えており 仕事が進捗通りだから有給消化する動きがあって良いと思っています。

この1年を振り返って

CI導入、RDSへの移行、FinagleやAngular2の採用、Hubotの利用、配信関係の可視化...etc 色々やりました。よく1年間でやったなと思います。 うまくチームでの仕組み作りが成功したから、ここまで改善できたと思っていますしチームのメンバーにも恵まれたなと感じました。

また、1年前の状況と比べてチームへの周りからの評価がガラリ良い方向に変わったことは嬉しかったです。非常にありがたいことに「さすがだね」や「攻めてるね」という言葉もいただくようになりました。頑張ってよかったです。そして、来年も頑張ろうかなと思います。

最後に

マネジメントに関しての記事は、初めて書くのですが難しいですね。
次回書く時は、もう少しまとめられたらいいなと思います。


次は渡瀬さんの記事です。

http://blog.engineer.adways.net/entry/advent_calendar/06

トイレハック!!

Adways Advent Calendar 4日目の記事です。

http://blog.engineer.adways.net/entry/advent_calendar/archive


こんにちは。久保田です。

さて、突然ですが、我が社アドウェイズは大きな問題を抱えています。

それは、 「トイレ」 です。
人数に対して若干トイレが少ないのか、よく辛そうに並んでいる方々を見受けます。

この問題はただ心や体が辛いだけでなく、待ち時間があることで業務も滞ります。
チリも詰まれば、なので、最終的に会社にとって大きな損害であるわけです。

問題があったら解決するのがエンジニアの仕事。
というわけで、今回は試験的に僕が作った「トイレセンサー」と周辺技術のお話をご紹介します。

概要

今回この状況を改善するため、 「自席からでもトイレの空き状況がわかるシステム」 を作ることにしました。
そのため、こちらの記事を参考にさせていただき、トイレセンサーを作りました。

qiita.com

このセンサーを中心にシステムを作り上げて行きます。

以下のような設計になります。

f:id:AdwaysEngineerBlog:20161205175030p:plain

トイレのドアにセンサーを仕掛けておき、開閉状況を受信機であるラズベリーパイに送信し、ラズベリーパイは信号を受け取ったら保存しておきます。
そしてラズベリーパイをAPIサーバーとしても稼働させておき、SlackBotやwebページからトイレの状況を確認できるようにします。

トイレセンサー

上でも述べたように、

qiita.com

こちらの記事を参考にさせていただき、ほぼそのまま行いました。
道具などもほぼ同じものを使っています。
電子工作初心者の僕でもわかりやすく、非常に助かりました!

僕はTWE-Lite Rの設定をMacで行ったので、少しメモしておきます。

ファームウェアの書き込み

まず、SDKをインストールします。

http://mono-wireless.com/jp/products/TWE-NET/TWESDK.html

そして、こちらの公式のページを参考にファームウェアの書き込みを行いました。

http://mono-wireless.com/jp/tech/misc/jenprog/index.html

usbでtwe-liteとPCをつなぎ、デバイスの確認をします。

$ ls /dev/tty.usbserial*
#=> /dev/tty.usbserial-xxx

次に、SDKの中に入っているjenprogを使ってファームウェアを書き込みます。
書き込むファイルはこちらのページからインストールします。

http://mono-wireless.com/jp/products/Software_download/index.html

jenprog -t /dev/tty.usbserial-xxx App_TweLite\Master\Build\App_TweLite_Master_JN5164_1_7_1.bin

ハマったところは、書き込みがうまく行かず、何度か試したらうまくいったことです。
マイコンてそんなものかな。。と思い進みました。

TWE-Lite Rの設定

次はUSB経由でTEW-LiteをPCと接続し、設定を行います。

sudo cu -s 115200 -l /dev/tty.usbserial-***

+を三回押すと、こんな画面に切り替わるので、

f:id:AdwaysEngineerBlog:20161205175252p:plain

センサー側、受信機側の設定を行います。 ここの設定でデータを送信する間隔などが設定できます。

回路の作成

こちらも参考にさせていただき、真似させていただきました。 ドアが閉じた瞬間の検出にするように組んであります。

f:id:AdwaysEngineerBlog:20161205175501j:plain

受信側の設定

受信側に設定したTWE-Liteをラズベリーパイに接続します。
まずは、ちゃんとセンサーに変化があった時にデータが送られることを確認します。

ラズパイにcuを入れて
sudo apt-get install cu

出力します。

cu -s 115200 -l /dev/ttyUSB0

こんな感じで送られてきたら成功です。

f:id:AdwaysEngineerBlog:20161205175531j:plain

35桁目が0だったらセンサー同士が離れている(空いている)、1だったら近づいている(閉まっている)
という状況です。

これで動作確認ができたので、このデータを好きなプログラミング言語で処理するプログラムを作って、プロセスを動かしておけばOKです!
僕はRubyで処理しました。
今回はセンサーごとにファイルを作り、ファイルに状態を保存することにしました。

require 'serialport'

sp = SerialPort.new('/dev/ttyUSB0', 115200)

Process.deamon
loop do
  line = sp.gets
  if line.length > 5
    puts line
    File.open("/home/pi/ngx_mruby/build/nginx/mruby/#{line[1]}#{line[2]}", "w") do |f|
      f.puts("#{line[33]}#{line[34]}")
    end
  end
end

このプログラムを動かしておくと、センサーごとにファイルが作られ、
01のファイルに00
02のファイルに01
といった形式でデータが保存されます。
(状態が2連続で続くと一桁目が8になります。)

APIサーバーの構築

次はラズベリーパイをAPIサーバー化します。
ラズベリーパイにあまり負担をかけたくないので、比較的軽量で動かすため、Railsなどは使わず、
nginxにmrubyを組み込んだ、ngx_mruby(https://github.com/matsumotory/ngx_mruby)を使います。

少し話が横道にそれますが、ラズベリーパイでこちらのngx_mrubyがうまく動かない問題が発生しました。
僕の力ではどうにもならなかったので、プルリクを作らせていただいたところ、解決まで導いていただきました。
OSSってすごい、、と思いました。

https://github.com/matsumotory/ngx_mruby/issues/229

ngx_mrubyからmrubyを使い、保存したデータをjsonに直して、ホストするようにしました。

#files = ['01', '02', '03']
files = ['01'] #(※今はテスト段階なので、01のセンサーのみ)
base_path = '/home/pi/ngx_mruby/build/nginx/mruby/'
h = {}
files.each do |file|
  File.open(base_path + file) do |f|
    h[file] = f.read
  end
end
r = Nginx::Request.new
r.content_type = "application/json"
Nginx.echo h.to_json

こうして、nginx.confに上のプログラムが動くように設定し、リクエストを投げると、以下のようなレスポンスが返ってきます。

pi@raspberrypi:~/ngx_mruby/build/nginx $ curl 127.0.0.1 -i -s
HTTP/1.1 200 OK
Server: nginx/1.11.6
Date: Sun, 04 Dec 2016 16:58:08 GMT
Content-Type: application/json
Content-Length: 14
Connection: keep-alive

{"01":"80\n"}

これでAPIサーバーは完成です。

Botの作成

最後にクライアントである、Botを完成させます。
なんでも作りたがりの僕は、hubotなどは使わずに構築しました。

SlackのRTM API(https://api.slack.com/rtm)を使ってwebソケットで常に繋いでおくようにしたのですが、
意外と簡単に接続が切れてBotが落ちてしまうため、
lxcを使ってコンテナ化し、監視用のプログラムを作り、落ちたらコンテナごと再起動しまたbotを動かす、という風な設計にしました。

bot本体
bot.rb

require 'websocket-client-simple'
require 'open-uri'
require 'faraday'
require 'json'

class RestRoom
  module Util
    SDGRESTROOMS = (1..3)
    class << self
      def sdg?(no)
        SDGRESTROOMS.include?(no.to_i)
      end

      def available?(s)
        s.to_i == 0
      end
    end
  end

  def initialize(state)
    @state = JSON.parse(state)
  end

  def to_message
    "to_message"
    @state.map do |no, s|
      "room #{no}[#{Util.sdg?(no) ? 'SDG' : 'BDG'}] is #{Util.available?(s) ? 'available' : 'close now...'} "
    end.join("\n")
  end
end


pid = fork do
  conn = Faraday.new(:url => 'https://slack.com') do |faraday|
    faraday.request  :url_encoded             
    faraday.adapter  Faraday.default_adapter
  end

  res = conn.post "/api/rtm.start", :token => "token"

  url = JSON.parse(res.body).to_h['url']

  wss = WebSocket::Client::Simple.connect url
  id = 1

  wss.on :message do |msg|
    data = JSON.parse(msg.data)
    if data['text'] && (data['text'] == '<> tell me')  # どのような形式で話しかけてきたら答えるようにするかの条件
      type = {
        'id': id,
        "type": "typing",
        "channel": ch
      }.to_json

      wss.send type
      id += 1

      rr = nil
      open('ラズパイのエンドポイント') do |f|
        rr = RestRoom.new(f.read)
      end

      j = {
        "id": id,
        "type": "message",
        "channel": ch,
        "text": rr.to_message
      }.to_json

      wss.send j
    end
    id += 1
  end

  loop do
  end
end

監視用のプログラム
monitoring.rb

require 'faraday'
require 'json'

def bot_run
  `sudo lxc-stop -r -n slack-bot-poo-001`  #  lxcによるコンテナの再起動
end

token = "slackのtoken"
user  = 'botのユーザーID'

while true
  conn = Faraday.new(:url => 'https://slack.com') do |faraday|
    faraday.request  :url_encoded
    faraday.adapter  Faraday.default_adapter 
  end

  res = conn.post "/api/users.getPresence", {:token => token, :user => user} # 10秒に一度botの状態をチェックする。

  JSON.parse(res.body)['presence'] == 'active' ? true : bot_run

  sleep 10
end

lxcでコンテナを作り、bot.rbを動かし、
monitoring.rbをホストOSで動かして監視しておけばOKです!

f:id:AdwaysEngineerBlog:20161205180138p:plain

まとめ

さて、これで全ての用意が整いましたので、今週中にトイレに設置しテストを始めたいと思います。

これで社員の皆様のトイレの悩みがなくなり、皆様の業務が捗ることを期待しております。

ではまたどこかで。。。


次は大曲さんの記事です。

http://blog.engineer.adways.net/entry/advent_calendar/05

日本をハックする

  • Adways Advent Calendar 3日目の記事です。

http://blog.engineer.adways.net/entry/advent_calendar/archive


こんにちは、大野です。

皆さん去年、国が行った国勢調査を覚えていますか?
5年ごとに行なわれている、国勢調査ですが実はこのデータある程度公開されているのです。

政府統計の総合窓口 GL01010101
こちらのページなのですが、他にもたくさんの統計データが公開されています。
主要な統計から探す 政府統計の総合窓口 GL02100101

さてこの中で地図情報と、エリアごとの人口分布や労働力などのデータが取得できるところがあります。
今回はここをGoogleMapに表示して、エリアごとのデータが見れる物を作りたいと思います。

1.データダウンロード

地図で見る統計(統計GIS)

1.上記URLを開く
2.「平成22年国勢調査(小地域) 2010/10/01」を選択
3.「男女別人口総数及び世帯総数」を選択
4.「統計表各種データダウンロードへ」を押す
5.「都道府県」で対象の都道府県を選ぶ
6.「市区町村 (複数選択可)」を選択
7.「検索」ボタンを押す
8.「世界測地系緯度経度・Shape形式」 と「世界測地系緯度経度・GML形式」のzipファイルをダウンロード

まずはこれを全都道府県の全市町村分行うのですが、手作業では非常に大変です。

そこでAPI的なものがないか、くまなくページ内を探したところ
Service Web サービス
ありました。
都道府県取得->GetDownloadStep3CityListTag
統計データzipファイル先取得用->GetDownloadStep4ListTokeiTag
のようなので、まずはGetDownloadStep3CityListTagから行っていきます。 私は今はrubyしか書いてないですが、もともとPHPerなのでPHPで書いてみました。

<?php

function curl($p) {
    $POST_DATA = array(
        'censusId' => 'A002005212010',
        'chiikiName' => $p
    );
    $curl=curl_init("http://e-stat.go.jp/SG2/eStatGIS/Service.asmx/GetDownloadStep3CityListTag");
    curl_setopt($curl,CURLOPT_POST, TRUE);
    curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($POST_DATA));
    curl_setopt($curl,CURLOPT_COOKIEJAR,      'cookie');
    curl_setopt($curl,CURLOPT_COOKIEFILE,     'tmp');
    curl_setopt($curl,CURLOPT_FOLLOWLOCATION, TRUE);

    $output= curl_exec($curl);

    return $output;
}
$pref = array(
    '北海道',
    '青森県',
    '岩手県',
...
        '宮崎県',
    '鹿児島県',
    '沖縄県'
);
foreach ($pref as $p) {
    $array = json_decode(json_encode(simplexml_load_string(urldecode(htmlspecialchars_decode(curl($p))))),TRUE);
    foreach ($array["option"] as $value) {
        $d = explode(" ", $value);
        echo $d[0] .":".  $d[1]. "\n";
    }
}

こんな感じで取得したjsonをparseします。
<市町村コード:市町村名>のデータが出来上がるかと思います。

次にGetDownloadStep4ListTokeiTagからリンクを取得します。
onclickの中にdodownload関数が書かれているのですが、ここを取得します。

<?php

$stats = array('T000572');
function curl($p, $f, $s) {
        $POST_DATA = array(
                'censusId' => 'A002005212010',
                'statIds'  => $s,
                'cityIds'  => $p,
                'forHyou'  => $f
        );
        $curl=curl_init("http://e-stat.go.jp/SG2/eStatGIS/Service.asmx/GetDownloadStep4ListTokeiTag");
...
}

while(!feof(STDIN)) {
    if(feof(STDIN)) {
         break;
    }
    $datas = explode(":", trim(fgets(STDIN)));

    foreach($stats as $s) {
        $xml       = urldecode(htmlspecialchars_decode(curl($datas[0], 'true', $s)));
        $pattern = '/<a onclick="([^\"]+)" href="#">/';
        preg_match($pattern, $xml, $m);
        file_put_contents("./dodownload_${s}.txt", $datas[1]. "\t". $m[1]."\n", FILE_APPEND | LOCK_EX);
    }

    $xml       = urldecode(htmlspecialchars_decode(curl($datas[0], 'false', 'T000572')));
    $pattern = '/世界測地系緯度経度・GML形式<\/td><td class=\'tdw35p\'><a onclick="([^\"]+)" href="#">/';
    preg_match($pattern, $xml, $m);
    file_put_contents("./dodownload_GML.txt", $datas[1]. "\t". $m[1]."\n", FILE_APPEND | LOCK_EX);
}

これでdodownload_***とdodownload_GML.txtファイルができたかと思います。
dodownloadはzipファイルをダウンロードしているjavascriptのようなので
これをPHPで書き直してdownloadfileのAPIを実行してzipファイルをダウンロードします。

<?php

$stats = array('T000572');
function dodownload($pdf,$id,$type,$tcode,$acode,$ccode) {
    $POST_DATA = array(
        'state' => '',
        'pdf'  => $pdf,
        'id'  => $id,
        'cmd'  => "D001",
        'type'  => $type,
        'tcode'  => $tcode,
        'acode'  => $acode,
        'ccode'  => $ccode,
    );
    $curl=curl_init("http://e-stat.go.jp/SG2/eStatGIS/downloadfile.ashx");
...
}
if (!is_dir('./datas/zip')) mkdir('./datas/zip', 0755, TRUE);

foreach($stats as $s) {
    $fileData = file_get_contents("./dodownload_${s}.txt");
    $lines = explode("\n", $fileData);
    foreach($lines as $line) {
        $datas = explode("\t", $line);
        if (count($datas) < 2) continue;
    
        $str = "";
        $cmd = '$str = '.$datas[1];
        eval($cmd."\n");
        file_put_contents("./datas/zip/${s}_${datas[0]}.zip", $str);
    }
}

これでダウンロードは完了しました。

2.データ生成

地図といえばMongoなので、MongoDBを使います。 地図系のindexが張りやすいのが特徴だと思っています。 先ほどのzipファイルを解凍していきます。 unzipはたくさんサンプルででくるためソースは割愛します。

次に必要なデータだけを抽出します。 デフォルト値だとpcreがエラーを吐くのであげる必要があります。

ini_set("memory_limit","1024M");
ini_set("pcre.backtrack_limit", 30000000); // デフォルトは100000
ini_set("pcre.recursion_limit", 30000000); // デフォルトは100000

$statsTitle = array(':KEN', ':CITY', ':KEN_NAME', ':SITYO_NAME', ':GST_NAME', ':CSS_NAME', ':MOJI', ':X_CODE', ':Y_CODE', ':KEYCODE2',':posList');
function parse($html, $statsTitle) {
    $return = "";

    preg_match_all('/<([a-z0-9\-]+)(.*?)>((.*?)<\/\1\2>)?/s', $html, $m);
    $out = array();
    if (count($m[0]) != 0){
        for ($t=0; $t < count($m[0]); $t++){
            if ($m[2][$t] == ':featureMember') $return .= "\n";
            if (in_array($m[2][$t], $statsTitle)) $return .= trim($m[4][$t]).",";
            if ($m[1][$t] == 'gml' && ($m[2][$t] == ':featureMember' || $m[2][$t] == ':surfaceProperty' || $m[2][$t] == ':patches' || $m[2][$t] == ':PolygonPatch' || $m[2][$t] == ':exterior'  || $m[2][$t] == ':LinearRing')) $return .= parse($m[4][$t], $statsTitle);
        }
    }

    return $return;
}

if (!is_dir('./datas/gml')) mkdir('./datas/gml', 0755, TRUE);
$dir = opendir( './datas/zip/' );
while( $file_name = readdir( $dir ) ){
    if ($file_name == '.' || $file_name == '..') { continue; }

    $info = pathinfo('./datas/zip/'.$file_name);
    if ($info['extension'] != 'gml') { continue; }

    $html = file_get_contents('./datas/zip/'.$file_name);
    $o = parse($html, $statsTitle);

    $fp = fopen("./datas/gml/".$file_name, "w");
    fwrite($fp,"$o");
    fclose($fp);
}
closedir( dir );

MongoDBにデータを入れます。 PHP7からはcomposerを使うようです。

<?php
ini_set("memory_limit","2048M");
ini_set("pcre.backtrack_limit", 30000000); // デフォルトは100000
ini_set("pcre.recursion_limit", 30000000); // デフォルトは100000

require_once dirname(__FILE__) . '/vendor/autoload.php';
$mongo = new MongoDB\Client("mongodb://localhost:27017");
$db = $mongo->selectDatabase('geo');

if (!is_dir('./marge')) mkdir('./marge', 0755, TRUE);

$dir = opendir( './datas/gml/' );
while( $file_name = readdir( $dir ) ){
    if ($file_name == '.' || $file_name == '..') { continue; }

    //GMLData
    $gml = file_get_contents('./datas/gml/'.$file_name);
    $g = explode("\n", $gml);

    $fname = str_replace("h22ka", "", $file_name);
    $fname = str_replace(".gml", "", $fname);

    //T000572=男女別人口総数及び世帯総数
    $t000572 = file_get_contents('./datas/zip/tblT000572C'.$fname.'.txt');
    $t000572 = mb_convert_encoding($t000572, "UTF-8", "SJIS");
    $t000572_data = explode("\r\n", $t000572);

    //Marge
    foreach($g as $line) {
        $datas = array();

        $gml_datas = explode(",", $line);
        if (count($gml_datas) < 9) continue;

        $saveData = array();
        $saveData['KEN'] = $gml_datas[0];
        $saveData['CITY'] = $gml_datas[1];
        $saveData['KEN_NAME'] = $gml_datas[2];
        $saveData['SITYO_NAME'] = $gml_datas[3];
        $saveData['GST_NAME'] = $gml_datas[4];
        $saveData['CSS_NAME'] = $gml_datas[5];
        $saveData['MOJI'] = $gml_datas[7];
        $saveData['LAT'] = $gml_datas[9];
        $saveData['LNG'] = $gml_datas[8];

        $pos  = explode(" ", $gml_datas[10]);
        $num = 0;
        $lat    = null;
        $lng   = null;
        $coordinatesArray = array();
        foreach($pos as $p) {
            $num++;
            if (($num % 2) == 1) {
                //緯度
                $lat = $p;
            } else {
                //経度
                $lng = $p;
            }

            if ($lat != null && $lng != null) {
                $coordinate = array(floatval($lng), floatval($lat));
                array_push($coordinatesArray, $coordinate);
                $lat = null;
                $lng = null;
            }
        }
        $saveData['GEO'] = array(
            'type' => 'Polygon',
            'coordinates' => array($coordinatesArray),
        );
        $key = $gml_datas[0].$gml_datas[6];
        $datas['t572'] = getData($t000572_data, $key);
        $saveData['ZINKOUSOUSUU'] = $datas['t572'][0];
        $saveData['ZINKOUOTOKO'] = $datas['t572'][1];
        $saveData['ZINKOUONNA'] = $datas['t572'][2];
        $saveData['ZINKOUSETAI'] = $datas['t572'][3];

        $coll = $db->selectCollection('Area');
        $coll->insertOne( $saveData );
    }
    $count++;
}

MongoDBのIndexを作成します。 今回はgeoテーブルのAreaコレクションを作成したので、そこのGEOにはります。

# mongo
> use geo
> db.Area.createIndex( { GEO:"2dsphere" } )

3.GoogleMapに表示する

ここは何も考えずにGoogleMapのAPIを叩くだけになります。

require_once dirname(__FILE__) . '/vendor/autoload.php';

$lat = $_GET['lat'] == null? 35.69609: $_GET['lat'];
$lng = $_GET['lng'] == null? 139.69039: $_GET['lng'];

$label = '';
$label = '_id,県CODE,市CODE,県名,市町村名,GST名,CSS名,住所,緯度,経度,人口総数,男,女,世帯総数';
$labelArray = explode(',', $label);

$mongo = new MongoDB\Client("mongodb://localhost:27017");
$db = $mongo->selectDatabase('geo');
$coll = $db->selectCollection("Area");

$query = array('GEO'=>array('$geoIntersects'=>array('$geometry'=>array('type'=>'Point', 'coordinates'=>array(floatval($lng), floatval($lat))) )));
$docs = $coll->find($query);

$geodata = '';
$renderdata = '';
foreach ($docs as $id => $obj) {
    $dataArray = array();
    foreach ($obj as $k=>$v) {
        if ($k == "GEO") {
            if (array_key_exists('coordinates', $v)) {
                foreach($v['coordinates'] as $geoArray) {
                    foreach($geoArray as $gk=>$gv) {
                        $geodata .= " new google.maps.LatLng(".$gv[1].", ".$gv[0]."),\n";
                    }
                }
            }
        } else {
            $dataArray[] = $v;
        }
    }
    foreach ($dataArray as $k=>$data) {
        $renderdata .= $labelArray[$k].":".$data."</br>\n";
    }
}
if (strlen($geodata) >= 2) {
    $geodata = substr($geodata, 0, -2);
}
render($lat, $lng, $renderdata, $geodata);

function render($lat, $lng, $data, $geo) {
    echo <<<HTML
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> 
<html> 
<head> 
    <title>国勢調査 Map</title> 
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />
    <script src="http://www.google.com/jsapi"></script>
    <script>google.load("jquery", "1");</script>
    <script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script> 
    <script type="text/javascript"> 
        var polygonObj = null; 
        var mapObj = null;
        var markerObj = null;
        google.maps.event.addDomListener(window, 'load', function() 
        {
            var lng = ${lng};
            var lat = ${lat};

            var mapOptions = { 
                zoom: 15,
                center: new google.maps.LatLng(lat, lng), 
                mapTypeId: google.maps.MapTypeId.ROADMAP, 
                scaleControl: true 
            }; 
            mapObj = new google.maps.Map(document.getElementById('gmap'), mapOptions); 
 
            // 作成するポリゴン外枠座標の配列 
            var points = [${geo}];
            // ポリゴンのオプションを設定 
            var polygonOptions = { 
                path: points, 
                strokeWeight: 5, 
                strokeColor: "#0000ff", 
                strokeOpacity: 0.5, 
                fillColor: "#008000", 
                fillOpacity: 0.5 
            }; 
 
            // ポリゴンを設定 
            polygonObj = new google.maps.Polygon(polygonOptions); 
            polygonObj.setMap(mapObj);
            
            //マーカー
            var latlng = new google.maps.LatLng(lat, lng);
            markerObj = new google.maps.Marker({ 
                position: latlng, 
                map: mapObj 
            }); 
 
            // マップクリックイベントを追加 
            google.maps.event.addListener(mapObj, 'click', function(e) 
            { 
                //削除処理
                polygonObj.setMap(null);
                markerObj.setMap(null);
                
                // ポジションを変更 
                markerObj.position = e.latLng; 
 
                // マーカーをセット 
                markerObj.setMap(mapObj); 

                //reload
                window.location.href = "/polygon.php?lat="+e.latLng.lat()+"&lng="+e.latLng.lng();
            });

        }); 
    </script> 
</head> 
<body> 
    <div id="gmap" style="width: 99%; height: 370px; border: 1px solid Gray;"> 
    </div>
    <div id="datas">${data}</div>
</body>
</html>
HTML;
}

Webページを開くと良い感じに表示されるかと思います。
デフォルトの緯度経度はアドウェイズ本社がある場所を指定してみました。 f:id:AdwaysEngineerBlog:20161205184023p:plain

今回は取得しませんでしたが、年齢別や年収別など様々な分類ごとの統計が取得できるので見ているだけで楽しいですね!


次は久保田くんの記事です!

http://blog.engineer.adways.net/entry/advent_calendar/04

KotlinとDataBindingのその後

Adways Advent Calendar 2日目の記事です。

http://blog.engineer.adways.net/entry/advent_calendar/archive


こんにちは、梅津です。

みなさん、Kotlin触ってますか?
私は個人的にやるプロジェクトでは必ずKotlinで始めるようにしたり、会社のプロジェクトでも一部分で導入してみたりと、毎日少しはKotlinに触れるような日々を送っております。

さて、私は以前 KotlinでDataBindingを使ってみたらハマったこと という記事を書いたのですが、最近ではこの辺の状況が変わってきました。
今回はKotlinとDataBindingを取り巻く環境がどんなふうに変わったのか紹介したいと思います。

1. カスタムセッターは拡張関数で

以前の記事ではカスタムセッターを作るためにobject宣言と@JvmStaticというアノテーションを使って実現しました。
その後、Kotlinスタートブックを読んで知ったのですが、カスタムセッターを作るなら拡張関数を使う方が良さそうです。

@BindingAdapter("imageUrl")
fun ImageView.loadImage(url: String) {
    Glide.with(context).load(url).into(this)
}

拡張関数を使ったコードをコンパイルすると、レシーバ(この場合はImageView)を第一引数に取るstaticなメソッドが作られます。
結果的にobject宣言 + @JvmStaticでやっていた時と同じようなコードが生成されるため、Java側から問題なく呼び出すことができます。
こっちの方がKotlinっぽいし、何よりJavaからどうやって呼ばれるのか?ということを意識しなくて良くなりました!

2. kaptがいい感じになった

Kotlin 1.0.4が出た時にkaptも改善されました。
以前まで設定していた generateStubs は削除して、代わりに下記のコードを追加しましょう。

apply plugin: 'kotlin-kapt'

最終的なapp/build.gradleは以下のようになります。
(最近はext.kotlin_versionをルートのbuild.gradleに書くこともあると思います。その場合は適宜読み替えてください)

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
+ apply plugin: 'kotlin-kapt'

android {
    ...

    dataBinding {
        enabled = true
    }
}

dependencies {
    kapt 'com.android.databinding:compiler:2.2.2'
}

buildscript {
    ext.kotlin_version = '1.0.5-2'  // 1.0.x系で今のところの最新

    ...
}

- kapt {
-    generateStubs = true
- }

基本的にKotlinでAnnotation Processorを使う場合はkotlin-kaptをapplyすれば良くなります。
これでおまじないのように書いていたgenerateStubsが消えて直感的になりました!

3. コードが真っ赤になってたアレが解消された

KotlinとDataBindingで開発している時に何が辛かったかというと、生成されたBindingクラスのメソッドやプロパティが見つからないと言われてコード中に赤線が出まくることでした。
IDEではエラーになっているのに実行することはできる、という中々カオスな状況でしたね。
しばらくすると慣れてきて「あー。いつもの模様か」となってくるんですが、できたらそんな赤線は見ないで開発したいわけです。
それがついにKotlin 1.0.5で解消されました!さらば赤線!
これでストレスなく開発していくことができそうです。

まとめ

以前までは自分自身の知識不足だったりKotlinのツール側がイマイチだったりで、「開発はできるけど、もうちょっと気持ち良く書けるようにならないかなー?」といった部分がありました。
しかし、今となってはそれらの部分は解消されています。
KotlinでDataBindingをするなら今が一番始めやすいのではないでしょうか。

何よりKotlinサイコーなので使う人がもっと増えると嬉しいです。

それでは、また。


次は大野さんの記事です!

http://blog.engineer.adways.net/entry/advent_calendar/03

Golangでchannelを使いたい

Adways Advent Calendar 1日目の記事です。

http://blog.engineer.adways.net/entry/advent_calendar/archive


Adwaysアドベントカレンダー1日目を担当します、安藤です。
業務では主にrubyを使っていますが、今回はchannelを使いたかったのでgoにしました。(笑)

今回学習として簡素なチャットを作ってみました。
channelを使った箇所は、

  • 入退室管理
  • メッセージ送信

です。

モデル

今回は一つのroomで複数のクライアントが接続するのを想定してます。(最大でどれほどのクライアントが作れるかは試してません。)

package main                                                                                                                                                                         

import (
    "github.com/gorilla/websocket"
    "log"
    "net/http"
)

// クライアント
type client struct {
    socket *websocket.Conn   // WebSocketのコネクション
    send   chan []byte       // メッセージをブラウザに送信するchannel
    room   *room             // クライアントが属するチャットルーム
}

// チャットルーム
type room struct {
    forward chan []byte      // 他のすべてのクライアントに送信するメッセージを持つ
    join    chan *client     // 入室するクライアント
    leave   chan *client     // 退室するクライアント
    clients map[*client]bool // 入室中のすべてのクライアント
}

// チャットルームの初期化処理
func newRoom() *room {
    return &room{
        forward: make(chan []byte),
        join:    make(chan *client),
        leave:   make(chan *client),
        clients: make(map[*client]bool),
    }   
}
  • client
      * send ・・・・ ここにはブラウザに送信するメッセージをキューのように保持します。

  • room
      * join, leave, clients ・・・・ clientsをそのままいじるのではなく、追加・削除もchannelを通して行います。

ブラウザへの送受信部分

  • read
      * ブラウザからのメーセージをWebSocket経由で受け取り、foward channelに渡しています。
    つまり、自分が書いたメッセージを他のユーザーに伝えるために、メッセージを蓄えさせているところ。

  • write
      * send channelに届いたメッセージをブラウザに送信しています。

func (c *client) read() {
    for {
        if _, msg, err := c.socket.ReadMessage(); err == nil {
            c.room.forward <- msg
        } else {
            break
        }
    }
    c.socket.Close()
}

func (c *client) write() {
    for msg := range c.send {
        if err := c.socket.WriteMessage(websocket.TextMessage, msg); err != nil {
            break
        }
    }
    c.socket.Close()
}

並行処理

runメソッドは無限ループさせており、強制終了するまでjoin, leave, forawardを監視します。
どれかのchannelにメッセージが届くと、そのcase文が実行されるしくみです。

func (r *room) run() {
    for {
        select {
        case client := <-r.join:
            //入室
            r.clients[client] = true
        case client := <-r.leave:
            delete(r.clients, client)
            close(client.send)
        case msg := <-r.forward:
            // すべてのクライアント(ユーザー)にメッセージを送信する
            for client := range r.clients {
                select {
                case client.send <- msg:
                    // 送信
                default:
                    // 失敗
                    delete(r.clients, client)
                    close(client.send)
                }
            }
        }
    }
}

select caseを使うと同時に実行される危険がないので、clientsへの変更が同時に発生することがないです。

defaultでは、send channelに送信できなかった場合、クライアントの退室処理とsend channelをcloseしてます。

ちなみに、closeしたchannelへの送信はランタイムパニックを発生させます。
(closeしたchannelにはアクセスしないようにしましょう。)

その他

roomをHTTPハンドラにし、ブラウザにアクセス時クライアントを生成、
client.read()をメインスレッドで行うことでコネクションを保持し、クライアントの終了時、退室処理をするように追加しました。

const (
    socketBufferSize  = 1024
    messageBufferSize = 256
)

// WebSocketを利用するなら必要
var upgrader = &websocket.Upgrader{ReadBufferSize: socketBufferSize, WriteBufferSize: socketBufferSize}

func (r *room) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    socket, err := upgrader.Upgrade(w, req, nil)
    if err != nil {
        log.Fatal("ServeHTTP:", err)
        return
    }
    client := &client{
        socket: socket,
        send:   make(chan []byte, messageBufferSize), // バッファサイズを設定
        room:   r,
    }
    r.join <- client
    defer func() { r.leave <- client }() // この関数が終了する時に呼ばれる
    go client.write()                    // 別のスレッドで書き出す
    client.read()                        // メインスレッドで読み込み
}


ここまできたらあとは、main.goとviewを作成し、
roomの初期化処理、ルーティングを設定すると以下のようにチャットが可能になります。

f:id:AdwaysEngineerBlog:20161201151852p:plain

所感

channelの使い所がいまいち分からないと思うことが、多々ありますが、
今回chatを作ってみて、goの恩恵を実感しました。

また、今回はシンプルなchatにしましたが、しっかりやるならユーザーがroomを作れたり、
チャット内容を一定期間保持したりといったことをした方がいいかなと思いました。

ちなみに、今回Go言語によるWebアプリケーション開発を参考にさせて頂きました。

f:id:AdwaysEngineerBlog:20161201123923j:plain


次は梅津さんの記事です!

http://blog.engineer.adways.net/entry/advent_calendar/02