Mailgunに登録してPHPでメール送信をしてみる

追記@2015-03-27
もしかしたら、アプリケーション内でhttpsによる外部APIを叩いているサーバのメモリ使用量が増加し続ける件について が起きるかもです。
素直にMailgunが提供しているPHP向けSDKを使ったほうが良いかもです。


標題のことをやってみました。Mailgunが用意したSDK for PHPは存在するのですが、そのままHTTPで叩きたいなと思ったので、やってみました。

Mailgunに登録すると、

curl -s --user 'api:key-xxxxxx' \
    https://api.mailgun.net/v3/sandboxxxxxxx.mailgun.org/messages \
    -F from='Mailgun Sandbox <postmaster@sandboxxxxxxx.mailgun.org>' \
    -F to='John Doe <john.doe@example.com>'\
    -F subject='Hello John Doe' \
    -F text='Congratulations John Doe, you just sent an email with Mailgun!  You are truly awesome! 

You can see a record of this email in your logs: https://mailgun.com/cp/log 

You can send up to 300 emails/day from this sandbox server. Next, you should add your own domain so you can send 10,000 emails/month for free.'

↑てな感じのコマンドを渡されます。これをPHPで再現したのが↓です。

<?php
// ↓の3つは自分の値に書き換える!
define('MAILGUN_USER', 'api:key-xxxxxx');
define('MAILGUN_URI', 'https://api.mailgun.net/v3/sandboxxxxxxx.mailgun.org/messages');
define('MAILGUN_FROM', 'Mailgun Sandbox <postmaster@sandboxxxxxxx.mailgun.org>');

$sendTo = 'John Doe <john.doe@example.com>';
$subject = 'Hello John Doe';
$text = 'Congratulations John Doe, you just sent an email with Mailgun!  You are truly awesome! 

You can see a record of this email in your logs: https://mailgun.com/cp/log 

You can send up to 300 emails/day from this sandbox server. Next, you should add your own domain so you can send 10,000 emails/month for free.';

$rep = sendEmailByMailgun($sendTo, $subject, $text);

var_dump($rep);

function sendEmailByMailgun($sendTo, $subject, $text) {
	$data = http_build_query(array(
		"from" => MAILGUN_FROM,
		"to" => $sendTo,
		"subject" => $subject,
		"text" => $text,
		), "", "&");
	$header = array(
		"Authorization: Basic " . base64_encode(MAILGUN_USER),
		"User-Agent: PHP",
		"Host: api.mailgun.net",
		"Accept: */*",
		"Content-Length: " . strlen($data),
		"Expect: 100-continue",
		"Content-Type: application/x-www-form-urlencoded",
	);
	$stream_context = array(
		"http" => array(
			"method" => "POST",
			"header" => implode("\r\n", $header),
			"content" => $data,
		)
	);
	return file_get_contents(MAILGUN_URI, false, stream_context_create($stream_context));
}


実行結果↓

string(129) "{
  "message": "Queued. Thank you.",
  "id": "<20150320184311.70040.17235@sandboxxxxxx.mailgun.org>"
}"

【PHP】pearで「Could not open input file: /opt/local/lib/php54/pear/pearcmd.php」とでるとき【macports】

環境はmacです。

macportsでphp56を入れて、それをデフォルトにしてみました。

$ sudo port install php56
$ sudo port select --set php php56
Selecting 'php56' for 'php' failed: symlink: /opt/local/etc/select/php/current -> php56: file already exists
$ php -v
PHP 5.6.6 (cli) (built: Feb 21 2015 09:42:01) 
Copyright (c) 1997-2015 The PHP Group
Zend Engine v2.6.0, Copyright (c) 1998-2015 Zend Technologies

そうしますと、pearやpeclが動かなくなりました。

$ pear
Could not open input file: /opt/local/lib/php54/pear/pearcmd.php

↑こんな感じで何も出来ません。いろいろと直し方はあるようですが、Pearの公式からpharを落としてきて実行したら直りました。

$ wget http://pear.php.net/go-pear.phar
$ sudo php go-pear.phar 

Below is a suggested file layout for your new PEAR installation.  To
change individual locations, type the number in front of the
directory.  Type 'all' to change all of them or simply press Enter to
accept these locations.

 1. Installation base ($prefix)                   : /opt/local
 2. Temporary directory for processing            : /tmp/pear/install
 3. Temporary directory for downloads             : /tmp/pear/install
 4. Binaries directory                            : /opt/local/bin
 5. PHP code directory ($php_dir)                 : /opt/local/lib/php
 6. Documentation directory                       : /opt/local/docs
 7. Data directory                                : /opt/local/data
 8. User-modifiable configuration files directory : /opt/local/cfg
 9. Public Web Files directory                    : /opt/local/www
10. Tests directory                               : /opt/local/tests
11. Name of configuration file                    : /opt/local/etc/pear.conf

1-11, 'all' or Enter to continue: 
Beginning install...
Configuration written to /opt/local/etc/pear.conf...
Initialized registry...
Preparing to install...
installing phar:///Users/mk/my_repos/workspace/go-pear.phar/PEAR/go-pear-tarballs/Archive_Tar-1.3.12.tar...
installing phar:///Users/mk/my_repos/workspace/go-pear.phar/PEAR/go-pear-tarballs/Console_Getopt-1.3.1.tar...
installing phar:///Users/mk/my_repos/workspace/go-pear.phar/PEAR/go-pear-tarballs/PEAR-1.9.5.tar...
installing phar:///Users/mk/my_repos/workspace/go-pear.phar/PEAR/go-pear-tarballs/Structures_Graph-1.0.4.tar...
installing phar:///Users/mk/my_repos/workspace/go-pear.phar/PEAR/go-pear-tarballs/XML_Util-1.2.3.tar...
install ok: channel://pear.php.net/Archive_Tar-1.3.12
install ok: channel://pear.php.net/Console_Getopt-1.3.1
install ok: channel://pear.php.net/Structures_Graph-1.0.4
install ok: channel://pear.php.net/XML_Util-1.2.3
install ok: channel://pear.php.net/PEAR-1.9.5
PEAR: Optional feature webinstaller available (PEAR's web-based installer)
PEAR: Optional feature gtkinstaller available (PEAR's PHP-GTK-based installer)
PEAR: Optional feature gtk2installer available (PEAR's PHP-GTK2-based installer)
PEAR: To install optional features use "pear install pear/PEAR#featurename"

******************************************************************************
WARNING!  The include_path defined in the currently used php.ini does not
contain the PEAR PHP directory you just specified:
</opt/local/lib/php>
If the specified directory is also not in the include_path used by
your scripts, you will have problems getting any PEAR packages working.


Would you like to alter php.ini </opt/local/etc/php56/php.ini>? [Y/n] : Y

php.ini </opt/local/etc/php56/php.ini> include_path updated.

Current include path           : .;/usr/lib/php/pear
Configured directory           : /opt/local/lib/php
Currently used php.ini (guess) : /opt/local/etc/php56/php.ini
Press Enter to continue: 

The 'pear' command is now at your service at /opt/local/bin/pear

# 確認
$ pear version
PEAR Version: 1.9.5
PHP Version: 5.6.6
Zend Engine Version: 2.6.0
Running on: Darwin macbook.local 12.5.0 Darwin Kernel Version 12.5.0: Sun Sep 29 13:33:47 PDT 2013; root:xnu-2050.48.12~1/RELEASE_X86_64 x86_64

ついでにpeclもインストールします。

$ sudo pear install CodeGen_PECL
WARNING: "pear/Console_Getopt" is deprecated in favor of "pear/Console_GetoptPlus"
downloading CodeGen_PECL-1.1.3.tgz ...
Starting to download CodeGen_PECL-1.1.3.tgz (102,640 bytes)
........................done: 102,640 bytes
downloading CodeGen-1.0.7.tgz ...
Starting to download CodeGen-1.0.7.tgz (51,114 bytes)
...done: 51,114 bytes
install ok: channel://pear.php.net/CodeGen-1.0.7
install ok: channel://pear.php.net/CodeGen_PECL-1.1.3

$ pecl version
PEAR Version: 1.9.5
PHP Version: 5.6.6
Zend Engine Version: 2.6.0
Running on: Darwin macbook.local 12.5.0 Darwin Kernel Version 12.5.0: Sun Sep 29 13:33:47 PDT 2013; root:xnu-2050.48.12~1/RELEASE_X86_64 x86_64

php.iniをセットします。

$ pear config-set php_ini /opt/local/etc/php56/php.ini
$ pecl config-set php_ini /opt/local/etc/php56/php.ini

peclでredisライブラリなどが入ります。

$ sudo pecl install redis
WARNING: channel "pecl.php.net" has updated its protocols, use "pecl channel-update pecl.php.net" to update
downloading redis-2.2.7.tgz ...
Starting to download redis-2.2.7.tgz (134,340 bytes)
.............................done: 134,340 bytes
13 source files, building
running: phpize
Configuring for:
PHP Api Version:         20131106
Zend Module Api No:      20131226
Zend Extension Api No:   220131226
building in /private/tmp/pear/install/pear-build-rootwa3QZM/redis-2.2.7
running: /private/tmp/pear/install/redis/configure

# ~~~中略~~~

Build process completed successfully
Installing '/opt/local/lib/php56/extensions/no-debug-non-zts-20131226/redis.so'
install ok: channel://pecl.php.net/redis-2.2.7
Extension redis enabled in php.ini

いい感じですね〜。

JWTをPHP(php-jwt)で試してみる

追記@2015-04-03

やはりいろいろと勘違いしておりました。JWTでencodeすれば暗号化されると思っていたのですが、暗号化ではなく署名が含まれいて改竄困難、の間違いでした。

たとえば↓の例で、

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g

というjwtによるトークンが出てきますが、

<?php
$header = urlsafeB64Decode('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9');
echo $header;

echo "\n";

$payload = urlsafeB64Decode('eyJrZXkxIjoidmFsdWUxIn0');
echo $payload;


としてやると(urlsafeB64Decodeはphp-jwtに含まれています)、

{"typ":"JWT","alg":"HS256"}
{"key1":"value1"}

となります。つまりjwtの文字列はbase64エンコードされいるだけの実質平文なわけです。もちろん署名が入っているので改竄困難ですが。

また、脆弱性が見つかったらしいので(時間があれば脆弱性を付く例を書きたいのですが。。。)、ご注意下さい。参考リンクはこちら→ https://auth0.com/blog/2015/03/31/critical-vulnerabilities-in-json-web-token-libraries/


JWT(JSON Web Token)の存在を知ったのでPHPで試してみました。この仕組を使えばユーザーの認証を保存する必要がなくなって効率的だと思います。おそらくRuby On Railsでこの仕組を使っています。

既存の仕組みだと、

ユーザーがusername/passwordをサーバーに送信

サーバーはusername/passwordを受け取って、検証

サーバーは、その情報が正しければランダムな文字列をデータベースに保存してさらにユーザーに送信。正しくなければユーザーに失敗であると返却

ユーザーはサーバーから送られてきたランダムな文字列を受け取ってcookieにセットし、その後のサーバーのやりとり全てでそのランダムな文字列を送信して、本人性を証明する

サーバーはユーザーからランダムな文字列を受け取ったらそれがデータベースに存在しているか確認し、存在していればそのまま処理をして、存在しなければ失敗であるとユーザーに返却

という感じになります。問題点として、サーバー側で、

– ランダムな文字列をデータベースに保存する必要がある
– ランダムな文字列をデータベースに毎回問い合わせして確認する必要がある
– サーバー側でデータベースを用意する必要がある

などが挙げられるでしょう。JWTを使えば、ユーザーの検証のためのデータをデータベースに保存する必要がなくなります。JWTでは、

ユーザーがusername/passwordをサーバーに送信

サーバーはusername/passwordを受け取って、検証

サーバーは、その情報が正しければJWTを生成してユーザーに渡す。正しくなければユーザーに失敗と返す

ユーザーはJWTを受け取ってcookieにセット、その後のやりとり全てでこのJWTを送信して本人性を証明する

サーバーはユーザーからJWTを受け取ったらそれを検証する。このとき、データベースへのアクセスは必要ない

となります。問題点としては相対的にCPUパワーを使う点(ただ、必要な情報は鍵だけなので、その鍵を複数のノードに分散してやれば、無限にスケールします)と、JWTを生成するための鍵の保持でしょうか?鍵が公になってしまうとjwtを作り放題になり悲惨なことになります(ただ、expireを付けておくことが可能ですので鍵の交換は容易でしょう)。まあPKIってそういうものですが。ここまで書いてきてなんですが、私はこういったセキュリティやPKIにさほど詳しくないので、詳細な情報は他をあたって下さい。

さてjwtを試してみます。使うライブラリは、jwtの公式からリンクされていた、

https://github.com/firebase/php-jwt

です。まずディレクトリを作ってライブラリを入れて、そこから本番です。

$ mkdir jwt-example; cd jwt-example/;mkdir jwt;cd jwt
$ wget https://raw.githubusercontent.com/firebase/php-jwt/master/Authentication/JWT.php; wget https://raw.githubusercontent.com/firebase/php-jwt/master/Exceptions/BeforeValidException.php; wget https://raw.githubusercontent.com/firebase/php-jwt/master/Exceptions/ExpiredException.php; wget https://raw.githubusercontent.com/firebase/php-jwt/master/Exceptions/SignatureInvalidException.php; 
$ cd ..

# 標準入力からjsonを受け取って、jwtに変換するコードです。
$ vi encode.php
$ cat encode.php 
<?php

require('jwt/JWT.php');

$key = 'i_am_a_secret_key';

$json = json_decode(file_get_contents('php://stdin'));

if ($json === NULL){
	die("[Error] Invalid input.\n" . 'example: $ echo \'{"key1":"value1","expire":12345}\' | php encode.php' . "\n");
}

$jwt = JWT::encode($json, $key);

echo $jwt;

# 標準入力からjwtを受け取って、検証してjsonに戻して表示するコードです。
$ vi decode.php
$ cat decode.php 
<?php

require('jwt/BeforeValidException.php');
require('jwt/ExpiredException.php');
require('jwt/SignatureInvalidException.php');
require('jwt/JWT.php');

$key = 'i_am_a_secret_key';

$jwt = file_get_contents('php://stdin');

try {
	$decoded = JWT::decode($jwt, $key);
} catch (Exception $e){
	die("[ERROR] Invalid jwt. Detail: " . $e->getMessage() . "\n");
}
var_dump($decoded);

さて、いろいろと試して遊んでみます。↑で作ったencode.phpは標準入力でjsonを受け取って、jwtを吐き出すものなので、以下のようにして使ってみます。

$ echo '{"key1":"value1"}' | php encode.php 
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g

↑出来ました。上記のようなjsonをjwtにした結果が得られました。さて今度は逆に、jwtからjsonに戻してみます。先ほどのjwt、「eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g」を、decode.phpを使って戻せるか試します。

$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g' | php decode.php
object(stdClass)#2 (1) {
  ["key1"]=>
  string(6) "value1"
}


↑戻すことが出来ました!

つなげて書くと、

$ echo '{"foo":"bar"}' | php encode.php | php decode.php 
object(stdClass)#2 (1) {
  ["foo"]=>
  string(3) "bar"
}

となります。

おかしな文字列を入力してデコードさせてみます。先ほどのjwt、「eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g」の最後の「g」を「G」にしてみます。

# ただしい文字列 → OK
$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1g' | php decode.php 
object(stdClass)#2 (1) {
  ["key1"]=>
  string(6) "value1"
}
# おかしな文字列(末尾がg→Gとなっている) → だめ
$ echo 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkxIjoidmFsdWUxIn0.w7Zzc7MkaUeLxKlBrXd3qyS2HPRpzp9OSSKrWl8Ma1G' | php decode.php 
[ERROR] Invalid jwt. Detail: Signature verification failed

となり、いい感じですね。

このPHPライブラリでは上記の単純な機能だけでなく、

– nbf (Not Before): 現在時刻が指定した時間より前なら処理しないような時間
– iat (Issued At): jwtが発行された時間
– exp (Expiration Time): jwtが無効になる時間

に対応しているようです。たとえば、

# 現在時刻を表示
$ php -r 'echo time() . "\n";'
1425560586

# nbfに↑の値を入れてエンコードとデコードを試す
$ echo '{"nbf":1425560586, "key1":"value1"}' | php encode.php | php decode.php 
object(stdClass)#2 (2) {
  ["nbf"]=>
  int(1425560586)
  ["key1"]=>
  string(6) "value1"
}

# nbfを大きな値にしてみる(1425560586 → 1525560586)と、エラーとなる。現在時刻 < nbfだからである。
$ echo '{"nbf":1525560586, "key1":"value1"}' | php encode.php | php decode.php 
[ERROR] Invalid jwt. Detail: Cannot handle token prior to 2018-05-06T07:49:46+0900

# ↑のnbfをiatに書き換えてみる (iatが未来の時間なのでエラーとなる)
$ echo '{"iat":1525560586, "key1":"value1"}' | php encode.php | php decode.php 
[ERROR] Invalid jwt. Detail: Cannot handle token prior to 2018-05-06T07:49:46+0900

# expを指定してみる (まず未来の値にしてみる→当然OK)
$ echo '{"exp":1525560586, "key1":"value1"}' | php encode.php | php decode.php 
object(stdClass)#2 (2) {
  ["exp"]=>
  int(1525560586)
  ["key1"]=>
  string(6) "value1"
}

# expを指定してみる (過去の値にしてみる→失効しているのでエラー)
$ echo '{"exp":1325560586, "key1":"value1"}' | php encode.php | php decode.php 
[ERROR] Invalid jwt. Detail: Expired token

というわけでした。nbf/iat/expといろいろとあって分かりにくい感じですが基本的にはiatとexpを使えば事足りるのではないでしょうか。

PHPRedisにおけるLua Scriptingの書き方

PHPの有名なRedisライブラリPHPRedisのLuaの書き方をいつも忘れるので書き残しておきます。Luaを使えばアトミックになるのでそれなりに使う機会はあると思います。

コードを下記に置いておきました、一目瞭然かと思いますが、説明しますと、evalの引数は必須がひとつ、オプションが2つあります。必須はLuaスクリプト、オプションのひとつはarray、オプションのもう一つはarrayの要素数です。本来のRedisではKEYSだけでなくARGVも使えますが、どうやらPHPRedisだとKEYSだけ使用可能なようです (Redis Clusterを使わない限りは、RedisのLuaにおけるKEYSとARGVに違いはありません、詳しくはこの記事の前の記事を参照してください)。

コード

<?php

$redis = new Redis();
$redis->connect('localhost', 6379, 1.0);

$script1 = <<<EOT
	return {KEYS[1], KEYS[2], KEYS[3]}
EOT;

$keys = Array('key1', 'key2', 'key3');
$result1 = $redis->eval($script1, $keys, count($keys));
var_dump($result1);

$script2 = <<<EOT
	return KEYS
EOT;

$result2 = $redis->eval($script2, $keys, count($keys));
var_dump($result2);

結果
array(3) {
  [0]=>
  string(4) "key1"
  [1]=>
  string(4) "key2"
  [2]=>
  string(4) "key3"
}
array(3) {
  [0]=>
  string(4) "key1"
  [1]=>
  string(4) "key2"
  [2]=>
  string(4) "key3"
}

おまけ

内部でRedisを呼ぶには次のようにします。
<?php

$redis = new Redis();
$redis_status = $redis->connect(REDIS_HOST, REDIS_PORT, REDIS_TIMEOUT);

if ($redis_status === FALSE) {
	die("Internal Server Error.\n");
}

$luascript = <<<EOT
	local key1 = KEYS[1]
	local key2 = KEYS[2]
	local key3 = KEYS[3]
	local key1_exists = redis.call('EXISTS', key1)
	local key2_exists = redis.call('EXISTS', key2)
	return {key1_exists, key2_exists}
EOT;

$keys = array('value1', 'value2', 'value3');

$result = $redis->eval($luascript, $keys, count($keys));

var_dump($result);

出力例

array(2) {
  [0]=>
  int(1)
  [1]=>
  int(0)
}

HMSETについては、以下のようにやればできます。

<?php
$redis = new Redis();
$redis_status = $redis->connect(REDIS_HOST, REDIS_PORT, REDIS_TIMEOUT);

if ($redis_status === FALSE) {
	die("Internal Server Error.\n");
}

$luascript = <<<EOT
	local key1 = KEYS[1]
	local key2 = KEYS[2]
	local key3 = KEYS[3]
	redis.call('hmset', 'hmset1', 'f1', key1, 'f2', key2, 'f3', key3)
	
	return redis.call('hgetall', 'hmset1')
EOT;

$keys = array('one', 'two', 'three');

$result = $redis->eval($luascript, $keys, count($keys));

var_dump($result);

出力例

array(6) {
  [0]=>
  string(2) "f1"
  [1]=>
  string(3) "one"
  [2]=>
  string(2) "f2"
  [3]=>
  string(3) "two"
  [4]=>
  string(2) "f3"
  [5]=>
  string(5) "three"
}

PHPとRedisでロック

追記@2014-11-05

自分の要件を考えてみると、わざわざこんなことしなくても素直にMySQLを使えばよいことに気が付きました><


DB鯖がたくさん

それらに一貫性のある書き込みをしたい

分散一意IDの発行みたいなのは出来ない(emailとかusernameみたいな一意なやつを書きたい)

Redisでロック・アンロックして、一意性のある書き込みの場合はたくさんのDB鯖に書き込めるプロセスを一個にしよう

という動機でRedisを使ったlock/unlockメソッドを書いてみました。詳しくはソース内↓に書いてます。けっこう苦労しました(ヽ’ω`)。
ロック周りは大変ですね。これが正解かは分かりません(というかCAP定理より全部を満たすことは出来ないですね)。おそらくロック用Redisサーバーを専用に用意する必要があるかと思います。というのも、これはロックを得るための待ち行列ができるからです。Redisで「 $ ./src/redis-cli -p 26379 monitor」としてみると、アクセスがたくさんあるのが分かるかと思います。

<?php
/**
 * PHPとRedisを用いてロックを実現するメソッドと使用例。
 * 
 * 分散された複数のデータベースサーバーについて、一貫性のある書き込みやupdateを行うために、Redisをロックサーバーとして用いる。
 * lockとunlockメソッドがある。
 * 使用する際は、lockメソッドでロックしてから、書き込みやアップデートして、後始末としてアンロックする。
 * 具体的な使い方は下記にある。
 * 
 * 留意点:
 * - DB_LOCK_TIMEOUT_FOR_DEADLOCK_SEC > (処理したい内容に掛かる時間) とせよ
 * → デッドロック回避のためにタイムアウトが指定されている。
 * → つまりロック後に、指定した時間以上に時間が掛かる処理だとロックが自動的に解除されて、
 * → 他のプロセスやスレッドがロックしてアクセスしてきて、一貫性が乱れる可能性がある。
 * 
 * - lockメソッドでロックを取得できない場合がある
 * → あまりにも多くのプロセスがロックを得ようとして、いつまでも待たされて、
 * → DB_LOCK_TIMEOUT_TOO_BUSY_SEC で設定した時間を超えるとFALSEが返ることがある。そのため、
 * → DB_LOCK_TIMEOUT_TOO_BUSY_SEC > (処理したい内容に掛かる時間) * 想定されるプロセス数
 * → となるようにせよ。
 * → 今回の場合は、1sec掛かる処理を行っており、DB_LOCK_TIMEOUT_TOO_BUSY_SECは10である。
 * → つまり、並列プロセス数10程度でロック取得失敗が起きる可能性がある。実際このプログラムを12並列で動かすと、
 * → 「*** Too busy. can not get a lock. ***」が起きる。
 * 
 * - DB_LOCK_WAIT_MSを小さい値にしすぎるとRedisへの負担が掛かる
 * → プロセスがロックがあるかどうかとDB_LOCK_WAIT_MSの周期でRedisに尋ねるので、Redisの負担になる。
 * → 大きすぎず小さすぎずの値にしておくべきだと思う。0.1secつまり100msあたりが妥当かと。
 * → 定量的に考えてみる、一般的なマシンでRedisは10万req/sec程度さばくことが出来る。
 * → 1プロセスあたりの1秒間のrequestは1000/DB_LOCK_WAIT_MS。たとえばDB_LOCK_WAIT_MS=100なら10req/sec
 * → 10並列のプロセスで100req/sec、100並列で1000req/secとなる。
 * → 一般論として同時コネクション数は100程度にしておくことが無難であり、Redisへのsetnxのアクセスは限界の10%つまり1万req/sec程度に
 * → しておけば大きな負荷にはならないだろう。ということは、1プロセスあたり一秒間に100回問い合わせして良いということなので、
 * → DB_LOCK_WAIT_MSの最小値は10と考えられる。これ以下にすると危険であろう。
 * → 特にレイテンシが求められるわけでもないのであれば、結局最初に書いた通り100に設定するのが妥当。
 * 
 * 疑問点:
 * ロックのためのkeyとvalueを書き込んでいるが、valueが必要かどうか良く分からない。
 * さらにunlockではvalueの値を確認して削除しているが、必要なのかどうか。
 * valueは必要ない気がする。消すか悩む。
 * → 消してみたら何かおかしくなった。きちんとしていない。ので、valueは残しておく。
 * 
 * 蛇足:
 * - $ ./src/redis-cli -p 26379 monitor
 * とすることで、Redisへの命令とその時間を眺めることが出来る。
 * 
 * 参考資料:
 * - http://ameblo.jp/principia-ca/entry-11770810115.html
 * - https://engineering.gosquared.com/distributed-locks-using-redis
 */

// ==================== 定数とメソッド ====================
define('DB_LOCK_KEY_PREFIX', 'lock:');
define('DB_LOCK_VALUE_PREFIX', 'value:');
define('DB_LOCK_TIMEOUT_FOR_DEADLOCK_SEC', 3); // means 3 sec。処理したい内容に掛かる処理時間よりも2倍以上大きな値にせよ!
define('DB_LOCK_TIMEOUT_TOO_BUSY_SEC', 10); // means 10 sec。想定されるプロセス数*(処理したい内容に掛かる処理時間)よりも大きな値にせよ!
define('DB_LOCK_WAIT_MS', 100); // means 100 ms。10ms程度が限界だと思われる。
function lock(Redis $redis, $lock_key, $lock_value) {
	$now = time();
	while (TRUE) {
		$res = $redis->setnx(DB_LOCK_KEY_PREFIX . $lock_key, DB_LOCK_VALUE_PREFIX . $lock_value);
		if ($res === TRUE) {
			// デッドロック回避のためタイムアウトを指定
			$redis->setTimeout(DB_LOCK_KEY_PREFIX . $lock_key, DB_LOCK_TIMEOUT_FOR_DEADLOCK_SEC);
			return TRUE;
		}

		// 競合が多すぎて指定された時間(= DB_LOCK_TIMEOUT_TOO_BUSY_SEC)内に取れない場合、FALSE
		if (time() > $now + DB_LOCK_TIMEOUT_TOO_BUSY_SEC) {
			return FALSE;
		}

		// 待機
		usleep(DB_LOCK_WAIT_MS * 1000);
	}
}
function unlock(Redis $redis, $lock_key, $lock_value) {
	$res = $redis->get(DB_LOCK_KEY_PREFIX . $lock_key);
	if ($res === DB_LOCK_VALUE_PREFIX . $lock_value) {
		$redis->delete(DB_LOCK_KEY_PREFIX . $lock_key);
		return TRUE;
	} else {
		return FALSE;
	}
}

// ==================== 以下、活用例 ====================

define('REDIS_HOST', '127.0.0.1');
define('REDIS_PORT', 26379);
define('REDIS_TIMEOUT_SEC', 2.0); // means 2.0 sec

$redis = new Redis();
$redis->connect(REDIS_HOST, REDIS_PORT, REDIS_TIMEOUT_SEC);

echo "========================================\n";
echo "Going to get a lock...\n";
if (lock($redis, 'key1', 'value1') === TRUE){
	echo "Lock ok.\n";
	echo "Going to do a task...\n";
	sleep(1); // task = 1 secs sleep!
	echo "Task done. Going to unlock.\n";
	if (unlock($redis, 'key1', 'value1') === TRUE){
		echo "Unlock ok.\n";
		echo "Yay! Everything done well!\n";
		echo "========================================\n";
	} else {
		echo "*** Unlock failed. ***\n";
		exit();
	}
} else {
	echo("*** Too busy. can not get a lock. ***\n");
	exit();
}