Raspberry PI に Zabbix Server 導入まで
概要
Raspberry pi に Zabbix Server を追加した経験談。
自身の失敗をメモしておくとともに、自身の失敗が誰かの救いになれば良い。
・・知識がないとこれほど大変だとは思わなかった。
事の発端
過去に購入したRaspberry Pi 。
- 出版社/メーカー: Raspberry Pi
- メディア: エレクトロニクス
- この商品を含むブログ (10件) を見る
監視カメラを作っている記事を見つけ、実践するつもりだったが、ただ動画キャプチャするだけだと
何か異常が起こっても分からないし、動画全部見るなんて結構面倒くさい。
そこで、状況に応じて、色んな制御を内部で組み込みたいと思っていた。
そして、Zabbix サーバというかなり高機能な運用監視サーバを導入しようと思いたち開始。
立ち上げまでの経緯
MySQL が動かない
MySQL のインストールがうまくいっていない。どうやら MySQL サーバが起動できないらしい。
すでにパッケージはインストールされているが、どうやら起動に毎回失敗している。
mysql のログは /var/log/syslog に出るらしいので、見てみると。
Feb 14 11:40:35 www mysqld: 160214 11:40:35 [ERROR] Can't start server: Bind on TCP/IP port: Cannot assign requested address Feb 14 11:40:35 www mysqld: 160214 11:40:35 [ERROR] Do you already have another mysqld server running on port: 3306 ? Feb 14 11:40:35 www mysqld: 160214 11:40:35 [ERROR] Aborting
とにかく接続できないらしい。netstat -t -a -n で調べるが、ポートは問題ない。
どうやら、curl http://127.0.0.1/ や ping 127.0.0.1 で失敗することから、127.0.0.1 が認識できていないようだ。
とりあえず /etc/mysql/my.cnf にある、bind-address を 127.0.0.1 → 192.168.0.xx に変えてみたら動いた。
確認してみると、過去に SSH の設定したときに、ネットワーク設定を間違ったようだ。
/etc/network/interfaces を確認すると、ループバックアドレス設定が間違っていた。
auto l0 iface lo inet loopback
なぜ今まで気づかなかったのか・・。bind-address も 127.0.0.1 に直して動くことを確認。
apt-get の Zabbix Server のバージョンが古い
無事 zabbix のインストールができたのだが、apt-get で導入した zabbix-server のバージョンが 1.8 。
あまりに古すぎるため、apt-get のミラーサイト変えてみたりして、色々試してみたもののどうしようもなかった。
調べてみると、ソースコードをビルドする方法で、最新バージョンを導入できそうなので (動作するか分からないけど)
今回はとりあえず、apt-get の推奨する動作環境で確認して、後で入れ替えて見ることに。
しかし、Zabbix の初期設定画面がうまくいかなかった。なんか mysql への接続に失敗しているみたい。
理由が分からなかっため、1週間以上放置してしまう。
Raspberry pi だからこそ起こる問題発覚
再開しようと思ってたおり、調査していると現状のままでは色々まずいことを web で発見する。
Zabbix サーバは監視データを逐一記録するため、数多くデータを書き込むが、
SD カード(Raspberry pi の記憶媒体)の寿命は書き換え回数ので数千回くらいなんだとか。
SD書き換え回数制限について - その他(ハードウェア) [解決済 - 2015/03/14] | 教えて!goo
ってことは、1分に1回データ書き込んでいたら、書き込み回数が一日 60 * 24 = 1460回 だから、一週間持たないということになる。
ってことで、データは外付け HDD に格納しなければならないと思い購入。
Raspberry Pi のコンパクトさを維持するため、なるべくかさばらないものを選択。
また、対象機種は Windows だけでなく Mac OS X も含まれていると Linux でも動作は問題ないだろうと判断。
容量としては500GB あれば十分と思い、メーカーと値段を考慮して以下に決定。
東芝 ポータブルハードディスク CANVIO 500GB ブラック HD-TH305JK3AA-D
- 出版社/メーカー: 東芝
- メディア: エレクトロニクス
- この商品を含むブログを見る
HDD を認識しない
注文から3日後、HDD が届く。Raspberry pi の USB に HDD を差し込み、起動。
どうやら最初から NTFS でフォーマットされているらしく、フォーマットとかパーティション切らなくていいらしい。
Linux コマンドの分からない自分にとっては調べる手間が省けて嬉しい。
・・・しかし。df コマンド叩くも、50GB くらいの ディスクが出てこない。
マウントしてないから? → fdisk -l コマンドを叩く。→ そもそも外付け HDD の情報がない。
おまけに HDD からカチッカチッと音がする。よくわからないが Raspberry pi は問題なく動作しているように見える。
HDD の初期不良かと思って、 Windows PC に差してみたが動作する。仕方がないので調査。
Raspberry pi 2が外付けHDDを自動認識してくれなかった - Qiita
同じ症状が出てる人結構いるみたいです。
/etc/fstab down't work with RPI2 · Issue #824 · raspberrypi/linux
- raspi2では起動プロセスが複数コアで並列して行われてる
- /etc/fstabのパースがHDD認識と同時またはそれ以前に行われてしまう
- /etc/fstabを読んでる段階ではUUIDとHDDが紐ついていないので、そんなHDDないよってことですっとばされる
各HDDのUUIDが判明するまでマウント作業を待ってもらえばいい。
/boot/cmdline.txtにrootdelay=5を追記。
上記を試してみるも、変化なし。
調べてみると電源が原因の可能性が浮上。私が使っている電源は 1.8A 出力するので、問題ないと思っていたが、
Windows PC に差したときにはカチッカチッという音はしなかったため、この可能性が高いと思い電源ハブを購入。
- 出版社/メーカー: バッファロー
- 発売日: 2009/02/09
- メディア: Personal Computers
- 購入: 3人 クリック: 22回
- この商品を含むブログ (5件) を見る
NTFS じゃだめだった?
電源ハブが届き、apt-get から ntfs-3g パッケージをインストールし、NTFS も読めるようにした。
すると、HDD を認識した。どうやら原因は電源だったようだ。ファイルの一覧も表示できており、動作は問題なさそうだ。
Zabbix サーバでは、監視データなどは基本的に、データベース内に作成するということで、mysql が本来ある場所に
シンボリックリンクを張って、mysql 自体を HDD に移動。
kakakikikekeのブログ: 【CentOS】zabbixのmysqlデータの保存先を変更する方法
service mysqld stop cd /var/lib mv mysql [HDD パス] ln -s [HDD パス]/mysql mysql service mysqld start
すると。【Can't connect to local MySQL server through socket '/var/lib/mysqld/mysqld.sock'】が出た。
そして、調べてみると、mysqlのファイル/フォルダの所有者が mysql ではなく pi になっている。
なので、chown を使って mysql に変更しようとするが、できない。
エラーが出ていないのに、実行しても所有者が変わらない?・・権限?
念のため、chmod ・・・が、アクセス権自体が変更できない。なんだこれは?
どうやら、ntfs では、マウント後でアクセス権変更したりとかできないらしい。
chmod で変更できません。 - linux 初心者ですlinux mint 14 を... - Yahoo!知恵袋
なので、ディスクを NTFS から ext4 に変更。参考にしたのは以下。
Raspberry Piでファイルサーバ、Part2 外付けハードディスクの導入編 | ものづくりエクスペリメント
UUID 調べて、etc/fstab に設定。
Zabbix も最新バージョンに移行
古いバージョンの zabbix を念のため、全て削除。
Zabbix の本家から最新ソースコードをダウンロードして、適用する。
下記の記事は、Zabbix 2.4.7 環境についてだったが、2.4.7 でも同様の手順で立ち上げられた。
端的にやることとコマンドだけ書かれているため、動かすことを目的としている私にはとても助かった。
raspberry2B/zabbix2.4.4
しかし、動かすことを優先しすぎて設定内容がよくわかってないため、これから設定内容は見直してみようと思う。
SSHJ による暗号化通信
概要
様々な処理をサーバに任せ、その処理結果だけをサーバから取得したい場合がある。
このような通信を行う際、FTP や TELNET を用いて行うと
通信が暗号化されないため、その内容を第三者に盗聴されるリスクを生んでしまう。
ここでは、SSH による暗号化通信を Java で実装する方法について記述する。
また、SSH 用のライブラリは多数存在するが、今回は SSHJ(version 0.15.0) を用いる
実装内容
今回は「サーバと簡単な通信が行える」を前提として、次の機能を提供するクラスを作成。
- 認証 (パスワード/公開鍵(OpenSSHキーファイル)認証)
- リモート先でのコマンド実行
- SCP/SFTP を用いたファイルダウンロード
- SCP/SFTP を用いたファイルアップロード
SCP と SFTP は場合によって使い分ける可能性があると思い、両方使えるようにした。
SCP と SFTP の違いはこちらが参考になる。セキュリティプロトコルマスター(8):軽快なscpか高機能なsftp、sshサーバに向いているのは? (1/2) - @IT
今回は、 SSH 用のライブラリを今後取り替える可能性も考慮して、ロジックにこれらを記述せず
Facade クラス(Facade - GoF デザインパターン - Status Code 303 - See Other) を作成、これらを利用する。
詳細コード
ビルドパス設定
SSHJ を使用する場合に必要なライブラリ
暗号化ライブラリ
圧縮ライブラリ (ダウンロード/アップロードで必要)
- jzlib-1.1.3.jar
ログ出力ライブラリ
- sshd-core-1.0.0.jar
ボイラープレートコード排除
- lombok.jar (Lombok ライブラリ - Status Code 303 - See Other)
クラス
SSHJ のFacade クラス (SSHClientFacade.java)
package communicate.ssh; import java.io.File; import java.io.IOException; import java.util.concurrent.TimeUnit; import net.schmizz.sshj.SSHClient; import net.schmizz.sshj.common.IOUtils; import net.schmizz.sshj.connection.channel.direct.Session; import net.schmizz.sshj.connection.channel.direct.Session.Command; import net.schmizz.sshj.transport.TransportException; import net.schmizz.sshj.transport.verification.PromiscuousVerifier; import net.schmizz.sshj.userauth.UserAuthException; import net.schmizz.sshj.userauth.keyprovider.FileKeyProvider; import net.schmizz.sshj.userauth.keyprovider.OpenSSHKeyFile; import net.schmizz.sshj.xfer.FileSystemFile; public class SSHClientFacade implements AutoCloseable { /** * SSH Client */ SSHClient ssh = new SSHClient(); /** * Connect server. * * @param host * @param port * @throws IOException */ public void connect(String host, int port) throws IOException { ssh.useCompression(); // need jzlib ssh.addHostKeyVerifier(new PromiscuousVerifier()); ssh.connect(host, port); } /** * Add user information with key file. * * @param user * @param keyFile * @throws TransportException * @throws UserAuthException */ public void authPublickey(String user, File keyFile) throws UserAuthException, TransportException { FileKeyProvider provider = new OpenSSHKeyFile(); provider.init(keyFile); ssh.authPublickey(user, provider); } /** * Add user information with password. * * @param user * @param password * @throws IOException */ public void authPassword(String user, String password) throws UserAuthException, TransportException { ssh.authPassword(user, password); } /** * Execute Command. * * @param command * @return SSH Result * @throws IOException */ public SSHResult execute(String command) throws IOException { try (final Session session = ssh.startSession()) { Command cmd = session.exec(command); cmd.join(30, TimeUnit.SECONDS); String output = IOUtils.readFully(cmd.getInputStream()).toString(); Integer status = cmd.getExitStatus(); if (status == 0) { return new SSHResult(status, output, ""); } return new SSHResult(status, output, IOUtils.readFully(cmd.getErrorStream()).toString()); } } /** * Download file. * * @param src * Source file path (remote) * @param dest * Destination directory or file path (local) * @param method * SCP or SFTP * @throws IOException */ public void download(String src, File dest, TransferMethod method) throws IOException { method.download(ssh, src, dest); } /** * Upload file. * * @param src * Source file path (local) * @param dest * Destination directory or file path (remote) * @param method * @throws IOException */ public void upload(File src, String dest, TransferMethod method) throws IOException { method.upload(ssh, src, dest); } /** * Tear down. * * @throws IOException */ @Override public void close() throws IOException { try { ssh.close(); } finally { ssh.disconnect(); } } /** * Transfer method strategy. * */ public static enum TransferMethod { SCP { @Override void download(SSHClient ssh, String src, File dest) throws IOException { ssh.newSCPFileTransfer().download(src, new FileSystemFile(dest)); } @Override void upload(SSHClient ssh, File src, String dest) throws IOException { ssh.newSCPFileTransfer().upload(new FileSystemFile(src), dest); } }, SFTP { @Override void download(SSHClient ssh, String src, File dest) throws IOException { ssh.newSFTPClient().get(src, new FileSystemFile(dest)); } @Override void upload(SSHClient ssh, File src, String dest) throws IOException { ssh.newSFTPClient().put(new FileSystemFile(src), dest); } }; /** * Download File. * * @param ssh * SSHClient * @param src * Source file(remote) * @param dest * Destination directory or file path(local) * @throws IOException */ abstract void download(SSHClient ssh, String src, File dest) throws IOException; /** * Upload File. * * @param ssh * SSHClient * @param src * Source file(local) * @param dest * Destination directory or file path(remote) * @throws IOException */ abstract void upload(SSHClient ssh, File src, String dest) throws IOException; } }
SSHClient から返る結果を格納するクラス (SSHResult.java)
package communicate.ssh; import lombok.Value; @Value public class SSHResult { /** * Exit status of command. */ public int status; /** * Output. */ public String output; /** * Error. */ public String error; }
設定ファイル (ssh.properties)
# Connection Settings ssh.host = [必須, IPアドレス、ホスト名どちらでも可] ssh.port = [必須] # User Authentication ssh.user = [必須] ssh.keyfile = [キーファイルのパスを指定、今回はOpenSSH形式のキーファイル対応] ssh.password = [パスワード、公開鍵認証なら空でも良い]
使い方
- ssh.properties を変更する
- ロジックを記述する
(Property クラス → プロパティファイルの使用方法 - Status Code 303 - See Other)
package communicate.ssh; import java.io.File; import java.io.IOException; import communicate.ssh.SSHClientFacade.TransferMethod; import resources.properties.Property; public class Main { public static void main(String[] args) throws IOException { Property property = new Property("ssh"); try (SSHClientFacade ssh = new SSHClientFacade()) { // 接続 ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port")); // 認証 ssh.authPublickey(property.getString("ssh.user"), new File(property.getString("ssh.keyfile"))); // ssh.authPassword(property.getString("ssh.user"), property.getString("ssh.password")); // コマンド実行 SSHResult result = ssh.execute("ls -al > test.txt"); System.out.println(result); // ファイルのダウンロード ssh.download("/home/pi/test.txt", new File("./sample"), TransferMethod.SCP); // SCP の場合 // ssh.download("~/test.txt", new File("./"), TransferMethod.SFTP); //SFTP の場合 // ファイルのアップロード ssh.upload(new File("./sample"), "/home/pi/", TransferMethod.SCP); // SCP の場合 // ssh.upload(new File("./test.txt"), "/home/pi/", TransferMethod.SFTP); // STFP の場合 } } }
注意点
リモート先のパスは、なるべくフルパスで記述した方が良い。不思議なことに、ヘテムルでは
download メソッドの引数ではホームディレクトリ(~/xxx/yyy) を解釈するが、upload 時は認識できないようで、
下記の例外が発生した。なお、フルパスに変更すると動作した。
Exception in thread "main" net.schmizz.sshj.xfer.scp.SCPRemoteException: Remote SCP command had error: scp: ~/xxx/yyy: No such file or directory
接続方法を様々に変更する拡張
今回は単純化のため、サーバへの接続方法が同じであると仮定して実装した。
しかし、様々なサーバに接続する際に、その接続設定が異なることもあるだろう。
これらは、SSHClient の設定を変更することで実現できる。
接続方法の追加
まず SSHClientFacade クラスに以下を追加する。
// アクセス方法に関する Strategy public static interface AccessStrategy { public abstract void execute(SSHClient ssh) throws IOException; } // connect メソッドを追加する public void connect(String host, int port, AccessStrategy method) throws IOException { method.execute(ssh); ssh.connect(host, port); }
そして、ロジックでは次のように使う想定。
ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port"), new AccessStrategy(){ @Override public void execute(SSHClient ssh) throws IOException { ssh.setConnectTimeout(60 * 1000); ssh.setTimeout(30 * 60 * 1000); ssh.loadKnownHosts(); ssh.addHostKeyVerifier(new PromiscuousVerifier()); } });
これで、サーバによって柔軟な設定が可能になり、SSHClient を次のメソッドを用いて設定する。
- addHostKeyVerifier
- addAlgorithmsVerifier
- loadKnownHosts
- useCompression
- setSocketFactory
- setTimeout
- setConnectTimeout
Facade を崩さない
このままではロジック部分が SSHJ を意識しなければならず、 SSHJ 特有の設定が SSHClientFacade 外部に漏れてしまう。
これでは、Facade を作った意味がない。なので、これらアクセス方法を定義した Strategy パターンを Enum 型で実装し、利用する。
実装一例。下記を SSHClientFacade に追加。
/** * Definition how to access to your server. * */ public static enum AccessStrategy { SERVER_A { @Override void execute(SSHClient ssh) throws IOException { ssh.setConnectTimeout(60 * 1000); ssh.setTimeout(30 * 60 * 1000); ssh.loadKnownHosts(); ssh.addHostKeyVerifier(new PromiscuousVerifier()); } }, SERVER_B { @Override void execute(SSHClient ssh) throws IOException { ssh.setConnectTimeout(10 * 1000); ssh.setTimeout(10 * 60 * 1000); ssh.loadKnownHosts(); } }; /** * write your settings. * @param ssh * @throws IOException */ abstract void execute(SSHClient ssh) throws IOException; } /** * Access server with your customize settings. * @param host * @param port * @param method * @throws IOException */ public void connect(String host, int port, AccessStrategy method) throws IOException { method.execute(ssh); ssh.connect(host, port); }
ロジック部の使用例
ssh.connect(property.getString("ssh.host"), property.getInt("ssh.port"), AccessStrategy.SERVER_A);
SSH はセキュリティ上重要なため、ライブラリ開発者の都合によっては、今後取り替えることを想定すべきかもしれない。
もしそうであったとしても、おそらくサーバの接続方法や設定は概念的には変わらない。
このように設計しておけば、ロジックの変更は SSHClientFacade クラスを変更するだけなので、取り替えは容易になる。
(またこのメソッドを直接ユーザに使わせないよう、ストラテジーのメソッドアクセスレベルはパッケージにしている)
ただし、この拡張はコードが複雑になるため、本当に必要なときだけ行った方が良い。
また、ここでは触れないが、認証方法に関しても様々なものが用意されているため、
本家のリファレンスは見ておくといいかもしれない。
C言語 の long int データ型仕様
概要
今回、開発環境でテストすると正しいが、本番環境では正しい結果が出力できなかった。
あまりにバカらしい理由ではあるが、戒めのために記述する。
起こった事
問題箇所
ある計算結果を出力するのだが、その結果は結構大きな値になってしまうので、
結果を格納するデータの型に long int を使用。そして、結果の表示において最初以下のように記述していた。
long int result = (計算部); printf("%d", result); // 588491482334(期待値)
すると、出力結果が明らかに小さい。
80962782
そして、自分が フォーマッタを『%d』にしていたミスに気付く。
『%d』→ 『%ld』に修正して、テスト。
long int result = (計算部); printf("%ld", result); // 588491482334(期待値)
出力結果が期待されるものになった。
588491482334
これで問題ないと思い、本番環境に送信して実行。その出力結果。
80962782
なぜか出力が戻った。送信したプログラムを確認したが問題ない。
問題解決フェーズ
計算結果を小さくしてみると正しい値が表示されるため、この現象は計算結果が大きくなった時のみ起こる。
しかし、フォーマッタは『%ld』を指定しているので、問題ないはずだが・・。
試しに『%lu』に変更してみたが効果なし。
今は C 言語の仕様が変わったのか?と思い、C 言語の仕様書を漁るもどこも変更なさそう。
仕方がないので本番環境で試しに、『LONG_MAX』(limits.h)を出力してみた。
2147483647
この瞬間、計算期待値がlong int の表現範囲を超えてることに気づく。
そこで、下記のように修正。
long long int result = (計算部); printf("%lld", result); // 588491482334(期待値)
本番環境でも動作を確認。こんな単純な問題に2時間掛けてしまった。
C 言語のデータ型仕様
この問題の要因を作った、long int の表現範囲を調べる。
まず、一般的には(データ型を参照)
int → 2byte(-32768~32767)を記憶できる
long int → 4byte (-2147483648~2147483647)を記憶できる
long long int → 8byte (-9223372036854775808~9223372036854775807)を記憶できる
まず、ここで既に勘違い。
long int が 8byte だと思ってた。(Java の long 型が 8byte だから)
(付け加えるなら long long int は 16byte だと思ってた)
そして、仕様は結構アバウトであり、基本的に下限だけ決まっている。
つまり、byte 数が大きい分には構わない。実際、私の開発環境では
#include<stdio.h> #include<limits.h> int main() { printf("INT_MAX:\t%d\n", INT_MAX); printf("LONG_MAX:\t%ld\n", LONG_MAX); printf("LLONG_MAX:\t%lld\n", LLONG_MAX); return 0; }
その結果。
INT_MAX: 2147483647 LONG_MAX: 9223372036854775807 LLONG_MAX: 9223372036854775807
long int は 8byte だった。しかも、 long long int とデータサイズ同じだった。
普段そんなに大きな値を使わないから、全く意識できていなかった。
反省。
結論
基本的なことではあるけれど、データ仕様は正しく覚える。
環境によってデータサイズが異なる事にも留意する。
各ケースのデータ選択には分岐より規約に基づいた処理を適用する
概要
分岐処理は、手軽に記述できるため、多くの場面で利用される。
しかし、あるデータの値によって、途中のデータを選択する処理を分岐を用いて
実装してしまうと、全てのケースをカバーしなければならないため、
新たなケースの追加や既存のケースの修正によって、プログラムを修正する必要がある。
もし、これら各ケースをプログラムでは同じ処理だと認識できれば、
ケースの追加やケースの修正を感知しないため、もっと保守性を向上できる。
今回はこれについて説明する。
詳細説明
今回は、会員のカテゴリ(VIP, 会員, 無料会員)があり、
これらのカテゴリ毎にメッセージを別々に表示したいとしよう。
まずは、改善の余地がある処理を提示する。
package sample; import resources.properties.Property; public class Main { public static void main(String[] args) { Property property = new Property("message"); String title, message; // ここで会員カテゴリを取得するとする. String category = getMemberCategory(); // ここでデータを選択する. if(category.equals("A")) { title = property.getString("vip.title"); message = property.getString("vip.message"); }else if(category.equals("B")) { title = property.getString("member.title"); message = property.getString("member.message"); }else if(category.equals("C")) { title = property.getString("normal.title"); message = property.getString("normal.message"); }else { throw new IllegalStateException("カテゴリ名が不正です"); } // データを用いた処理. System.out.println("title: " + title); System.out.println("message: " + message); } // コンパイラを通すために仮の値を作成. private static String getMemberCategory() { return "B"; } }
なお、property クラスは、message.properties からキーに対応した値を取得するクラスである。
以下の過去記事にその実装例がある。
プロパティファイルの使用方法 - Status Code 303 - See Other
コンストラクタでリソースファイル名を指定できる実装にしているが、基本的に動作は同じ。
そして、タイトルやメッセージを格納した message.properties はこちら。
vip.title = VIP会員様へ vip.message = 今お買い物頂くと 40% のポイントが溜まります。 member.title = 会員様へ member.message = 今お買い物頂くと 20% のポイントが溜まります! normal.title = 無料会員様へ normal.message = 正会員になれば、ポイントが溜まり、1p=1円でご利用できます!
タイトルとメッセージを外出しファイルにすることで、内容の変更は properties ファイルの
更新だけになり、プログラムを修正する必要がないようにしている。
しかし、もうちょっと保守性を向上できる。
今回、ネックとなっているのは分岐の部分だ。
分岐を用いた、このような実装は自然に映るかもしれないが、
会員カテゴリが追加、変更された場合は、以下を変更しなければならない。
- プログラム(if 文の分岐部分)
- リソースファイルの xxx.title, xxx.message キーの追加
本来、プログラムは処理を記述するものであるから、データ追加の影響は受けたくない。
タイトルとメッセージだけを追加すれば動作するようにしたい。
これを実現するには、下記のように message.properties のキー名を
カテゴリに対応したキー名にし、それを処理で取得するようにする。
今回の例では、次のようにする。
- [カテゴリ名] + ".title" の値をタイトルに表示する
- [カテゴリ名] + ".message" の値を本文に表示する
このように、リソースデータの記述方法を規約化してしまうことによって、
各ケースに対する処理をまとめてしまう。
package sample; import java.util.MissingResourceException; import resources.properties.Property; public class Main { public static void main(String[] args) { Property property = new Property("message"); String title, message; // ここで会員カテゴリを取得するとする. String category = getMemberCategory(); try { // ここでデータを選択する. title = property.getString(category + ".title"); message = property.getString(category + ".message"); } catch(MissingResourceException e) { throw new IllegalStateException("カテゴリ名が不正です"); } // データを用いた処理. System.out.println("title: " + title); System.out.println("message: " + message); } // コンパイラを通すために仮の値を作成. private static String getMemberCategory() { return "B"; } }
message.properties は以下のようにキー名を変更する。
# 存在するカテゴリ名 + .title, + .message をデータとして取得する A.title = VIP会員様へ A.message = 今お買い物頂くと 40% のポイントが溜まります。 B.title = 会員様へ B.message = 今お買い物頂くと 20% のポイントが溜まります! C.title = 無料会員様へ C.message = 正会員になれば、ポイントが溜まり、1p=1円でご利用できます!
このようにすれば、もし、カテゴリが追加されたとしても、message.properties に
新しいカテゴリ "D" に対する title と message を追加すれば、処理は変更しなくて良くなる。
# 存在するカテゴリ名 + .title, + .message をデータとして取得する A.title = VIP会員様へ A.message = 今お買い物頂くと 40% のポイントが溜まります。 B.title = 会員様へ B.message = 今お買い物頂くと 20% のポイントが溜まります! C.title = 無料会員様へ C.message = 正会員になれば、ポイントが溜まり、1p=1円でご利用できます! # 追加 D.title = お試し会員様へ D.message = お試し期間中は全サービスが使えますが、有効期間は1週間です!
その出力結果。(getMemberCategory() で "D" を返すように変更)
title: お試し会員様へ message: お試し期間中は全サービスが使えますが、有効期間は1週間です!
今回は、簡単のため、同一 properties ファイル内にデータを記述しているが、
カテゴリによって変更されるデータの数が多くなればなるほど、大量のデータで混沌とする。
この場合は、読み込むリソースファイル自体を変更する。
例えば カテゴリが "A" の場合、message_A.properties を読み込むようにする。
注意点として、この方法は各ケースの異なる部分が処理データだけだということ。
もし、各ケースの処理自体が異なっている場合は、単純にこの方法は適用できない。
もし、データ選択とそのデータの処理がコード的に分割できるのであれば、
まずそれを行ってから、上記変形を行う。
結論
データの値によって、処理されるデータを変更したい場合は、
リソースファイルに規約を適用した処理を記述することによって、
ケースの追加や変更にリソースファイルだけで対応できるようになる。
しかし、各ケースによって処理自体が異なってしまう場合は、単純に適用できない。
もし、これらからデータ選択部と処理部が分割できるなら、それをまず行う。
テンプレートによるロジックとデザイン切り分け
概要
デザインとロジックを切り分ける内容。
本来これらは全く別個に管理すべきなものであるため、分離させたい場合が多くある。
今回は、Java でこの方法を実現する手順を記述。
デザインとロジックの混在する悪い例
public class Main { public static void main(String[] args){ String user = "hoshi-kouki"; boolean isMember = true; Cart cart = new Cart(); cart.add(new Item("タマネギ", 100)); cart.add(new Item("じゃがいも", 150)); cart.add(new Item("人参", 100)); cart.add(new Item("肉", 500)); System.out.println(user + " 様"); System.out.println(); System.out.println("以下商品を購入されたことを通知します。"); System.out.println("==========================="); for(Item item : cart.getItems()) { System.out.println(item.getName() + " " + item.getPrice() + " 円"); } System.out.println("--------------------------"); System.out.println("合計 " + cart.sum() + " 円"); System.out.println("--------------------------"); if (isMember) { System.out.println("いつもお買い上げ頂きありがとうございます!"); }else { System.out.println("会員登録するとポイントがもらえますよ!"); } } }
まずい理由
- ロジックとデザインが混在するため、どちらも内容が分かり辛い。
- デザイン変更→ロジック破壊、ロジック変更→デザイン破壊が起こる可能性
- デザインを管理すべき人が自由に変更できない
- デザイン担当する人がプログラマと同じことができるとは限らない
本来あるべき姿
- デザインを担当する人は、その人の好きなツールで作成
- そのデザインを内部に取り込む仕組みはプログラマが作成
テンプレートの仕組み作成
Velocity - Velocity User Guide を用いたサンプルを記述する。
インストール手順は、ここ参照(http://hack.aipo.com/archives/8360/)。
事前設定
詳細説明
今回実装したテンプレート利用クラス(OutputBuilderクラス).
import java.io.StringWriter; import org.apache.velocity.Template; import org.apache.velocity.VelocityContext; import org.apache.velocity.app.Velocity; /** * 既に定義されたテンプレートファイルからプログラム上で様々な値を * セットして処理に応じた結果を作成する。 */ public class OutputBuilder { /** * ファイル名. */ private String fileName; /** * Velocity コンテキスト. */ private VelocityContext context; /** * コンストラクタ. * @param fileName ファイル名 */ public OutputBuilder(String fileName) { this.fileName = fileName; context = new VelocityContext(); } /** * Velocityコンテキストにパラメータを設定する. * @param key キー名 * @param value 値 * @return 呼び出したテンプレートビルダー自身 */ public OutputBuilder set(String key, Object value) { context.put(key, value); return this; } /** * 格納した結果から文字列を作成する. * @return 文字列 */ public String build() { Velocity.init(); Template template = Velocity.getTemplate(fileName, "UTF-8"); StringWriter writer = new StringWriter(); template.merge(context, writer); return writer.toString(); } }
今回は最小構成で作るため最低限のみ。機能追加は本家ドキュメントを参考に。
使い方
請求書を作成するとして記述する。
- 必要データ洗い出し
請求書に必要なデータは、今回は以下とする。- 購入者
- 購入商品・その値段
- 合計金額
- 購入者が会員/非会員かどうか
- Value Object(VO)用意
基本データ(文字・数値)のみで構成しても動作するが、モデル化クラスを利用した方が分かりやすくなる。- 商品の属性情報を管理するクラス。(Itemクラス)
- 商品自体を管理するクラス (Cartクラス)
- 各クラス実装
Itemクラス/** * 商品情報を管理する. */ public class Item { /** * 商品名. */ private String name; /** * 単価. */ private int price; /** * コンストラクタ. * @param name 商品名 * @param price 単価 */ public Item(String name, int price) { this.name = name; this.price = price; } public String getName() { return name; } public int getPrice() { return price; } }
Cartクラス
import java.util.ArrayList; import java.util.List; /** * 購入商品を管理する. */ public class Cart { /** * 購入商品. */ private List<Item> items; /** * コンストラクタ. */ public Cart() { items = new ArrayList<>(); } /** * 商品リストを取得する. * @return 商品リスト */ public List<Item> getItems() { return items; } /** * 商品を items に追加する. * @param item 追加する商品 */ public void add(Item item) { items.add(item); } /** * 商品の合計金額を取得する. * @return 金額 */ public int sum() { int sum = 0; for(Item item : items) { sum += item.getPrice(); } return sum; } }
- テンプレート(bill.vm)作成
$name 様 以下商品を購入されたことを通知します。 =========================== #foreach ($item in $cart.getItems()) $item.getName() $item.getPrice() 円 #end -------------------------- 合計 $cart.sum() 円 =========================== #if($isMember) いつもお買い上げ頂きありがとうございます! #else 会員登録するとポイントがもらえますよ! #end
今回は紹介のため様々なものを使っているが、
デザインの全体像が分かり辛くなるため分岐(#if ~ #else ~ #end)はあまり使わない方が良い。なお、上記にある特殊な意味を持つ文字について説明すると。
$name
setメソッドで渡したキー名'name'に対応した値を表示。
#foreach ($item in $cart.getItems()) (処理) #end
(処理)を集合個数($cartにはいったアイテム分)実行する。
各繰り返し処理において、カートの中の各要素がループごとに $item にセットされ、処理に利用できる。#if($isMember) (処理1) #else (処理2) #end
条件によって、(処理1)か(処理2)の結果が表示。
- ロジック記述
ロジックにテンプレートに対する処理を記述。- 使用するテンプレート指定 (OutputBuilder コンストラクタ)
- $name, $isMember, $cart に使用するデータ設定(setメソッド)
- テンプレートの指定箇所にロジックで得たデータを組み合わせた結果を得る(build メソッド)
public class Main { public static void main(String[] args) { Cart cart = new Cart(); cart.add(new Item("タマネギ", 100)); cart.add(new Item("じゃがいも", 150)); cart.add(new Item("人参", 100)); cart.add(new Item("肉", 500)); OutputBuilder template = new OutputBuilder ("bill.vm").set("name", "hoshi-kouki").set("isMember", true).set("cart", cart); System.out.print(template.build()); } }
出力結果は以下。
hoshi-kouki 様 以下商品を購入されたことを通知します。 =========================== タマネギ 100 円 じゃがいも 150 円 人参 100 円 肉 500 円 -------------------------- 合計 850 円 =========================== いつもお買い上げ頂きありがとうございます!
bill.vm の所定の箇所にデータが埋め込まれた形で出力できていることが分かる。
- ロジック記述
参考文献
Start up the Velocity Template Engine | JavaWorld
http://hack.aipo.com/archives/8360/
更新履歴
2016/01/20 変な表現多すぎたので、記事修正。
TemplateBuilderクラス → OutpuBuilderクラス(テンプレート作るわけじゃないし)
2016/06/13 説明が冗長すぎて分からなかったので修正。
Lombok ライブラリ - フィールド関連
主記事
Lombok ライブラリ - Status Code 303 - See Other
この記事では、フィールドに定義可能なアノテーションを記述。
(記事執筆時のバージョン :v1.16.6)
(更新中)
アクセッサ自動生成
指定されたフィールドのアクセッサを提供する。
@Getter
どのようなフィールドに対しても、Lombok が標準的なゲッタを提供する。
標準的なゲッタとは、おそらく以下の内容だと思われる。
- JavaBeansの命名規則を満たすゲッタ
- 処理はフィールドをそのまま返す
例:
private @Getter int foo;
上記を設定することにより、下記のコードが生成される。
public int getFoo() { return this.foo; }
オプション
- lazy(デフォルト:false)
- 明示的に書かれていない。遅延初期化?
- onMethod
- Lombok により生成されたゲッタに設定するアノテーションをリスト形式で記述する。
- value(デフォルト:public)
- アクセスレベルをゲッタに設定する。
@Setter
指定されたフィールドにセッタを提供する。
オプション
- lazy(デフォルト:false)
- 明示的な説明がない。遅延初期化?
- onMethod
- Lombok により生成されたセッタに設定するアノテーションをリスト形式で記述する。
- value(デフォルト:public)
- アクセスレベルをセッタに設定する。
コレクションフィールドに対するデータ格納メソッドの自動生成
null チェックの自動生成
@NonNull
Lombok から生成された全てのメソッドにおいて、該当のフィールドに値を割り当てる処理をする前に nullチェックを行う。
もし、 割り当てる値が null だったなら NullPointerException をスローする。
オプション
なし。
Lombok ライブラリ - その他
主記事
Lombok ライブラリ - Status Code 303 - See Other
この記事では、クラスとフィールド以外に関連するアノテーションを記述。
(記事執筆時のバージョン :v1.16.6)
(更新中)
データ同期
型推論
val
ローカル変数にのみ使用可能であり、初期化の式から型を推測する。
また、val を定義したローカル変数は、final になる。
例:
val x = 10.0; val y = new ArrayList<String>();
と記述すれば、 val は以下と同等とみなされる。
final double x = 10.0; final ArrayList<String> y = new ArrayList<String>();
なお、あまりそうは見えないかもしれないが val はアノテーション型である。(正確にはシンタックスシュガーらしい)
なぜなら、 val x = 10.0; と記述すれば、Lombok は @val final int x = 10; にコードを変換するから。
詳細はこちら(val)
オプション
なし。
ツールに対する適用外設定
@Generated
いずれは、Lombok が生成したメソッドやクラスに自動的に付加するアノテーション。
全てのコードスタイルチェックツールやバグ発見ツールに対して、これらのコードを無視させるために使用する。
オプション
なし。
メソッドパラメータの null チェック自動生成
リソース解放処理の自動生成
@Cleanup
何が起こったかに関係なく、ローカル変数がそれらの close メソッド呼び出しによってリソースの解放を保証する宣言。
ローカル変数の有効範囲にある全ての命令文を包括した try 文を実装することにより、リソースを解放する。
詳細なドキュメントこちら(@Cleanup)。
例:
public void copyFile(String in, String out) throws IOException { @Cleanup FileInputStream inStream = new FileInputStream(in); @Cleanup FileOutputStream outStream = new FileOutputStream(out); byte[] b = new byte[65536]; while (true) { int r = inStream.read(b); if (r == -1) break; outStream.write(b, 0, r); } }
これらの宣言が Lombok によって以下の処理に変換される。
public void copyFile(String in, String out) throws IOException { @Cleanup FileInputStream inStream = new FileInputStream(in); try { @Cleanup FileOutputStream outStream = new FileOutputStream(out); try { byte[] b = new byte[65536]; while (true) { int r = inStream.read(b); if (r == -1) break; outStream.write(b, 0, r); } } finally { if (out != null) out.close(); } } finally { if (in != null) in.close(); } }
チェック例外 → 実行時例外変換
@SneakyThrow
Java では、メソッド処理内にチェック例外を生成し得る命令が記述された場合、その例外に対処しなければならない。
(try-catch 文で例外をキャッチするか、メソッド呼び出し元に伝播させるなら throws 宣言を使ったりする)
しかし、このアノテーションをメソッドに記述すると、本来行うはずのチェック例外に対処しなくても良くなる。
このアノテーションは、内部的にオプション(value)に指定されたチェック例外(複数可)を
RuntimeException もしくは他の実行時例外にラップしている。こうして、チェック例外が実行時例外内に隠れるため、
本来 JVM が行うはずだった、チェック例外における一貫性チェックを回避している。
例:
下記のバイト列を特定の文字コードから String インスタンスを生成するコンストラクタはチェック例外である
UnsupportedEncodingException をスローするため、本来は何かしら対処をしなければならない。
しかし、以下の例では、これを省略してもプログラムは正常に動作する。
@SneakyThrows(UnsupportedEncodingException.class) public void utf8ToString(byte[] bytes) { return new String(bytes, "UTF-8"); }
これは、Lombok が上記コードを内部的に下記のように変換しているから。
public void utf8ToString(byte[] bytes) { try { return new String(bytes, "UTF-8"); } catch (UnsupportedEncodingException $uniqueName) { throw useMagicTrickeryToHideThisFromTheCompiler($uniqueName); // This trickery involves a bytecode transformer run automatically during the final stages of compilation; // there is no runtime dependency on lombok. } }
なお、元のコードと同じ動きを保証するために発生する条件では、本来スローされるべき例外がスローされる。
import java.text.SimpleDateFormat; import lombok.SneakyThrows; public class Main { @SneakyThrows public static void main(String[] args) { System.out.println(new SimpleDateFormat("yyyyMMdd").parse("aaaaa")); // 意図的に例外(ParseException)を発生させる } }
出力結果。
Exception in thread "main" java.text.ParseException: Unparseable date: "aaaaa" at java.text.DateFormat.parse(DateFormat.java:366) at Main.main(Main.java:8)
詳細はこちら(@SneakyThrows)