PHP : してログ

複数の条件で処理を分けたい場合は、「if ~ else if ~」といった繰り返しでコードを記述すると思いますが、条件式の開始位置が合わず、どうやっても美しいとは言えないコードになります。同じような条件分岐に、「switch ~ case」文もありますがこちらを多少トリッキーな書き方をすることで、見やすいコードにすることができます。

if ~ else if で記述した場合

if ($val1==1) {
	$color = 'red';
} else if ($val2==1) {
	$color = 'orange';
} else if ($val3==1) {
	$color = 'yellow';
} else if ($val4==1) {
	$color = 'purple';
} else {
	$color = 'default';
}

switch (true) ~ case で記述した場合

switch (true) {

case $val1==1:
	$color = 'red';
	break;

case $val2==1:
	$color = 'orange';
	break;

case $val3==1:
	$color = 'yellow';
	break;

case $val4==1:
	$color = 'purple';
	break;

default:
	$color = 'default';

}

このように通常は変数を記述する部分に true と入れることで、各 case 文に条件式を記述することができます。人によっては break 文が余計で嫌とか思うかも知れませんが、条件式の開始桁が揃って見やすくなるので、直観的には分かりやすい書き方だと思います。

今ではすっかり廃れた感のある SOAP だが、久しぶり(というか PHP では初)に作成する機会があり、やっぱり苦労の連続になりました。XML 地獄という言葉もあるらしいが、しっかりと設計されておらず名前空間が入り乱れてるような複雑な XML は、もはやいじめでしかないように思う。せめて、stdClass や SimpleXML で軽くアクセスできるようなレベルに留めてもらいたいものである。

今回悩んだのは、あるメソッドに XML 文書をポストしてくる仕様で、受け取った時点で stdClass にパースされてしまっているというもの。その XML がシンプルな要素のみで構成されているなら、むしろ簡単にアクセスできるのでいいのだが、前述したような名前空間が入り乱れ、同じ要素名で属性が違うとかいうものがあると対応できない。stdClass では、名前空間プレフィクスは消えるし、属性にはアクセスできないためである。それであれば、プレーンテキストで受け取れれば簡単に解決できるのにどうしても出来ない。そもそもデフォルトでオブジェクトにするのなら DOMDocument じゃないのかなあ?

という訳で、PHP の SOAP エクステンションじゃあどうにもならないので、裏技的な手法で生の XML を取得することができたので紹介します。SOAP のリクエストは POST で来ますので、その生データを横取りする方法です。

$soapxml = file_get_contents('php://input');

注意点としては、返ってくるのは SOAP エンベロープにラップされた XML になるので、適切に抜き出す必要があるということ。具体的には、DOMDocument に変換してルート要素を抜き出して saveXML すれば良いです。他にスマートな方法があるかも知れませんが、とりあえずこれでうまく行っています。

テレビの録画をニコニコ実況入れて保存するようにしてから、地上波見るの結構楽しくなってきました。つまらない番組でも実況あると、なんとなく面白く見えてくるので不思議です。もしかしたら地上波が生き残るチャンスかもしれませんよ~。

ただ、どの番組にも必ず荒らすバカはいるものです。そういったユーザーのコメントを消すのに NG ユーザ設定があるのですが、これが XML に出力するとき効いてないんですよね。XML って言ってもテキストファイルなので、今回はエディタなどを使って除去してみようと思います。

せっかくコメント入りの動画を作っても、しつこい卑猥なコメント野郎や意味不明な連投をするユーザがいると台無しです。そういったコメントに毎回反応する「○○○消えろ」とかいったコメントも迷惑。まとめて排除してしまいましょう。

正規表現を考えてみる

いきなりですが、特定のユーザ ID のコメントにヒットする正規表現を紹介します。正規表現が何なのか知らない人は申し訳ありませんがググってください(ここからはプログラミングの知識が必要です)。"NG-USER-ID-HERE" には消去したい NG ユーザの ID を調べて入れてください。また、正規表現は PHP の PCRE 用に書いたものですので、他の処理系では変更が必要かも知れません。

/<chat[^>]+?user_id="NG-USER-ID-HERE".+?<\/chat>/s

実際に除去してみる

正規表現が使えて UTF-8 が扱えるテキストエディタがあれば、上記正規表現を空文字に置き換えれば良いだけです。ただし、エディタによっては絵文字などが化けてしまうのでご注意ください(私が愛用している TeraPad は化けてしまうんですよね)。

プログラムができる人は、シェルスクリプトや PHP などを使ってバッチコマンドを作ると便利です。NG ユーザリストを外部ファイルで供給したり、キーワードによるフィルタなんかも作れば不快なコメントの問題はほぼ解決でしょう。

サンプルコード

私が作った PHP のコードを下記に示します。$xml にコメントデータを読み込み、$nguid にユーザーの ID が入っているものとします。

$reg = '/<chat[^>]+?user_id="'.$nguid.'".+?<\/chat>/s';
$xml = preg_replace($reg,'',$xml);

今まで、BOM 付き UTF-8 のテキストデータを扱う時、substr($str,3) とかで BOM を取り除いていたのですが、これだと BOM 無しの UTF-8N ではデータまで削ってしまいます。どちらでもうまく処理できるように、正規表現で書き直してみましたので今度からはこちらで行きます。

$str = preg_replace('/^\xEF\xBB\xBF/','',$str);

なお、PHP コード自体は BOM 無しの UTF-8N で書く必要があります。普段 UTF-8N で保存できるエディタを使用していても、本番環境などでうっかり Windows のメモ帳なんかで編集してしまうと、予期せぬエラーを招くので要注意です。

これめんどくさいから言語のほうで吸収してくれんかなと、ときどき思います。それか、UTF-8 と UTF-8N をはっきり区別して扱えるようにしてほしい。せっかく文字コードで悩まなくて済むようになったはずなのに、こういう混乱を招く仕様をなぜ入れてしまったのだろうか。

汎用的に使える和暦フォーマット関数を作成してみました。元号は漢字表記とアルファベットに対応し、通常の日付フォーマットを利用することが可能です。また、明治以降に対応し、平成の次の新元号が決まったら書き換えれば済む(改元の2019年5月1日は組み込んであります)ようになっています。

  • 「平成」などの漢字表記と「H」などのアルファベット表記ができます
  • 最初の年を「元年」と表記します
  • 年以外の部分に日付フォーマットの書式が使えます
  • 新元号にすぐに対応できるようにしました
  • 明治以降の元号に対応しています
  • 明治より前は「西暦1192」や「AD 1192」のような表記になります

使い方

// 平成30年1月28日
convert_jpdt(time());

// 平成30年01月28日
convert_jpdt(time(),'年m月d日');

// H30.01.28
convert_jpdt(time(),'.m.d',false);

ソースコード

// 西暦→和暦変換
function convert_jpdt($dt,$fmt='年n月j日',$kanji=true) {
	$date = (int)date('Ymd',$dt);
	$year = (int)date('Y',$dt);

	if ($date >= 20190501) {        //新元号元年(2019年5月1日以降)
		$name = "  ?未定";
		$year -= 2018;
	} else if ($date >= 19890108) { //平成元年(1989年1月8日以降)
		$name = "  H平成";
		$year -= 1988;
	} else if ($date >= 19261225) { //昭和元年(1926年12月25日以降)
		$name = "  S昭和";
		$year -= 1925;
	} else if ($date >= 19120730) { //大正元年(1912年7月30日以降)
		$name = "  T大正";
		$year -= 1911;
	} else if ($date >= 18680125) { //明治元年(1868年1月25日以降)
		$name = "  M明治";
		$year -= 1867;
	} else {
		$name = 'AD 西暦';
	}

	if ($kanji) {
		$name = substr($name,3);
		if ($year==1) $year = '元';
	} else {
		$name = ltrim(substr($name,0,3));
	}

	return $name.$year.date($fmt,$dt);
}

SJISのテキストファイルなどで機種依存文字(①,②,③,Ⅰ,Ⅱ,Ⅲ,㈱,㎝ など)を含むデータを読み込むときに、これらの文字が「?」に化けないようにします。例えば、Windowsで作成されたCSVファイルですが、次のように変換してしまうと「?」に化けてしまいますので要注意です。

mb_convert_encoding($str, 'UTF8', 'SJIS');

機種依存文字が無い場合はこれでもうまく行きますが、それが含まれる場合は次のように変換します。

mb_convert_encoding($str, 'UTF8', 'SJIS-win');

もしくは、

mb_convert_encoding($str, 'UTF8', 'CP932');

当たり前だが、UTF-8以外に変換すると化けるし、「~」などダメな場合もあり、オールマイティじゃないので注意です。そうした場合は、予め置換しておくなどの対処が必要かと思います。

XYZ 方式のタイルレイヤで、用意されていないズームレベルのタイルを、それより低い解像度のタイルで代用したいときがあります。地理院地図で細かい地図を表示したくないときや、容量やレンダリング負荷を減らしたい場合に有効なテクニックです。OpenLayers2 は不可能だと思って PHP で対応していたのですが、今回可能だということが分かったので紹介します。

OpenLayers2

serverResolutions にサーバーでサポートする解像度を配列で指定しておくと、それ以外のズームレベルで拡大表示が行われます。解像度は1ピクセル当たりのメートル単位になっており、下記ソースコードの配列で0~18の順で並んでいます。拡大表示を確認するには、下側の解像度をいくつかコメントアウトしてみてください。

var map = new OpenLayers.Map({
	div: "map",
	projection: "EPSG:3857",
	displayProjection: "EPSG:4326",
	numZoomLevels: 19,
	layers: [
		new OpenLayers.Layer.XYZ(
			"GSI Maps", 
			"https://cyberjapandata.gsi.go.jp/xyz/std/${z}/${x}/${y}.png",
			{
				serverResolutions: [
					156543.03390625,
					78271.516953125,
					39135.7584765625,
					19567.87923828125,
					9783.939619140625,
					4891.9698095703125,
					2445.9849047851562,
					1222.9924523925781,
					611.4962261962891,
					305.74811309814453,
					152.87405654907226,
					76.43702827453613,
					38.218514137268066
					19.109257068634033,
					9.554628534317017,
					4.777314267158508,
					2.388657133579254
					1.194328566789627,
					0.5971642833948135
				]
			}
		)
	],
	center: new OpenLayers.LonLat(139, 37).transform("EPSG:4326", "EPSG:3857"),
	zoom: 5
});
OpenLayers3

maxZoom にサーバーでサポートするズームレベルを指定すると、それ以外のズームレベルで拡大表示が行われます。下記ソースコードでは、ズームレベル11以上は10の拡大表示になります。

var map = new ol.Map({
	target: 'map',
	layers: [
		new ol.layer.Tile({
			source: new ol.source.XYZ({
				url: "https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png",
				maxZoom: 10
			})
		})
	],
	view: new ol.View({
		center: ol.proj.fromLonLat([139, 37]),
		zoom: 5
	})
});
Leaflet

Leaflet では maxNativeZoom を指定することで、同様の拡大表示を実現可能です。

PHP で無理やり対応(おまけ)

OpenLayers2 にはそういった機能が無いと思って、PHP で動的に拡大カットするラッパーを組んでしまいました。必要性が無くなったものの、他にも応用が効くコードかと思いますので公開しておきます。

  • シンプルにするために、以下のコードでは入力パラメータをチェックしていませんのでご注意ください
  • $max_zoom にはタイルレイヤで用意されている最大ズームレベルを入れてください
$z = $_REQUEST['z'];
$x = $_REQUEST['x'];
$y = $_REQUEST['y'];

$max_zoom = 12;

if ($z > $max_zoom) {

	// $max_zoom のタイルを切り取って拡大する

	$pw = pow(2,$z - $max_zoom);

	$zz = $max_zoom;
	$xx = floor($x / $pw);
	$yy = floor($y / $pw);

	$dw = 256;
	$sw = floor($dw / $pw);
	$sx = $x % $pw * $sw;
	$sy = $y % $pw * $sw;

	$path = "./layers/tile/{$zz}/{$xx}/{$yy}.png";

	if (file_exists($path)) {

		$src = imagecreatefrompng($path);

		$dst = imagecreatetruecolor($dw,$dw);
		imageAlphaBlending($dst,false);
		imageSaveAlpha($dst,true);
		$transparent = imageColorAllocateAlpha($dst, 0,0,0, 0);
		imageFill($dst, 0,0, $transparent);

		imagecopyresampled($dst, $src, 0,0, $sx,$sy, $dw,$dw, $sw,$sw);

		imagepng($dst);
		exit;

	} else {

		header("HTTP/1.1 404 Not Found");
		exit;
	}

} else {

	// 実ファイルにリダイレクト

	$url = "./layers/tile/{$z}/{$x}/{$y}.png";

	header('Location: '.$url);
	exit;

}
関連ページ

連想配列の配列をループしながら、分類項目でキーブレイクして階層的な HTML に出力したい場合、前後のタグを挟む条件分岐や最終部分の閉じタグの書き方が煩雑になりがちです。こういった処理は度々書いていますが、もう少し簡潔に書けないものかとマニュアルを読んでいたところ、currentnext などの関数が使えそうでしたので試してみました。

例として、都道府県で市町村をグループ化して、セレクトボックスの HTML を出力するコードを書きました。まだ、$opt の初期化が2箇所にあるのが気に入りませんが、タグでラップする部分は分かりやすいと思います。

$dataset = array(
	array('pref'=>'新潟県','city'=>'新潟市'),
	array('pref'=>'新潟県','city'=>'長岡市'),
	array('pref'=>'新潟県','city'=>'上越市'),
	array('pref'=>'新潟県','city'=>'村上市'),
	array('pref'=>'長野県','city'=>'長野市'),
	array('pref'=>'長野県','city'=>'松本市'),
	array('pref'=>'長野県','city'=>'上田市'),
	array('pref'=>'長野県','city'=>'岡谷市'),
	array('pref'=>'石川県','city'=>'金沢市'),
	array('pref'=>'石川県','city'=>'七尾市'),
	array('pref'=>'石川県','city'=>'小松市'),
	array('pref'=>'石川県','city'=>'輪島市'),
);


$code = $opts = '';

while ($row = current($dataset)) {

	$opts.= "<option>{$row['city']}</option>";

	if (!($ner = next($dataset)) || $row['pref'] != $ner['pref']) {
		$code.= "<optgroup label='{$row['pref']}'>{$opts}</optgroup>";
		$opts = '';
	}

}

echo "<select>{$code}</select>";

相対 URL を絶対 URL に解消する関数を探したのですが、ぴったりのものが見つからないので自分で作ってみました。ただし、簡単そうにみえて難しい部類なので、あらゆるパターンで完璧かどうかは保証できません。なお、HTML を処理すると言っても、<a>、<img> の2つのみが処理対象になっています。他のタグを含めるには、正規表現のパターンを追加してください。

ソースコード
function html_absolute_url($base,$html) {

	return preg_replace_callback(

		'/(<a\s+.*?href=")(.+?)(".*?>)'.
		'|(<a\s+.*?href=\')(.+?)(\'.*?>)'.
		'|(<img\s+.*?src=")(.+?)(".*?>)'.
		'|(<img\s+.*?src=\')(.+?)(\'.*?>)/',

		create_function(
			'$matches',
			'$base = unserialize(\''.serialize($base).'\');'.
			'$n = (count($matches)-1)/3-1;'.
			'$url = $matches[$n*3+2];'.
			'$url = absolute_url($base,$url);'.
			'return $matches[$n*3+1].$url.$matches[$n*3+3];'
		),

		$html
	);

}

function absolute_url($base,$url) {

	if (substr($base,-1)!='/') $base.='/';

	$pbase = parse_url($base);

	$purl = parse_url($url);

	if (!isset($purl['scheme'])) {
		if (!isset($purl['host'])) {
			// スキームとホストが省略されている
			if (substr($url,0,1)=='/') {
				// 絶対URL
				$url = $pbase['scheme'].'://'.$pbase['host'].$url;
			} else {
				// 相対URLと判定(./と../の解消は行わない)
				$url = $base.$url;
			}
		} else {
			// スキームのみ省略されている
			$url = $pbase['scheme'].':'.$url;
		}
	} else {
		// 完全なURLと判定
	}

	return $url;
}

html_absolute_url が HTML を処理する関数で、absolute_url が URL を個別に処理する関数です。PHP5.3 未満に対応するために、preg_replace_callback のコールバック関数でクロージャを使用しておらず、変数の値を無理やり展開して渡しています。また、正規表現の中でシングルとダブルクオーテーションをそれぞれ記述していますが、まとめてうまく表現できないものかと思います。もっとスマートに書ける人がいたら、教えて欲しいです。

PHP ソースコードの標準的なエンコーディングは UTF-8 になっており、そのまま記述すればどのような記号も問題ないはずです。しかし、使っているエディタによっては文字化けしたりしますので、一般的な記号以外はあまりお勧めしません。こういった文字は「\uXXXX」のようなコードポイント数値表現を json_decode でデコードすることで記述できます。

「♥」を記述したいとき
json_decode('"\u2665"')