PHPのDateTimeクラスを使って日時の差分を取る時に注意すること

はじめに

とても今さら感のあるネタですが自分用にメモ。

プログラムで日付や時間を比較して何かを処理したい場合は多いはず。

PHPだとDateTimeクラスというのがあって、比較演算子==><などが使えます。

大小比較だけでなく、日時としての差分を取るためのDateTime::diff()メソッドも用意されていて、2つのDateTimeクラスの差分を表すDateIntervalクラスを返します。

DateInterval::format()メソッドを使って差分を確認するのですが、ちょっと癖があると思うので注意が必要です。

使い方は、下記の実験を見て貰えばわかるはず。

実験

翌日との比較

コード

<?php
$datetime1 = new DateTime('2015-11-20');
$datetime2 = new DateTime('2015-11-21');
$interval = $datetime1->diff($datetime2);

// %Rは符号を出力するオプション
echo $interval->format('%R%Y') . "\n"; // 年
echo $interval->format('%R%M') . "\n"; // 月
echo $interval->format('%R%D') . "\n"; // 日
echo $interval->format('%R%H') . "\n"; // 時
echo $interval->format('%R%I') . "\n"; // 分

結果

+00 // 年
+00 // 月
+01 // 日
+00 // 時 ?
+00 // 分 ??

翌月の同日との比較

コード

<?php
$datetime1 = new DateTime('2015-11-20 ');
$datetime2 = new DateTime('2015-12-20');
$interval = $datetime1->diff($datetime2);

echo $interval->format('%R%Y') . "\n"; // 年
echo $interval->format('%R%M') . "\n"; // 月
echo $interval->format('%R%D') . "\n"; // 日
echo $interval->format('%R%H') . "\n"; // 時
echo $interval->format('%R%I') . "\n"; // 分

結果

+00 // 年
+01 // 月
+00 // 日 ?
+00 // 時 ??
+00 // 分 ???

翌月の同日との比較その2

コード

<?php
$datetime1 = new DateTime('2015-11-20 ');
$datetime2 = new DateTime('2015-12-20');
$interval = $datetime1->diff($datetime2);

echo $interval->format('%R%Y') . "\n"; // 年
echo $interval->format('%R%M') . "\n"; // 月
echo $interval->format('%R%a') . "\n"; // 日 Dではなくaオプション(総日数)
echo $interval->format('%R%H') . "\n"; // 時
echo $interval->format('%R%I') . "\n"; // 分

結果

+00 // 年
+01 // 月
+30 // 日 やったぜ!
+00 // 時
+00 // 分

解説

要するに、DateTime::diff()で算出するのは、2つの日付の年月日時分秒のそれぞれの差分ということです。

上記の実験では%Hで時間の差を表示すると、どちらも時間が省略されているので0時と解釈されて、0時と0時の比較なので差は0時間です。

今日の0時と翌日の0時で比較した場合、差として24時間を期待したので、ちょっとはまりました。

唯一、日数のみ%aオプションというのが用意されていて総日数が取得できます。

全部にそういうオプション欲しい。

参考

http://php.net/manual/ja/datetime.diff.php

FuelPHPでデフォルトのタイムゾーンと異なるタイムゾーンの日付を取得したい

はじめに

通常、FuelPHPにおけるタイムゾーンは`fuel/app/config/config.php`の`default_timezone`で設定します。

でも、ある日付を別のタイムゾーン、例えばUTC協定世界時)に変換したい時ってありますよね。

WebAPIの日付指定がUTC限定だったり。

そんな時は、Fuelには便利なクラスが用意されてるので、それを使いましょう。

やりかた

Dateクラスを使います。

コード

タイムゾーンが`Asia/Tokyo`の前提です。

`Date::display_timezonel()`でutcを設定した上で、`Date::format()`の第二引数に`true`を指定すると、`Date::display_timezonel()`の日付を取得できます。

簡単ですね。

$date = \Date::create_from_string('2015/11/11/23:00', "%Y/%m/%d%H:%M");
$date->display_timezone('utc');
echo $date->format('%Y/%m/%dT%H:%M', true);
出力
2015/11/11/14:00

Vagrantのベースボックス更新をするスクリプト

はじめに

最近、仕事でVagrantを使って仮想環境を構築して使うことが多いです。

たいていの場合、最初にここからベースボックスを取得して、パッチ更新、各種ミドルウェアのインストールなどをしてメンバーに配布します。

開発を進める中で、ボックスの内容が大きく変わったときや、プロビジョニングに時間がかかるようになってきたら、vagrant packageでボックスを更新して再配布してます。

再配布時にはvagrant destroy -> vagrant box remove -> vagrant box addという手順でやっていたのですが、オプションを眺めていたらvagrant box add --clean --forcevagrant box removevagrant box add同等ということを知りました。

わかったついでに、黒い画面が苦手な人にも配布したりするので、できればコマンド一発にしたいと思ってシェルスクリプトにしてみました。

ファイル名固定だったり、destroy前に確認してなかったり、改善余地はありますが使えるはず。

json使う方法も検討したけど、バージョニングするほどでもないなってことで見送りました。

使い方

配布したVagrantfileとpackage.boxと同じディレクトリに置いて実行。

私のチームではVagrantfileと一緒にコミットしてます。

コード

#!/bin/sh
if [ ! -e ./Vagrantfile -o ! -e ./package.box ]; then
    echo "not found ./Vagrantfile or ./package.box"
    exit
fi

BOX=`sed -n 's/^.*config.vm.box.*=.*"\(.*\)"/\1/p' ./Vagrantfile`

if [ -z ${BOX} ]; then
    echo "not found config.vm.box parameter in ./Vagrantfile"
    exit
fi

echo "##### ${BOX} setup start\n"

echo "##### vagrant destroy\n"
vagrant destroy -f

echo "##### vagrant box add ${BOX}\n"
vagrant box add -c -f ${BOX} ./package.box

echo "##### ${BOX} setup end\n"

PHPのsimplexml_load_file()でXMLを扱うときに注意すること

はじめに

XMLは、JSONと違って同じ名前の要素を複数持つことができるのですが、PHPのsimplexml_load_file関数で扱うときにちょっとめんどくさかったのでメモ。

もっといい解決策があるかもしれません。

実験

入力XML

list1.xml

<?xml version="1.0" encoding="utf-8"?>
<data>
  <book>
      <title>Swiftではじめる iPhoneアプリ開発の教科書</title>
      <caption>Appleのプログラミング言語、「Swift」バージョン2対応の、iPhoneアプリ作成入門書です。</caption>
      <date>2015/10/30</date>
  </book>
</data>

list2.xml

<?xml version="1.0" encoding="utf-8"?>
<data>
  <book>
      <title>詳細! Swift 2 iPhoneアプリ開発 入門ノート</title>
      <caption>待ったなし! Swift 2</caption>
      <date>2015/11/14</date>
  </book>
  <book>
      <title>Swiftではじめる iPhoneアプリ開発の教科書</title>
      <caption>Appleのプログラミング言語、「Swift」バージョン2対応の、iPhoneアプリ作成入門書です。</caption>
      <date>2015/10/30</date>
  </book>
</data>

list1.xmlはbookタグが1個、list2.xmlはbookタグが2個存在します。

コード1

<?php
$xml1 = simplexml_load_file('./list1.xml');
$xml2 = simplexml_load_file('./list2.xml');

$xml_array1 = json_decode(json_encode($xml1), true);
$xml_array2 = json_decode(json_encode($xml2), true);

print_r($xml_array1);
print_r($xml_array2);

SimpleXMLElementオブジェクトを配列に変換する方法は、公式ドキュメントのコメントを参考に。

json_decodeの第2引数をtrueにすることで連想配列にしています。

結果

temp $ php xml2array1.php
Array
(
  [book] => Array //連想配列
      (
          [title] => Swiftではじめる iPhoneアプリ開発の教科書
          [caption] => Appleのプログラミング言語、「Swift」バージョン2対応の、iPhoneアプリ作成入門書です。
          [date] => 2015/10/30
      )

)
Array
(
  [book] => Array //連想配列の配列
      (
          [0] => Array
              (
                  [title] => 詳細! Swift 2 iPhoneアプリ開発 入門ノート
                  [caption] => 待ったなし! Swift 2
                  [date] => 2015/11/14
              )

          [1] => Array
              (
                  [title] => Swiftではじめる iPhoneアプリ開発の教科書
                  [caption] => Appleのプログラミング言語、「Swift」バージョン2対応の、iPhoneアプリ作成入門書です。
                  [date] => 2015/10/30
              )

      )

)
temp $

book要素が1個の場合と2個の場合で結果が異なります。 これでは、viewに表示するときなどに困りますね。

コード2

<?php
$xml1 = simplexml_load_file('./list1.xml');

$xml_array1 = json_decode(json_encode($xml1), true);
print_r($xml_array1);

foreach($xml_array1 as $key => $value) {
  // 連想配列だったら配列に変換
  if (is_array($value) && array_values($value) !== $value) {
  if (array_values($value) !== $value) {
    $xml_array1 = array($key => array($value));
  }
}
print_r($xml_array1);

結果

temp $ php xml2array2.php
Array
(
  [book] => Array
      (
          [title] => Swiftではじめる iPhoneアプリ開発の教科書
          [caption] => Appleのプログラミング言語、「Swift」バージョン2対応の、iPhoneアプリ作成入門書です。
          [date] => 2015/10/30
      )

)
Array
(
  [book] => Array //要素1の連想配列の配列
      (
          [0] => Array
              (
                  [title] => Swiftではじめる iPhoneアプリ開発の教科書
                  [caption] => Appleのプログラミング言語、「Swift」バージョン2対応の、iPhoneアプリ作成入門書です。
                  [date] => 2015/10/30
              )

      )

)
temp $

これで同じ構造になりました。

おまけ

if (array_values($value) !== $value)は何をしているのか。

公式ドキュメントからこの関数の説明を引用します。

array_values() は、配列 array から全ての値を取り出し、数値添字をつけた配列を返します。

つまり、配列の場合はarray_valuesを使っても何も変わりませんが、連想配列であれは値だけになると!==となるので、連想配列かどうかを判定できます。

参考

SimpleXML 関数

array_values

ランダムにタスクを割り当ててSlackに通知するbotを作った

はじめに

先日公開された下記のスライドを社内に紹介したところ、とあるディレクターさんからランダムにタスクを割り当てるbotについて聞かれたので適当につくってみました。

www.slideshare.net

やったこと

とにかく簡単にタスクの登録がしたいということだったので、Googleスプレッドシートを使うことに。

tasksシートとmembarsシートに、それぞれタスクとメンバーを記入、そこからランダムに1件選択してSlackに通知するだけのシンプル仕様。

これをディレクターさんのGoogleスプレッドシートに登録して、任意の時間をスケジューリングしてもらいました。

かなり雑な作りですが、とりあえずは要望を満たせています。

お試し運用してもらって、もっと改善要望出てきたら対応しようと思います。

コード

function doPost() {
    var tasks = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('tasks');
    var taskMax = tasks.getLastRow();
    var task = tasks.getRange(getRandomNumber(2, taskMax), 1).getCell(1, 1).getValue();

    var members = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('members');
    var memberMax = members.getLastRow();
    var member = members.getRange(getRandomNumber(2, memberMax), 1).getCell(1, 1).getValue();

    var payload = {
        text : "<@" + member + "> " + task + "お願いします",
        username : 'タスク割り当てbot',
        icon_emoji:':dart:',
        link_names: 1
    }

    var options = {
        method: 'post',
        payload : JSON.stringify(payload)
    };
    var url = "https://hooks.slack.com/services/xxxxxxxxxx/xxxxxxxxxx/xxxxxxxxxx";
    var response = UrlFetchApp.fetch(url, options);

    if (response.getResponseCode() != 200) {
        Logger.log(response);
    }
}

function getRandomNumber(min, max) {
    return Math.floor((Math.random() * ((max + 1) - min)) + min);
}

2015年の振り返り

雑に2015年の振り返りした。

ここ数年、1年過ぎるのが加速度的に早くなっている気がする。

お仕事全般

去年よりは、コード書く仕事が多かった。

主にFuelPHPと戯れてた。

Qiita:Team

7月くらいからQiita:Teamが導入されたので、積極的に投稿してる。

社内ということもあって、メモ的なものも投稿したりしてハードルはかなり下げてるつもりなんだけど、まめに投稿してくれる人はまだまだ少ないので、しばらくはQiita:Teamおじさんとして活動していこうと思う。

社内勉強会

FuelPHPのコードリーディングを開催した。

業務が多忙になって参加者が減り、最終的に私個人の活動になってしまったのは残念。

社外勉強会など

勉強会はPHPカンファレンスなど気になったものに少しだけ参加した。

去年よりは多かったけど、家のこともあるから2,3ヶ月に一回くらいが限界かなー。

相変わらず自宅では時間が取りにくいので、1時間くらい早めに出社するのを続けてるけど、やりたいことに対して不足気味。

新しい言語はSwiftを11月から始めてみた。来年はなんかアプリ作ろう。

プライベート

会社以外の時間は、ほぼ息子さんと遊んでばっか。

話せる言葉も増えてきたし、アドベントカレンダーでも書いたけど読み聞かせも喜んでくれるので、一緒に遊ぶのが楽しい。

あと石垣島はサイコーだった。

反省など

Qiita:Teamでの情報共有はそこそこ頑張った気がする。

でも仕事が忙しくなってくると、余裕がなくなってダメ。

FuelPHP勉強会とか、もうちょいなんとかなったんじゃないかなー。

来年の目標

もうちょい自分の時間を捻出して、積ん読の消化とコードの読み書きの時間を増やす。

社内勉強会を復活させる。

Qiita:Teamおじさん業を続ける。

FuelPHPで別フィールドを参照するバリデーションルール

はじめに

このエントリーは、FuelPHP Advent Calendar 2015の11日目です。

FuelPHPのValidationクラスは、用意されたバリデーションルール以外にも独自のルールを簡単に追加することができます。

今までいくつも作ったことがありますが、今回ちょっとだけハマったのでコードを読んで理解した内容を残しておきます。

バージョン

FuelPHP 1.7.2

追加したいルール

2つのフィールドのうち、どちらか一方だけ必須入力としたい。*1
requred_with(ある項目が入力ありなら必須)の変形みたいな感じ。

他にも、選択肢の中からN個以上必須とか、ある項目の入力内容で次の項目のバリデーションが異なるとか、検査のために別のフィールドの内容が必要になるパターン全般に共通です。

うまくいかなかったバージョン

バリデーションメソッド

<?php
public static function _validation_required_which($val, $field)
{
  //現在アクティブなValidationオブジェクトのinput配列を取得
  $input = Validation::active()->input();

  if (!array_key_exists($field , $input)) {
    return false; //ここに引っかかった
  }

  //両方からっぽ
  if (Validation::_empty($input[$field]) and Validation::_empty($val)) {
    return false;
  }

  //両方指定あり
  if (!Validation::_empty($input[$field]) and !Validation::_empty($val)) {
    return false;
  }

  return true;
}

呼び出し側(抜粋)

<?php
$valid = Validation::forge();

$valid->add_callable('Util_Validations');
$valid->add('hoge', 'HOGE')
->add_rule('required_which', 'fuga');
$valid->add('fuga', 'FUGA')
->add_rule('required_which', 'hoge');

if (!$valid->run()) {
  $error = array();

  foreach ($valid->error() as $key => $e) {
    $error[$key] = array(
        'message' => $e->get_message(),
        'type'    => $e->rule
        );
  }

  Session::set('error', $error);
  return Response::redirect('/input');
}

結果

一つめのフィールドである hogeを検査した際に、 if (!array_key_exists($field , $input)) return false; に引っかかってしまいました。

つまり、比較対象である fuga が入力フォームの配列になかったということらしい。

念のため Input::post を確認したところ問題なく取得。

原因

結論としては Validation::run() の仕様を誤解していたためでした。

すべてのフィールドを追加したら、バリデーションを実行することが出来ます。 デフォルトでは $_POST ですが、入力を与えることで、上書きや拡張することが出来ます。 Validation - クラス - FuelPHP ドキュメント

これを読んで、引数無しで Validation::run() を実行すると $_POST、つまり Input::post を渡したのと同等になると理解したのですが、実際には微妙に違っていて、検査するフィールドごとにInput::postから取得していました。

自作メソッドでは Validation::input() を使って input 配列を取得しているのですが、一つめのフィールド hoge の検査時点では hoge 自身しか入っていませんでした。

コードを読んでみる

Validation::run() (抜粋)

<?php
public function run($input = null, $allow_partial = false, $temp_callables = array())
{
  //省略

  $this->validated = array();
  $this->errors = array();
  $this->input = $input ?: array(); // ここが array() ではなく、Input::post だと思ってた
  $fields = $this->field(null, true);
  foreach($fields as $field) // add_field()やadd() したフィールドを順に処理
  {
    static::set_active_field($field);

    // convert form field array's to Fuel dotted notation
    $name = str_replace(array('[', ']'), array('.', ''), $field->name);

    $value = $this->input($name); // input配列から値を取得、配列内になかったらここで追加
    if (($allow_partial === true and $value === null)
        or (is_array($allow_partial) and ! in_array($field->name, $allow_partial)))
    {
      continue;
    }
    try
    {
      foreach ($field->rules as $rule)
      {
        $callback  = $rule[0];
        $params    = $rule[1];
        $this->_run_rule($callback, $value, $params, $field);
      }
      if (strpos($name, '.') !== false)
      {
        \Arr::set($this->validated, $name, $value);
      }
      else
      {
        $this->validated[$name] = $value;
      }
    }
    catch (Validation_Error $v)
    {
      $this->errors[$field->name] = $v;

      if($field->fieldset())
      {
        $field->fieldset()->Validation()->add_error($field->name, $v);
      }
    }
  }

  //省略
  return empty($this->errors);
}

Validation::input()

<?php
public function input($key = null, $default = null)
{
  if ($key === null)
  {
    return $this->input; // 引数なしだと現在のinput配列を返す
  }

  // key transformation from form array to dot notation
  if (strpos($key, '[') !== false)
  {
    $key = str_replace(array('[', ']'), array('.', ''), $key);
  }

  // if we don't have this key
  if ( ! array_key_exists($key, $this->input))
  {
    // it might be in dot-notation
    if (strpos($key, '.') !== false)
    {
      // check the input first
      if (($result = \Arr::get($this->input, $key, null)) !== null)
      {
        $this->input[$key] = $result;
      }
      else
      {
        $this->input[$key] =  $this->global_input_fallback ? \Arr::get(\Input::param(), $key, $default) : $default;
      }
    }
    else
    {
      // do a fallback to global input if needed, or use the provided default
      $this->input[$key] =  $this->global_input_fallback ? \Input::param($key, $default) : $default; //ここでInput::paramから取得
    }
  }

  return $this->input[$key];
}

global_input_fallbackというのは、Validationクラスの設定で、デフォルトtrue設定。
なので、input配列に存在しなかったらInput::paramから探します。*2

true にした場合で、入力された配列に値が見つからなかった場合、値は Input::param になります。 Validation - クラス - FuelPHP ドキュメント

呼び出し時にバリデーション対象を探して内部の配列に追加というのは、個人的にはちょっと行儀がよくない動きのように感じましたがどうなんでしょう。
使わない(ルールを適用しない)フィールドがたくさんある場合は効率いいのかな。

解決策その1

run(Input::post()) のように、引数に明示的にPOSTデータを渡してやれば実行時に全て保持しているので失敗しません。

でもルールによって呼び出し側で考慮しないといけないので、イマイチな気がします。

解決策その2

バリデーションメソッドを改良する。

<?php
public static function _validation_required_which($val, $field)
{
  $valid = Validation::active();

  if ($valid->_empty($valid->input($field)) and $valid->_empty($val)) {
    return false;
  }

  if (!$valid->_empty($valid->input($field)) and !$valid->_empty($val)) {
    return false;
  }

  return true;
}

Validation::active()->input(); としていたのを、Validation::active(); としてアクティブなインスタンスを取得して全てインスタンス経由で操作するように変更。

Validation::input() を引数指定で呼び出すことにより指定フィールドが input 配列になかった場合に Input::post を探しに行くようになり、期待どおりの動作が実現できました。

おまけ

Validationクラスで扱えるのは連想配列ということになっている*3のですが、今回の流れの途中でコードを読んでみたらちょっと違いました。

以下のサンプルの1つ目は動きますが、2つ目は add() した時に例外が発生します。

<?php
$valid = \Validation::forge();
$valid->add(1, 'hoge')->add_rule('required');
$valid->run(array('a', 'b'));
<?php
$valid = \Validation::forge();
$valid->add(0, '1st element')->add_rule('required');
$valid->run(array('a', 'b'));

これは、Validation::add() の実体である Fieldset::add()empty() を使ってチェックしているから。

<?php
public function add($name, $label = '', array $attributes = array(), array $rules = array())
{
  //省略

  if (empty($name) || (is_array($name) and empty($name['name'])))
  {
    throw new \InvalidArgumentException('Cannot create field without name.');
  }

  //省略

つまり、配列であっても0番目以外のフィールドには使えます。

バリデーションで連想配列ではなく配列を使いたいシーンがあるのかわからないですが、対策するならコアクラスを拡張して、 Validation::add()連想配列のみ通す、または Fieldset::add()Validation::_empty()みたいに 0 と '0' を通すのどちらかですかね。

参考

Validation クラス

*1:ラジオボタン使えというのは、とりあえず無しでおねがいします

*2:なんで Input::post じゃなくて Input::param なんでしょうね。

*3:1.7のドキュメントにはないけど、1.8のドキュメントのrun()の説明にある