Raccoon Tech Blog [株式会社ラクーン 技術戦略部ブログ]

株式会社ラクーン 技術戦略部より、tipsやノウハウなど技術的な話題を発信いたします。

Zipファイルを紙とペンで解凍してみた

こんにちは。ほそかわです。

社内勉強会用に、埋めていくだけでzipファイルの解凍ができるプリントを作りました。

普段、zipファイルの解凍はコンピュータにやってもらっていますが、
この記事を読みながらご自身の手を使って紙とペンでやってみましょう。

アルゴリズムとデータ構造を生で感じるチャンスです。

準備

これから使うプリントをダウンロードして印刷しましょう。

「ZIPローカルファイルヘッダ」、
「ZIPセントラルディレクトリファイルヘッダ」、
「ZIPセントラルディレクトリ終端レコード」

記入してZIPの解凍を進めていくプリントです。

「ZIPファイルデータ」
この記事で解凍を行うzipファイルのバイナリデータです。
左側に書いてあるのは16進数で表されたバイナリデータです。主にこちらを使います。
16進数で書かれている行名と列名を足すと何byte目のアドレスにあるデータかが分かります。
右側に書いてあるのは文字列データが簡単に調べられるASCIIで表されたバイナリデータです。

「16進数表」
計算の手間を省くためのものです。

全て印刷して筆記用具も用意できたら準備はOKです。

今回用意したZIPファイルデータは、データ圧縮が行われていない特殊なzipファイルになってます。
圧縮を行わないzipファイルに意味なんてないと感じるかもしれませんが、圧縮に関する仕様は独立しているのでこの記事を読んだ後に別に勉強できます。

無圧縮のzipファイルはzipコマンドでも作れます。

zip -0 file.zip file.txt

プロトコルのバージョンによって違いがあるかもしれないのでこの記事ではこちらで用意したファイルを使います。

概要

Zipファイルの中にはどんなデータが入っているのか見てみましょう。

ファイル全体の概要を図にしました。

zip_overview_multi_file
Zipファイルはヘッダとファイルエントリ、終端レコードで構成されています。

ヘッダには2種類あります。

  • ZIPローカルファイルヘッダ
  • ZIPセントラルディレクトリファイルヘッダ

ZIPセントラルディレクトリファイルヘッダは、ZIPファイルの最後にあるZIPセントラルディレクトリという領域に入っています。そして、ZIPセントラルディレクトリの一番最後にアーカイブ全体についての情報が書いてある終端レコードが付いていてます。

ヘッダにはzipファイルに格納したファイルについてファイル名などの情報が書いてあり、ファイルエントリには格納したファイルの中身が入っています。

図のzipファイルにはA,Bの2つのファイルが格納されています。2種類のヘッダとファイルエントリがファイル数だけ繰り返されています。

ネタバレになってしまいますが繰り返しがあると手間が増えるばかりなので、今回使うzipファイルに格納されているファイルは一つです。

バイナリデータ

Zipファイルは普段扱っているテキストデータよりもコンピュータ向けなバイナリデータとして保存されています。

バイナリデータを扱う為に、16進数、2進数の変換方法とエンディアンについて知っておきましょう。

基礎的な知識なのでWikipediaをみてください。

2進法

16進数

エンディアン

エンディアンは様々なことが書いてありますが、zipファイルを扱うのにはリトルエンディアンとビックエンディアンのデータが読めるようになっていれば十分です。

リトルエンディアンでは1byteをかたまりとして並び順を逆にします。

AB CD EF 12

であれば

12 EF CD AB

となります。

例えば、16進数で1B58という数値をリトルエンディアンで表すと

58 1B

となります。

ビックエンディアンは並び順を変えないもので、

AB CD EF 12

であれば、そのまま

AB CD EF 12

となります。

ZIPセントラルディレクトリ終端レコード

これから紙とペンを使ってzipを解凍していきます。

まずは、アーカイブ全体の情報を得るために、セントラルディレクトリの終端レコードに記録してある情報を調べましょう。

使うプリントは、「ZIPセントラルディレクトリ終端レコード」です。

概要で説明したように、終端レコードはzipファイルの最後の領域にあります。

ZIPの仕様として終端レコードの頭には、

50 4B 05 06

というシグネチャをつけることになっています。

終端レコードはファイルの末尾にあるので末尾から開始地点を表すシグネチャを探すことで、終端レコードの領域を知ることができます。
zipファイルデータの末尾からシグネチャを探しましょう。

アドレス0x5Cに見つかりました。

そこからセントラルディレクトリヘッダの終端レコードが始まってます。
次のアドレスから順番にプリントに書き込んでいくと終端レコードの解凍を進められます。

四角い枠一つが1byteに対応してます。

入力欄解説
中には1byteのデータを書き込みます。

シグネチャは例としてリトルエンディアンで埋めてあります。

次の手順で全て埋めてみてください。

  1.  16進数で読み、数値データはリトルエンディアンで、(文字列)と書いてある文字列のデータはビックエンディアンで四角い枠の欄に記入する。

  2.  数値データは、16進数のデータを10進数に直して下線が引いてある欄に記入する。文字列のデータは1byteごとにASCII文字に変換してある右に表の同じ位置の文字を探して記入する。

(厳密には、文字列のデータもリトルエンディアンになっています。並び替えを行わないビックエンディアンとして扱えている理由は、1文字の1byte単位で保存が行われているからです。リトルエンディアンは1byteをかたまりとして並び替えるものなので、1byteのデータでは並び替えが行われず、リトルエンディアンになる並び替えが行われてないように見えています)

最初なので答えを載せます。

ZIPセントラルディレクトリ終端レコード_答え

全ての記入が終わったら細かく見て行きます。

セントラルディレクトリレコードの合計数

セントラルディレクトリレコードの数はファイルの数と同じになっています。
このzipファイルに含まれるファイルは一つだけだと分かります。

セントラルディレクトリの開始位置のオフセット、セントラルディレクトリのサイズについて

セントラルディレクトリの開始位置のオフセットからは、ファイルの先頭から見てどのアドレスでセントラルディレクトリが始まるのか、セントラルディレクトリのサイズからはどこまで続いているのかが分かります。
セントラルディレクトリは、先頭から39byte目のアドレス0x27で始まって53byte後のアドレス0x5Bで終わっているということがわかります。

Zipファイルコメントと長さについて

このzipファイルにはコメントがないため長さには0が入っています。
コメントは可変長のデータなのでここで長さを定義してそのバイト数だけコメントとして扱うという作りになってます。

その他の項目については、今では使われない機能が多いので割愛します。
興味があったら調べてみてください。

ZIPセントラルディレクトリファイルヘッダ

次は、セントラルディレクトリファイルヘッダです。
使うプリントは、「ZIPセントラルディレクトリファイルヘッダ」です。

終端レコードの情報から、セントラルディレクトリはアドレス0x27で始まっているとわかりました。
セントラルディレクトリファイルヘッダのシグネチャは、

50 4B 01 02

です。

探してみると、終端レコードで書いてあったようにアドレス0x27にあります。

終端レコードの時と同じようにシグネチャの次のバイトからプリントを埋めていきます。

zipでは、日付、時刻のデータがバイト単位で分けられてません。
2byteの領域に3つのデータが入っているので、
一度、2byteを繋げた2進数に変換してから区切り直して、10進数に変換します。

次の手順で行います。

  1. リトルエンディアンで2byte分を2進数に変換して一桁ずつ下線の上に書く
  2. 縦線で区切られているところで分けて2進数から10進数変換して、次の行に書き込む
  3. 秒は2倍、年は1980を足す

このやり方も初めてなので答えを載せておきます。

日時入力欄解説

これでファイルの最終変更日時がわかります。

これも全ての記入が終わったら細かく見ていきます。

圧縮メソッド
ファイルエントリがどのプロトコルで格納されたかが書いてあります。
今回は無圧縮なので0が入っていますが8のDeflateが一般的です。

Wikipedia - Deflate

ファイルの最終変更時刻、最終変更日付
格納したファイルの最終変更日時です。
年は1980の下駄を履かせるので1980年からが使えます。
そして、なんと、秒はビットが足らず2秒刻みで丸められているので2倍します。

zipで圧縮を行ったファイルは最終変更日時の秒が保障されないことをご存知だったでしょうか。いつも使っているものでも意外な発見があるものなんですね。

CRC-32
格納前のファイルエントリで計算したCRC-32が入っています。

圧縮サイズ
格納後のサイズです。ファイルエントリのサイズが書いてあります。

非圧縮サイズ
格納前のファイルサイズです。

ファイル名の長さ、ファイル名
Zipファイルコメントと同じようにまず長さが定義されていて、その長さ分のファイル名データがあります。

ローカルファイルヘッダの相対オフセット
ファイル一個につき、セントラルディレクトリファイルヘッダとローカルファイルヘッダが一つずつあります。

対応するローカルファイルヘッダのアドレスが書いてあります。
最初のファイルのローカルファイルヘッダのアドレスなので0が入っています。

数が多いので他は割愛します。
調べてみてください。

ZIPローカルファイルヘッダとファイルエントリ

ローカルファイルヘッダはZIPセントラルディレクトリファイルヘッダと同じやり方で埋めていけます。
ローカルファイルヘッダの次のアドレスからファイルサイズのバイト数分だけがファイルエントリと呼ばれるファイルの中身のデータになってます。

圧縮が行われている場合には、ヘッダから得た圧縮プロトコル情報をもとにファイルエントリのデコードを行います。今回は無圧縮なので飛ばせます。

ファイルエントリをこれまでヘッダから得た情報をもとにファイルとして保存すればこのファイルは解凍されます。

今回は、

  • ファイルの最終変更日は「2016年1月26日 13時27分36秒」
  • ファイル名は「raccoon」
  • ファイルの中身は「CA FE」

というファイルを作り、ファイルエントリをそのまま中に書き込めばこのzipファイルの解凍は成功です。

「CA FE」とは16進数で使われるA~Fまでの文字という制約で考えた単語「Cafe」です。

最後に

お疲れ様でした!

Zipファイルが身近に感じられるようになったのではないでしょうか。

この記事は最短ルートでzipファイル概要を理解し解凍を体験できることを目標に書いたので多くの仕様を省略しています。
次のステップとして、圧縮アルゴリズムを勉強すると圧縮が行われている普通のzipファイルも解凍できるようになります。他にもパスワード付きや分割など多くの仕様があります。そして、反対のことを行えば圧縮もできます。

ZIPファイルフォーマットは様々なところで使われています。ZIPの知識を生かすことで仕事の幅が広がるかもしれません。このプリントを使って勉強会を開催するのも面白いでしょう。
これからの活躍につながるといいですね。

参考

wikipedia - ZIP

PK-Ware - APPNOTE

[E2Eテスト]メンテナンス自動化~パスワードの定期変更に対応する~

こんにちは。
技術戦略部のやすだです。

以前弊社のCI/CDへの取組みについての記事がありましたが、
今回の記事はその中で触れられていた「Selenium」を利用したE2Eテストのシナリオメンテナンスについてです。

経緯 

 弊社ではSeleniumを使ったE2Eテストを行っています。
 その中で、ログイン後の機能について確認するシナリオがあり、ログインに利用するテスト用アカウントのパスワードをシナリオに直接記述していました。

 しかし、弊社ではセキュリティの観点から、社内で利用している全てのテストアカウント用のパスワードをバッチで定期的に自動変更しています。
 
 シナリオに記述されているパスワードについては、定期変更がされる度に人力で変更していたため、シナリオ側のパスワードを変更しそびれると、ログインできずにシナリオの実行に失敗することになってしまいます。 
 というわけで、今回の要望です。
 

要望

 パスワードが変更されたタイミングでSeleniumシナリオに記述しているパスワードも自動で変更したい!
 

概要

 まずはざっくりとパスワード更新バッチとSeleniumのシナリオについての図です。
スライド2

 DBに保存されるパスワードはハッシュ化したものですので、Seleniumのシナリオで利用するパスワードは生成した直後の平文である必要があります。
 そのため、下記の2点の対応を行います。
  1.[パスワード更新バッチ]ハッシュ化前のパスワードをファイルに出力する
   Seleniumのシナリオでログイン後ページを確認するためだけのアカウントなので、何か特別なことができるわけではありませんが、 平文のパスワードを保存するのでサーバーのアクセスコントロールは適切に設定する必要があります。
  2.[テストシナリオ]ファイルからパスワードを取得する

 上記の対応を行うことで、このようになります。 
指摘対応中_技術ブログネタ

詳細内容

 ということで詳細内容です。
 1.[パスワード更新バッチ]ハッシュ化前のパスワードをファイルに出力する
   生成した直後のハッシュ化前の平文パスワードをファイルに出力します。
   Seleinumのシナリオ内では、データファイルとしてHTMLファイルが扱いやすくなっています。
   文字列をHTMLで出力するだけで特別なことは行っていないため、内容については割愛しちゃいましょう。
   ファイルは下記のような形にしています。
password.html
<html>
  <head></head>
    <body>
       <div id="password">1234</div>
    </body>
</html>

 2.[テストシナリオ]ファイルからパスワードを取得する
   Seleniumのシナリオ内ではjavascriptを記述することができます。
   さらに、storeEvalを使うことで値を保持できるため、異なるページで取得した値を取り扱うことが可能です。
   シナリオ内でのjavascriptの実行については、下記の要領でタグ内に記述することで実行することができます。
<td>
 javascript{"'" + this.page().getCurrentWindow().document.getElementById("password").textContent.toString() + "'";}
</td>

  実際にパスワードを取得する際の記述は下記のようになります。
  • パスワードファイルを開く
<tr>
    <td>open</td>
    <td>社内サーバーに配置したパスワードファイルのpath</td>
    <td></td>
</tr>
  • パスワードを取得し、storeする
<tr>
<td>storeEval</td>
    <td>javascript{"'" +this.page().getCurrentWindow().document.getElementById("password").textContent.toString() + "'";}</td>
    <td>get_pass</td>
</tr>
  • storeしたパスワードを他のページで使う
    storeEvalで格納した値は他のページに遷移した後も保持されます。
    そのため、異なるページでも下記の要領で使いまわすことが可能です。
<tr>
    <td>open</td>
    <td>http://www.superdelivery.com/</td>
    <td></td>
</tr>
<tr>
    <td>type</td>
    <td>name=password</td>
    <td>${get_pass}</td>
</tr>


まとめ

  E2Eテストを自動化する最大のメリットはリグレッションテストのコストや障害の発生を抑えられることだと思っています。
 リグレッションテストのコストや障害の発生を抑えることは、システム変更を容易にすることにつながります。
 システム変更が容易であれば、より良いサービスをスピーディーにお届けすることができます。
 ですが、E2Eテストを自動化する場合、どうしてもシナリオのメンテナンスコストが発生してしまうのが悩みどころとなります。
 今回のようにメンテナンス自体もなるべく自動化することで、E2Eテストのメンテナンスコストという悩みを解決し、最終的により良いサービスをスピーディーにお届けできればというのが個人的な思いです。

CORECを支える技術 ~ログ監視編~ fluentd-AmazonSNS連携

こんにちは。なべです。
CORECの開発を担当しています。
CORECを支える技術 ~チケット駆動編~』に続き、『CORECを支える技術』シリーズの第2弾としてCORECのログ監視について書きたいと思います。

旧ログ監視の問題と対策

当初、CORECのログ監視はログ集約にくっつける形で始まりました。
ログ集約はログファイルを一定時間ごとにバックアップする仕組みで、各サーバーのログをfluentdで集め、1時間ごとに圧縮しS3に格納しています。
ログ監視は、その集約されたログを社内の監視システムで取得し、エラーログがあった場合にメールとIRCで通知するというものです。

この仕組には大きく以下の2つの問題がありました。

  • タイムラグ
    ログの監視をできるタイミングがS3に圧縮ファイルがアップされた後なので、検知するまで1時間のタイムラグが発生する可能性がある。
  • サービスレベル
    ログ監視を社内のサーバーに配置していて、消防点検や停電または、サーバーメンテナンス時に監視できないなどサービスレベルにも問題がある。

これらの問題に対応するためfluentdに集めたタイミングでエラーログを抽出し、AmazonSNS経由でメール通知することにしました。
これにより、社内のサーバーも経由しなくなり、タイムラグの問題・サービスレベルの問題を同時に解消できました。

CORECログ監視概要図

実施内容

この対応で実施したことは以下の4つです

  1. fluentdにプラグイン追加
  2. AmazonSNSに通知用Topic作成
  3. メールBody整形用のERB追加
  4. fluentdの設定ファイル記述

1. fluentdにプラグイン追加

以下の2つのプラグインを追加しました。

fluent-plugin-sns
Amazon SNS通知用
fluent-plugin-rewrite-tag-filter
エラーのフィルタ・レベルごとの分岐処理用
fluentdはrubyで動いているのでRubyistならお馴染みのgemコマンドでインストールできます。
この時fluentdが使っているrubyの環境を指定する必要があります。
特にfluentdにバンドルされたrubyを使用している場合は注意してください。
sudo /usr/lib/fluent/ruby/bin/gem install fluent-plugin-sns
sudo /usr/lib/fluent/ruby/bin/gem install fluent-plugin-rewrite-tag-filter

2. AmazonSNSに通知用Topic作成

Amazonの管理コンソールからSNSサービスを選択し、画面に従ってTopic及びSubscritionを作成します。 特に迷うことはないかと思いますが、注意するポイントは以下の2点です。

  • メールアドレスを登録するとメールがくるのでクリックして認証すること
  • 作成したTopicの権限設定を確認する
    権限設定とは作成したTopicに対して、通知に利用するAWSアカウントがこのトピックに投稿する権限のことです。もし権限がない場合には、[ポリシーの編集]から許可を与えてください。

3. メールBody整形用のERB追加

CORECの場合、fluentdが受け取るレコードは改行が\nにエスケープされていて読みづらいため、テンプレートで改行コードに変換しました。
以下のコードをerbファイルとして保存しておきます。
これだけのコードですが受け取った側の負担がだいぶ違います。特にスタックトレースで威力を発揮します。

<%record.each do |k,v|%>
<%="#{k}:#{v.to_s.gsub(/\\n/,"\n")}" %>
<%end%>

4. fluentdの設定ファイル記述

それでは最後にメインとなるfluentdの設定ファイルの編集をします。
変更/追加のポイントは以下の3点です。

4-a. copyプラグインで処理を2系統に分岐する
4-b. rewrite_tag_filterプラグインでエラーレベルごとにフィルタリングして、新しいタグを設定する
4-c. snsプラグインで通知するためのmatchディレクティブを追加する

それぞれ簡単に説明します。

4-a. copyプラグインで処理を2系統に分岐する

fluentdは最初にマッチしたmatchディレクティブだけを実行しますので、同じレコードに対して複数の処理をしたい場合にはcopyプラグインを使用します。
※copyプラグインは標準のプラグインなのでインストールをする必要はありません。

matchディレクティブと同等の内容をstoreタグに書くことができ、同じレコードに対して処理を複数記述できるようになります。
今回の対応では従来のS3への送信する処理をcopyで分岐し、SNSに送信する処理を追加します。

<match tag.log>
  type copy
  <store>
        従来の処理
  </store>
  <store>
        追加したい処理
  </store>
</match>

4-b. rewrite_tag_filterプラグインでエラーレベルごとにフィルタリングして、新しいタグを設定する

ただ単に前項の「追加したい処理」にSNS送信のディレクティブの内容を書いてしまうと、アプリケーションが出力したログすべて(エラーでないものも含めて)通知されてしまいます。
そこでログの内容によって処理を振り分けるにはrewrite_tag_filterプラグインを使用します。
レコードのキーを指定して、検索する文字列または正規表現を指定し、それにマッチした場合に新しいタグを指定する仕組みです。
今回はログレベルがFATALもしくはERRORの場合にシステム通知がしたかったので、ログレベルが格納されているキー(lebel)に対して、FATAL・ERRORそれぞれルールを作成しました。
複数のルールを設定するにはrewriteruleの後の数字をインクリメントして記述します。

  <store>
    type rewrite_tag_filter
    rewriterule1 level FATAL corec.rails.sns_fatal
    rewriterule2 level ERROR corec.rails.sns_error
  </store>

4-c. snsプラグインで通知するためのmatchディレクティブを追加する

typeにsnsを指定して、内容にはAWSユーザーにはお馴染みのパラメータ群を設定していきます。 sns_body_templateのパスはtd-agentが参照可能な場所にERBを配置して指定してください。

<match corec.rails.sns_fatal>
  type sns
  sns_topic_name for_system_notice_topic
  aws_key_id xxxxxxxxxx
  aws_sec_key xxxxxxxxxx
  sns_endpoint sns.ap-northeast-1.amazonaws.com
  sns_subject [COREC ALERT]corec/rails
  sns_body_template /etc/td-agent/erb/sns_body_template.erb
</match>

fluentd設定ファイルイメージ(全体)

変更前後の設定ファイルは以下のとおりです。
行はだいぶ増えていますが、それぞれの設定内容に難しいところはありませんでした。

  • 設定ファイル:修正前
<match corec.rails>
    S3に送信する設定(省略)
</match>
  • 設定ファイル:修正後
<match corec.rails>
  type copy
  <store>
        従来のS3に送信する設定(省略)
  </store>
  <store>
    type rewrite_tag_filter
    rewriterule1 level FATAL corec.rails.sns_fatal
    rewriterule2 level ERROR corec.rails.sns_error
  </store>
</match>
<match corec.rails.sns_fatal>
  type sns
  sns_topic_name for_system_notice_topic
  aws_key_id xxxxxxxxxx
  aws_sec_key xxxxxxxxxx
  sns_endpoint sns.ap-northeast-1.amazonaws.com
  sns_subject [COREC ALERT]corec/rails
  sns_body_template /etc/td-agent/erb/sns_body_template.erb
</match>
<match corec.rails.sns_error>
 (省略)
</match>

設定ファイル修正後、fluentdを再起動すれば反映されます。
※fluentdの連携をしている場合には送信元のfluentdも再起動することをおすすめします。
 私が設定した時には送信側が受信側を見失ってログの送信が止まってしまいました。

新たな問題

前項までの対応により、旧ログ監視の問題は解消し、 リアルタイムで、かつサービスレベルの高い仕組みができました。
が、新たな問題がいくつか発生しました。

  1. フィルタリングが無くなったのでERROR・FATALのレベルのログが全部飛んでくる
  2. 異常が発生するとガンガンメールがくる
  3. IRCに通知されない

新たな問題 1. ERROR・FATALのレベルのログが全部飛んでくる
→ 対策: 随時チケット化してエラーログの根本原因を潰す!

実はログを検索してメールを飛ばす処理の中でホワイトリストでフィルタリングして、通知するものを絞っていました。
その仕組みが無くなったのでERROR・FATALのレベルのログが全部飛んでくるようになってしまいました。 fluentdでフィルタリングするという選択肢もありましたが、対応の必要のないFATALやERRORを出すシステム側を修正すべきということで、チケット化してエラーの根本対応を実施。

新たな問題 2. 異常が発生するとガンガンメールがくる
→ 対策: 覚悟しておく!

従来のログ監視は1時間に1回ログを検索してメールを飛ばす処理だったため過去1時間に何件エラーが発生していても1通の通知にまとまっていました。
それがリアルタイムでエラー1件毎に通知されるため、異常事態にはメールが大量に送信されてしまいます。
実際に外部ベンダー側にトラブルがあり、復旧までに4時間かかった際に断続的に300通送信されてきました。orz
しかし、異常が発生しているのだから通知はあるべきでそれを止めるのは本末転倒ということで受け入れることにしました。

新たな問題 3. IRCに通知されない
→ 対策:社内で導入を進めている新監視システムへ

メールと同時にIRCに通知して、業務時間中はメールよりも気づきやすいように対策していましたが、IRCは社内サーバーで稼働しているため利用できませんでした。
現状は通知のために旧監視システムと並行稼動をしていますが、実際にはリアルタイムに飛んでくるメールに十分に気づけています。
今後は社内で進めているSlackのサービスを利用した障害対応フローに移行する予定です。
AmazonSNSに通知先を増やすだけで対応できるはずです。

まとめ

  • fluentdにプラグインを追加することで簡単にAmazonSNS経由の通知を実現できました。
    こういったプラグインが充実しているのもfluentdの魅力だと思います。(fluentdの導入については『fluentd(td-agent) の導入』を参照してください。)

  • ログ監視も即時性・対障害性の高いシステムを構築して確実で迅速な検知が必要です!
    ログ監視は最後の砦であり将来発生する障害の芽を発見するアンテナだと思っています。これからもCORECとともにCORECを支える運用システムもブラッシュアップしていきます。

  • 無駄なログは技術負債。根本から除去しましょう!
    無駄なログがたくさん通知されてくると、本来拾わなければいけないログを見落としてしまう可能性があります。そのために通知するログを絞ることは大切ですが、安易にフィルタリングをするのではなく、一つ一つ原因を突き止めて対応すべきところはプログラムで対応し、技術負債を残さないようにしたいですね。

第4回技術部LT大会 開催

こんにちは、たむらです。

先日第4回技術部LT大会を開催しました。もう定着している感のあるLT大会ですが、
今回もみんなしっかり準備をしていて、内容も非常にバラエティに富んだものになっていました。

~発表されたLTタイトル~
「軽い気持ちでリモデ」
「SSL証明書やドメインなどの期限管理」
「パソコン少年の成れの果ての懐古(または、劣化移植への限りなき愛情)」
「キャッシュフロー計算書を作ってみた」
「URIHOの社内ツールのモック作成でLESS使ってみた感想」
「コレックウォッチ ~ログ監視のはなし~」
「YeomanでAngularの開発環境を構築して何か作ってみた」
「今年やりたいこと」
「個人事業主や中小企業の主に小の方に売掛保証を広めたいと思った結果」
「キーワードで振り返る2015年IoTトピックス」
「PayPal APIのネガティブテストを作ってみた」
「新監視体制について」
「Seasar2から卒業しよう」

LT大会の様子
注) 窓に貼ってある習字は会社の年始イベントの書初めです。こちらも毎年恒例となりつつあります(笑)。。
DSC_0943
DSC_0951
DSC_0953


みんなで選ぶ優秀発表者は、
1位:「今年やりたいこと」
2位:「キーワードで振り返る2015年IoTトピックス」
3位:「Seasar2から卒業しよう」
となりました。

1位の内容は、年始ということもあり自分の今年の抱負を語るというものでした。仕事に繋がることもプライベートなことも盛り沢山の話で、みんなの支持を集めていました。2位は、Raspbery Pi2からmyThings、SORACOMなどの2015年のIoTを自身の感想を含めて振り返るというもの。本当に多くのサービスと商品が増えてきたIoTですが2016年も更に賑やかになりそうですね。3位は弊社でも利用していて今年の秋にサポートが終了するSeasar2についての見解を述べたものでした。業務上の課題をみんなに共有できていて有意義な内容となっていました。

次回はまた夏から秋ぐらいに行なう予定です。

[Rails]Model/テーブル設計で必ず覚えておきたいSTI

開発チームの下田です。

Ruby on Railsについて、基本的なこと(modelやcontrollerが何か知っている程度)を理解されている方を対象としています。

BtoBクラウド受注・発注システムCORECはRailsで開発しています。 開発初期のコードを見直してみると失敗したなと思うことがあります。 中でも特に開発効率に影響しているなと思うのが、RailsのActiveRecordのSTI(Single Table Inheritance、単一テーブル継承)を活用しなかったことです。

STIを簡単に言うと、モデルを永続化するときに、どのクラスなのかというメタ情報を含めてデータベースに保存します。 これだけではわかりづらいと思うので、失敗した例と改善例からご紹介したいと思います。

悪い設計

仮に下記の仕様のコードを書くとします。

要件1:
 CORECではWEBアプリから発注書を送信できます。送信方法は次の3つから選べます。

  • WEBアプリ内で送信する
  • メールで送信する
  • FAXで送信する

この時、次のようなコードにしていました。
(項目名、メソッド名は意味が通じるように和名にしています)

# 注文書model 
class Order < ActiveRecord::Base
  include FAX送信モジュール
  validates :送信方法, :presence => true
 
  # WEBで送信するなら送信先IDが必須 
  validates :送信先ユーザ_id, :presence => true, if: ->{ 送信方法 == 'WEB' }
 
  # メールならメールアドレスが必須 
  validates :メールアドレス, :presence => true, if: ->{ 送信方法 == 'メール' }
 
  # FAXならFAX番号が必須 
  validates :FAX番号, :presence => true, if: ->{ 送信方法 == 'FAX' }
 
  has_many :FAX送信結果
 
  def 送信する
    self.送信済 = true
    validate!
    case 送信方法
    when 'メール'
      注文Mailer.メール注文書送信.deliver
    when 'FAX'
      FAX送信する
    end
    save
  end
 
  private
 
  def FAX送信する
    FAX送信結果.create(送信結果: FAX送信モジュール.送信)
  end
end


何が問題か

当初は送信するときだから、送信手段ごとに処理をcase-whenで振り分けることに違和感はありませんでした。しかし、開発を進めていると、同じコードがあちこちに現れて読みづらいコードになり始めました。

要件2:

  • FAXは送信に失敗することがあるので、結果判定をする
  • WEBとメールは必ず成功したとみなす
class Order < ActiveRecord::Base
  # ~~ 略 ~~ 
 
  def 送信成功?
    # メソッドごとに送信方法による分岐がある状態 
    case 送信方法
    when 'FAX'
      FAX送信結果.送信成功?
    default
      true
    end
  end
end


あるべき姿

FAXもメールもすべて注文書の情報なのでOrderクラスにしたことは正しいのですが、送信方法によってふるまいが違います。役割が同じでふるまいが違うなら、サブクラスを作りポリモーフィズムを持たせるべきです。

sti


class Order < ActiveRecord::Base
  def 送信する
    self.送信済 = true
    save
  end
 
  def 送信成功?
    false
  end
end
 
# WEBで送信する注文書クラス
class Order::Web < Order
  validates :送信先ユーザ_id, :presence => true
 
  def 送信する
    validate!
    super
  end
end
 
# メールで送信する注文書クラス
class Order::Mail < Order
  validates :メールアドレス, :presence => true
 
  def 送信する
    validate!
    注文Mailer.メール注文書送信.deliver
    super
  end
end
 
# FAXで送信する注文書クラス 
class Order::Fax < Order
  validates :FAX番号, :presence => true
 
  has_many :FAX送信結果
 
  def 送信する
    validate!
    FAX送信する
    super
  end
 
  def 送信成功?
    FAX送信結果.送信成功?
  end
 
  private
 
  def FAX送信する
    FAX送信結果.create(送信結果: FAX送信モジュール.送信)
  end
end

メソッドの中から冗長なcase文が消えます。また、relationやvalidationのふるまいも送信方法ごとに整理され、見通しが良くなりました。

このようなクラス設計にしたい場合はSTIを使用します。STIを使用するにはtype:stringカラムを追加します。

  create_table "orders", force: true do |t|
    t.string   "type" # 追加するとSTIになる 
    t.integer  "送信先ユーザ_id"
    t.string   "メールアドレス"
    t.string   "FAX番号"
    t.datetime "created_at"
    t.datetime "updated_at"
  end

typeカラムがある場合とない場合を比較してみましょう。

# typeカラムがない場合 
order = Order::Mail.new(メールアドレス: 'hoge@example.com')
order.save
# id: 1 
# メールアドレス: "hoge@example.com" 
 
# OrderからロードするとOrderになる 
Order.find(order.id)
=> #<Order id: 1, メールアドレス: "hoge@example.com"> 
 
# Order::MailからロードすればOK 
Order::Mail.find(order.id)
=> #<Order::Mail id: 1, メールアドレス: "hoge@example.com"> 
# typeカラムがあり、STIになっている場合 
order = Order::Mail.new(メールアドレス: 'hoge@example.com')
order.save
# type: 'Order::Mail' # クラス名がtypeに自動的にsaveされる 
# id: 1 
# メールアドレス: "hoge@example.com" 
# created_at: 2015-01-01 12-34-56 
# updated_at: 2015-01-01 12-34-56 
 
# Orderからロードしても、きちんとOrder::Mailになる 
Order.find(order.id)
=> #<Order::Mail id: 1, メールアドレス: "hoge@example.com"> 
 
# サブクラスからロードすると、typeを検索条件に自動で加えてくれる 
Order::Mail.all.to_sql
=> "select * from orders where type = 'Order::Mail'"

データベースに保存し永続化した後でもふるまいを維持し続けてくれるので、クラス設計を適切に行えば複雑な仕様が追加されても、良い状態のコードを保てると思います。

運用上の注意

開発には非常に便利なSTIですが、運用を考えて開発しなければならないことが1点あります。typeカラムに存在しないclass名が設定されていると、ActiveRecord::SubclassNotFound例外が発生します。 ActiveRecord::SubclassNotFound例外が発生するのは、2パターンがあります。

  • リリース時に並行稼動している時に、新しいAPで保存した新しいサブクラスを古いAPでロードした
  • リリースしたがロールバックし、新しいAPで保存した新しいサブクラスを古いAPでロードした<

どちらの場合も、何も変更しないサブクラスを作り、先行してリリースしておくと、予期せぬ例外が起きません。フェイルソフトになります。

# 仮置きする。 
class Order::NewClass < Order
end

もしくはデフォルトスコープに現バージョンで存在しているclassのみを指定すると、ロードされないので例外が発生しません。サブクラスを追加した時に追加し忘れたり、where句が思わぬところで効いたりしないか確認が必要です。

class Order < ActiveRecord::Base
  # 条件に加えてしまう。
  default_scope ->{ where(type: [Order, Order::Corec, Order::Mail, Order::Fax]) }
  # ~~ 略 ~~ 
end

おわりに

STI以外にも、ああすればよかったと思うことは多くあります。その中でもSTIについて書いたのは、知っているか知らないかでModelの設計に差が出てしまうため、気づいた時には手軽にはリファクタリングできなくなっているからです。その反面、ある程度の規模のアプリケーションでなければ使い道が無くRuby on Rails チュートリアルに載っていなかったりと軽視されがちです。 新しくmodelを設計するときには頭の片隅に置いておくといいと思います。

記事検索