20201011のPHPに関する記事は15件です。

Laravel Task Schedulerで動的にタスクを実行する

Laravelには標準でタスクスケジューラーがあるため指定した時刻に、指定した処理を実行したい場合にはかなり簡単にかける。

Kernel.php
<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    /**
     * The Artisan commands provided by your application.
     *
     * @var array
     */
    protected $commands = [
        //
    ];

    /**
     * Define the application's command schedule.
     *
     * @param \Illuminate\Console\Scheduling\Schedule $schedule
     * @return void
     */
    protected function schedule(Schedule $schedule)
    {
        //ここにコマンドを呼び足す処理と、時刻の指定その他条件を書くだけ 
        $schedule->command('backup:clean')->dailyAt('3:00')->environments('production')->onOneServer();
    }

    /**
     * Register the commands for the application.
     *
     * @return void
     */
    protected function commands()
    {
        $this->load(__DIR__ . '/Commands');

        require base_path('routes/console.php');
    }
}

基本的にはコマンドを作成したりして、毎分実行されるscheduleメソッドの中に追記していけばいいが、困るのはDBなどから設定をとってきてそれらの時刻設定などをもとに実行する処理がある場合。この場合においてもLaravelであればかなり簡単にかける。

Model

単純にcron書式の文字列だけ持つモデルをサンプルとして作成(ie, ['id' => 1, 'cron' => '* * * * *'])

Scheduler.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Scheduler extends Model
{
    protected $fillable = ['cron'];
}

Command

動的に設定された時刻に実行するテスト用のコマンドを生成、単純にログを書き込むだけ。

sample.php
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class Sample extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'log:test';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Command description';

    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
       Log::info('scheduler called');
    }
}

動的スケジュール

Kernel.phpにDBからとってきた値に基づいて、コマンドを呼び出すように処理を追記。

Kernel.php
<?php

namespace App\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;

class Kernel extends ConsoleKernel
{
    /**
     * The Artisan commands provided by your application.
     *
     * @var array
     */
    protected $commands = [
        //
    ];

    /**
     * Define the application's command schedule.
     *
     * @param \Illuminate\Console\Scheduling\Schedule $schedule
     * @return void
     */
    protected function schedule(Schedule $schedule)
    {
        //DBから取得
        $schedulers = Scheduler::all();
        foreach($schedulers as $scheduler) {
          //普通にコマンドを呼び出すが、時刻設定の部分にcronを使用して、取得したcron書式の文字列を渡す。
          $schedule->command('log:test')->cron($scheduler->cron);
        }
    }

    /**
     * Register the commands for the application.
     *
     * @return void
     */
    protected function commands()
    {
        $this->load(__DIR__ . '/Commands');

        require base_path('routes/console.php');
    }
}

こんな感じでログ取れていればOK

storage/logs/laravel-2020-10-10.log
[2020-10-11 23:33:14] development.INFO: scheduler called  

まとめ

今回は、汎用性が高そうだったので、時刻指定の部分にcronを使用したが、用件に合わせてdailyAtなどの文字列を渡してもいい。
DBからスケジュールの情報を持ってきてコマンドを呼び出すという処理をschedulerメソッドの中に書いているため、呼び出しメソッドが増えていくと、可読性が下がるし、重くなる。どんな方法でハンドリングしていくのがいいかまでは試せていないので悪しからず。

結構動的に処理しなければいけないものが多かったり、重複実行してしまうのがまずかったりする場合にはwithoutOverlappingを使用したり、本番のみ実行させたい時にはenvironments、単一サーバー上で実行したい場合にはonOneServerなどをチェーンして書くことも可能。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Content-Typeとattachmentを指定してファイルダウンロードする方法[PHP]

この記事では、HTTPレスポンスとしてファイルをダウンロードさせたいときのheaderの指定方法について調べたことをまとめています。これまでなんとなく記述していたContent-TypeやContent-Dispositionについて触れています。

結論から書くと、ファイルをダウンロードさせたいときには次のようにheaderを書くと良さそうです。

  • Content-Typeに適切なファイル種別を入れる
  • Content-Dispositionをattachmentにする

私の理解では上記のような結論になりましたが、間違いなどありましたらご指摘いただけると大変嬉しいです。

JSONファイルがブラウザに表示されずにダウンロードされる

この記事を書くきっかけになったのが、下記のコードです。

index.php

<?php

$items = [
    ['name' => 'banana', 'price' => 200],
    ['name' => 'orange', 'price' => 150],
];

// jsonに変換
$data = json_encode($items, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
// Content-Typeをjsonに指定
header("Content-Type: applicarion/json");

echo $data;

ブラウザから上記のファイルにアクセスすると、なぜかテキストファイルがダウンロードされてしまいました。

原因がしばらくわからなかったので、この機会にheaderのContent-Typeについてちゃんと知ろうと思ったのが、この記事を書いたきっかけです。

テキストファイルがダウンロードされてしまう原因

結論から書くと、Content-Typeのファイル種別をタイプミスしていたことが上記の挙動の原因でした。

- header("Content-Type: applicarion/json"); // 修正前 ファイルがダウンロードされる
+ header("Content-Type: application/json"); // 修正後 ブラウザに表示される

echo $data;

applicationtrになっており、そのせいでファイルがダウンロードされてしまっていました。

のちほど詳しく書きますが、Content-Typeはファイルの種別を表す項目らしく、ここにtext/htmlapplication/pdfなど、ブラウザで表示可能なファイルが指定されている場合は表示するのが正常な挙動のようです。Content-Typeがapplication/jsonの場合も、一般的には次のようにブラウザに表示されます。

スクリーンショット 2020-10-11 12.42.38.png

※ 画像はChromeのJSON Formatterという拡張機能で整形しています

しかし、このContent-Typeに未知のファイル種別が指定されていると、ブラウザ側の仕様でファイルをダウンロードさせてしまうことが多いようです。つまり、Content-Type: applicarion/jsonとタイプミスした影響で、ブラウザに未知のファイル種別と誤認されてしまっていたようです。

ファイルをダウンロードさせるときのheaderの書き方

ファイルをダウンロードさせたい場合、上記のようにContent-Typeに未知のファイル種別を指定する以外にも、いくつか方法があるようです。参考にさせていただいたこちらの記事では、次のように書かれていました。

ダウンロード形式に関わるヘッダは、 Content-Type Content-Disposition の2つがあり、調べていると以下の3つの指定がよく使われているのが見つかります。

  • Content-Type: application/force-download
  • Content-Type: application/octet-stream
  • Content-Disposition: attachment

https://shkn.hatenablog.com/entry/2019/03/22/235503

Content-Typeをapplication/force-downloadにする方法

ファイルをダウンロードさせたいとき、Content-Type: application/force-downloadという指定の仕方は慣習的によく使われるようですが、実はapplication/force-downloadというMIMEタイプ(ファイル種別)は存在しないようです。

つまり、さきほど私がファイル種別をタイプミスしたときと同様に、未知のMIMEタイプが指定されたときはファイルをダウンロードする挙動になる、というブラウザの仕様を利用したダウンロードに過ぎないようです。application/force-downloadでなく、hogeなど任意の文字列をContent-Typeに指定してもファイルはダウンロードされました。

// ファイルがダウンロードされる
header("Content-Type: application/force-download");

echo $data;

この方法でダウンロード処理を実現することは可能なのですが、本来はMIMEタイプを記載すべき場所をダウンロード処理のために使っているという点に違和感を覚えました。

後述するContent-Dispositionは、ファイルをWEBページとして表示するか、ダウンロードさせるかを指定するためのheaderなので、こちらを使用するほうが自然なように思えました。

Content-Typeをapplication/octet-streamにする方法

application/octet-streamは、未知のファイルを表すMIMEタイプのようです。

application/octet-stream

これは、バイナリファイルでは既定です。これは未知のバイナリ形式のファイルを表すものであり、ブラウザーはふつう実行したり、実行するべきか確認したりしません。これらは Content-Disposition ヘッダーの値に attachment が設定されたかのように扱い、「名前を付けて保存」ダイアログを提案します。

https://developer.mozilla.org/ja/docs/Web/HTTP/Basics_of_HTTP/MIME_types

さきほどのapplication/force-downloadは公式には存在しないMIMEタイプでしたが、こちらのapplication/octet-streamは公式に定義されたMIMEタイプのようです。

// ファイルがダウンロードされる
header("Content-Type: application/octet-stream");

echo $data;

application/octet-streamは公式なMIMEタイプらしいので、上記のような記述は間違いではないと思いますが、しかし、たとえばJSONファイルをダウンロードさせたいとき、MIMEタイプはapplication/jsonとわかっているのに、ダウンロードのためだけにapplication/octet-streamと書くのはやはり不自然な感じがします。

Content-Dispositionをattachmentにする方法

Content-Dispositionは、ファイルをWEBページとして表示するか、ダウンロードさせるかを指定するためのheaderです。

挙動
inline(デフォルト) ウェブページとして表示する
attachment ファイルをダウンロードする(「名前を付けて保存」ダイアログを表示する)

また、filenameというパラメータでダウンロードファイルの名前のデフォルト値を設定することも可能です。

// ファイルがダウンロードされる
header('Content-Disposition: attachment; filename="任意のファイル名.json"');

echo $data;

Content-Dispositionは、もともとファイルの扱いを指定するためのheaderなので、これを利用してダウンロード処理を実現するのが最も自然に思えます。Content-Typeと併用も可能なので、正しいMIMEタイプを通知しつつ、ダウンロードさせることができます。

ブラウザーの互換性

Chrome Edge Firefox Internet Explorer Opera Safari Android webview Android 版 Chrome Android 版 Firefox Android 版 Opera iOSのSafari Samsung Internet
Content-Disposition 完全対応あり 完全対応12 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり 完全対応あり

https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition

上記を見た限り、互換性も問題なさそうです。

curlコマンドで挙動の違いを確認

これまで紹介したContent-TypeやContent-Dispositionなどのheaderを変更しながら、レスポンスがどのように変化するのかを確かめてみました。確認にはcurlコマンドを使いました。

ちなみに、curlは「シーユーアールエル」とも読まれますが、公式に「カール」という発音が書かれているようです。

普通にechoした場合

index.php

<?php

echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: text/html; charset=UTF-8 // HTMLファイルとして出力

スクリーンショット 2020-10-11 12.00.05.png

ウェブページとして表示されました。

Content-Type: text/plainを指定した場合

index.php

<?php

header("Content-Type: text/plain");
echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: text/plain;charset=UTF-8 // テキストファイルとして出力

スクリーンショット 2020-10-11 12.00.26.png

ウェブページとして表示されましたが、CSSスタイルに少し違いがありました。

word-wrapプロパティ

word-wrapプロパティは、W3Cで審議中の仕様をInternet Explorerが独自に採用したもので、 表示範囲内に収まりきらない単語がある場合に、単語の途中で改行するかどうかを指定するに使用します

http://www.htmq.com/style/word-wrap.shtml

white-spaceプロパティ

white-spaceプロパティは、
1.ソース中のホワイトスペース(連続する半角スペース・タブ)の表示方法
2.ソース中の改行の表示方法
の2点を指定するプロパティです。 この2つの表示方法の組み合わせパターンの数だけ値が用意されている、と考えると理解しやすいかもしれません。

~~ 中略 ~~

pre-wrap

ソース中のホワイトスペースをそのまま表示
ソース中の改行をそのまま表示
ボックスサイズが指定されている場合にはそれに合わせて自動改行する

http://www.htmq.com/style/white-space.shtml

Content-Type: application/octet-streamを指定する場合

index.php

<?php

header("Content-Type: application/octet-stream");
echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: application/octet-stream // index.phpというファイル名でダウンロードされる

Chromeブラウザで確かめた結果、index.phpというファイル名でダウンロードされました。

Content-Disposition: attachmentを指定する場合

index.php

<?php

header('Content-Disposition: attachment; filename="hello.txt"');
echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: text/html; charset=UTF-8 // HTMLファイルとして認識される
~~ 中略 ~~
< Content-Disposition: attachment; filename="hello.txt" // 指定されたファイル名でダウンロード

Content-Type: application/octet-streamを利用したときはindex.phpというファイル名でダウンロードされましたが、Content-Disposition: attachmentを利用したときは、ちゃんとhello.txtという指定したファイル名でダウンロードされることが確認できました。

しかし、細かいことかもしれませんが、Content-Type: text/htmlと認識されている点が気になりました。

Content-TypeとContent-Dispositionの両方を指定する場合

index.php

<?php

header('Content-Disposition: attachment; filename="hello.txt"');
header("Content-Type: text/plain");
echo "hello, world.";
-> % curl --http1.1 --get -v http://localhost/index.php
~~ 中略 ~~
< Content-Type: text/plain;charset=UTF-8 // プレーンテキストとして認識される
~~ 中略 ~~
< Content-Disposition: attachment; filename="hello.txt"

このようにContent-TypeとContent-Dispositionの両方を指定すると、正しいMIMEタイプが認識され、かつダウンロード処理やファイル名のデフォルト値も意図した通りに実現できました。

少し面倒ですが、ファイルをダウンロードさせたいときはこのように記述するのが良さそうです。

まとめ

この記事で書いたことをまとめると次のようになります。

  • Content-Typeをタイプミスすると表示可能なテキストファイルでもダウンロードされる
  • ブラウザは未知のMIMEタイプを認識すると実行せずにファイルをダウンロードする
  • ファイルをダウンロードさせたいときはContent-Typeにapplication/force-downloadapplication/octet-streamがよく使われる
  • Content-Dispositionをattachmentにすると任意のファイル名でダウンロードさせることができる

ファイルをダウンロードさせたいとき、Content-Typeを利用する方法とContent-Dispositionを利用する方法、どちらでも実現できますが、Content-TypeでMIMEタイプを指定し、Content-Dispositionでファイルの処理方法を指定するのが本来の分担のようです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravelルーティングから起動まで

【概要】

1.プロジェクトの作成

2.ルーティング

3.起動方法

1.プロジェクトの作成

$ composer create-project laravel/laravel 名前 --prefer-dist

名前に作成したいファイル名を記載するとその名前でファイルが生成されます。任意のファイルの中にで作成してください。

2.ルーティング

routes/web.php
<?php
Route::get('/' , function(){
   return view('****');
});

1.で作成した際にテンプレートで上記は記載されています。'/'はhttp://localhost:8000/へアクセスするようになっており、"welcom"はviews/welcome.blade.phpのプログラムが反映されます。3.で起動した際にLaravelが出てきます。

公式ではなく直にちゃんとルーティングが反映されているかを確認する方法としてweb.phpに直接viewを反映する方法があります。

routes/web.php
Route::get('hoge' , function(){
  return '<html><body>HOGE<p>HELLO,HOGE
          </body></html>';

上記のようにコーディングすると、
http://localhost:8000/HOGEにアクセスした際に、
”HOGE”と”HELLO,HOGE”が記載されているページを表示できます。

3.起動方法

$ php artisan serve

これで起動ができます。
phpの場合は何も設定していなければ、"localhost:8000"でブラウザに表示できます。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PHPバージョンアップ時にphp7compatibilityを使ってコード互換性を確認する

PHPバージョンアップ時にphp7compatibilityを使うと互換性を簡単にチェックする事ができます。
php7compatibilityを使う時、環境セットアップに多少準備が必要になるのですが、docker環境なら、すでに作成済みのdockerイメージが公開されているので、だいぶお手軽に環境を構築して確認する事ができます。

例えば以下はPHP7.1に対しての互換性を確認する際のコマンド例。
--ignore=vendorでvender配下をチェックから除外する事が出来ます。

docker run --rm -v $(pwd):/app vfac/php7compatibility 7.1 --extensions=php --ignore=vendor .

参考

vfalies/php7compatibility

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CodeIgniter3でci-phpunit-test 2.xを利用したUnitTestいろいろ④(Coverage Reportを出力する)

概要

ci-phpunit-testで、色々と実装してきたphpunitのテストコードに対して、Coverage Reportを出力していきたいと思います。

phpunit実行時に出ていた出力内容で

以下のようにphpunitを実行すると、Warningが出ておりました。

# cd /var/www/html/codeigniter3/application/tests
# ../../vendor/bin/phpunit --testdox

PHPUnit 9.4.0 by Sebastian Bergmann and contributors.

Warning:       No code coverage driver available
Warning:       Your XML configuration validates against a deprecated schema.
Suggestion:    Migrate your XML configuration using "--migrate-configuration"!

Gmo_api_model_test
 ✔ G m o決済リンク urlが正常に取得できること
 ✔ G m o決済リンク url取得失敗の詳細が exceptionで伝播されること
 ✔ G m oカード会員編集 urlが正常に取得できること

Gmo_result_notification_logs_model_test
 ✔ ログの登録が正常に完了すること
 ✔ 登録済みのログが削除できること
 ✔ 登録した順序の降順で取得できること

Time: 00:00.047, Memory: 8.00 MB

OK (6 tests, 29 assertions)

No code coverage driver availableは、xdebugが使えるようになっていないことを表しているようです。

xdebugをインストール

# dnf install php74-php-pecl-xdebug

以下を追記

/etc/php.ini
[xdebug]
zend_extension=xdebug

これでphpunitを実行すると以下のエラーが発生するようになってしまった。

A PHP Error was encountered

Severity:    Core Warning
Message:     Failed loading Zend extension 'xdebug' (tried: /usr/lib64/php/modules/xdebug (/usr/lib64/php/modules/xdebug: cannot open shared object file: No such file or directory), /usr/lib64/php/modules/xdebug.so (/usr/lib64/php/modules/xdebug.so: cannot open shared object file: No such file or directory))
Filename:    Unknown
Line Number: 0

Backtrace:

どうやら、先ほどphp.iniに追記したzend_extension=xdebugという設定が、xdebug.soを参照する設定のようで、そのxdebug.soファイルが見つからなくてエラーになっているようです。
私の環境はphp:remi-7.4を利用しているため、標準リポジトリからセットアップされるphpのモジュールとはディレクトリ構成が違うようです。

xdebug.soがどこにいるのか探してみます。

# find / -name "xdebug.so"
/opt/remi/php74/root/usr/lib64/php/modules/xdebug.so

上記フォルダに存在しているようなので、php.iniファイルを以下のように修正します。

/etc/php.ini
[xdebug]
;zend_extension=xdebug
zend_extension="/opt/remi/php74/root/usr/lib64/php/modules/xdebug.so"

再度phpunitを実行するとエラーが解消され、以下のような出力内容に変わりました。

# ../../vendor/bin/phpunit --testdox
PHPUnit 9.4.0 by Sebastian Bergmann and contributors.

Warning:       Your XML configuration validates against a deprecated schema.
Suggestion:    Migrate your XML configuration using "--migrate-configuration"!

Gmo_api_model_test
 ✔ G m o決済リンク urlが正常に取得できること
 ✔ G m o決済リンク url取得失敗の詳細が exceptionで伝播されること
 ✔ G m oカード会員編集 urlが正常に取得できること

Gmo_result_notification_logs_model_test
 ✔ ログの登録が正常に完了すること
 ✔ 登録済みのログが削除できること
 ✔ 登録した順序の降順で取得できること

Time: 00:00.294, Memory: 16.00 MB

OK (6 tests, 29 assertions)

Generating code coverage report in Clover XML format ... done [00:00.297]

Generating code coverage report in HTML format ... done [00:00.110]

この状態でWebアプリにアクセスすると、設定した内容が反映されないみたいなので、php-fpmのサービスは再起動しておきます。

# systemctl restart php-fpm

上記までの対応でCoverage Reportが無事出力されたようです。
出力先は以下になってました。

/var/www/html/codeigniter3/application/tests/build/coverage

Coverage Reportをブラウザで見れるようにする

出力されたCoverage ReportはHTML形式でも出力されていますので、ブラウザで見れるようにします。

# cd /var/www/html/codeigniter3/public/
# ln -s /var/www/html/codeigniter3/application/tests/build/coverage coverage

シンボリックリンクしか思いつかなかったので、閲覧可能な状態にしてブラウザでアクセス

https://tk2-999-99999.vs.sakura.ne.jp/coverage/

スクリーンショット 2020-10-11 18.13.41.png

Dashboardはこんな感じ

screencapture-tk2-253-36402-vs-sakura-ne-jp-coverage-dashboard-html-2020-10-11-18_15_08.png

これでどの程度テストコードが実装できたのかビジュアル化できました。
カバレッジ100%目指す!なんてことはしませんが、これでテストコードを実装するモチベーションが上がってきたんで、これからちょいちょいテストコード作って、ある程度満足したら続きの機能を作り込んで行こうかなと思います。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

php74 コアダンプ

似たようなの?
https://lists.freebsd.org/pipermail/freebsd-ports/2019-January/115381.html

root@pt2:/usr/ports # php -v
PHP 7.4.11 (cli) (built: Oct 11 2020 15:23:02) ( NTS )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
    with Zend OPcache v7.4.11, Copyright (c), by Zend Technologies
セグメントエラー(coreを出力しました)

gdb

#0  0x291883dc in openssl_lh_strcasehash () from /usr/local/lib/libcrypto.so.11
(gdb) bt
#0  0x291883dc in openssl_lh_strcasehash () from /usr/local/lib/libcrypto.so.11
#1  0x29192f8b in OBJ_NAME_cleanup () from /usr/local/lib/libcrypto.so.11
#2  0x291880d5 in OPENSSL_LH_delete () from /usr/local/lib/libcrypto.so.11
#3  0x29192b60 in OBJ_NAME_remove () from /usr/local/lib/libcrypto.so.11
#4  0x29192f04 in OBJ_NAME_cleanup () from /usr/local/lib/libcrypto.so.11
#5  0x29188337 in OPENSSL_LH_doall () from /usr/local/lib/libcrypto.so.11
#6  0x29192e66 in OBJ_NAME_cleanup () from /usr/local/lib/libcrypto.so.11
#7  0x2917e9c1 in evp_cleanup_int () from /usr/local/lib/libcrypto.so.11
#8  0x29185d0d in OPENSSL_cleanup () from /usr/local/lib/libcrypto.so.11
#9  0x287d6fad in __cxa_finalize () from /lib/libc.so.7
#10 0x287710dc in exit () from /lib/libc.so.7
#11 0x082c5fe4 in main ()
#12 0x080671ef in _start1 ()
#13 0x08067178 in _start ()
#14 0x00000000 in ?? ()
(gdb)

前もはまったがopensslのバージョンが変わる?とimapモジュールが落ちるようになる。
pkg delete php74-imap
とすると落ちなくなる。

php74-imapがpanda-cclientを使っているが、opensslをバージョンアップしてもpanda-cclientがバージョンアップされないのが原因っぽい。

panda-cclientを入れなおせばなるはず。
(もうはまらないようにimapは使わないので消した)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【初心者向け】郵便番号で住所検索をする外部APIを叩く

はじめに

実務でいつ使用してもいいように、ローカル環境で外部提供のAPIを叩く練習をしていきます。
あと、長いこと使用していないAjaxの復習もかねて。

今回は会員登録時に住所入力をサポートする、最近のデファクトスタンダードな仕様を目指してフォームの作成をしていきます。

使用API

郵便番号検索API
http://zipcloud.ibsnet.co.jp/doc/api

※画面下部にAPI利用規約がありますので、使用する場合は一読するようにお願いします。

〜以下引用(2020年10月11日)〜
郵便番号検索APIは、日本郵便が公開している郵便番号データを検索する機能をRESTで提供しています。
現在使用しているデータは、「2020年9月30日更新分の全国一括データ(加工済バージョン)」です。

環境+使用ライブラリなど

macOS Catalina 10.15.7
MAMP(Apache + MySQL)
PHP 7.3.11
jQuery(Ajax通信に使う)
Bootstrap(別に必要ではない)

今回のゴールとする仕様

  • 半角数字で7ケタの郵便番号を入力する。
  • 「住所検索」ボタンを押す。
  • 入力した郵便番号を使って外部APIを叩く
  • レスポンスで受け取った情報をもとに、住所がフォームに自動入力される。
  • クリアボタンを押すとフォーム全てがリセットされる(サービスにおいては必要なさそう)

完成画面

image.png

作成コード

sample.php
<?php

  session_start();  // 今回の説明には不要

?>

<!DOCTYPE html>
<html lang="ja">
<head>
  <title>郵便番号検索API</title>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css" integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">
  <!-- Optional JavaScript -->
  <!-- jQuery first, then Popper.js, then Bootstrap JS -->
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.min.js" integrity="sha384-OgVRvuATP1z7JjHLkuOU7Xw704+h835Lr+6QL9UvYjZE3Ipu6Tp75j7Bh/kR0JKI" crossorigin="anonymous"></script>
  <script src="postal_api.js"></script>
</head>
<body>
  <div class="container col-6">
    <h1 style="text-align: center; margin-top: 2.4rem; margin-bottom: 1.6rem">入力フォーム</h1>
    <div>
      <form method="post" action="#" style="width: fit-content; margin: 0 auto;">
        郵便番号<br />
        <input type="text" name="zip_code" style="width:100px" id="zip_code">
        <input type="button" value="住所検索" id="search_address_btn">
        <input type="button" value="クリア" id="search_clear_btn">
        <br />
        都道府県<br />
        <input type="text" name="address1" style="width:500px" id="address1"><br />
        市区町村<br />
        <input type="text" name="address2" style="width:500px" id="address2"><br />
        その他<br />
        <input type="text" name="address3" style="width:500px" id="address3"><br />
        建物名など<br />
        <input type="text" name="address4" style="width:500px"><br />
        <br />
        <div class="submit_button_right" style="text-align: right;">
          <input type="submit"><br />
        </div>
      </form>
    </div>
  </div>

</body>
</html>

見た目を少しででも整えるために
Bootstrap関係のタグが多いですが、以下のタグだけでも動作確認済みです。

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script> //ajax通信用
<script src="postal_api.js"></script> //jsファイルの読み込み


読み込んでるjsのスクリプトはこんな感じです。

postal_api.js
$(document).ready(function(){

    // 住所検索ボタンを押すと外部apiを叩く処理が走る。
    $('#search_address_btn').click(function() {
        $.getJSON('http://zipcloud.ibsnet.co.jp/api/search?callback=?',
            {
            zipcode: $('#zip_code').val()
            }
        )
        .done(function(data) {
            if (!data.results) {
                alert('該当の住所がありません');
            } else {
                let result = data.results[0];
                $('#address1').val(result.address1);
                $('#address2').val(result.address2);
                $('#address3').val(result.address3);
            }
        }).fail(function(){
            alert('入力値を確認してください。');
        })
    })

    // クリアボタンを押すと、フォームの中身がリセットされる。
    $('#search_clear_btn').click(function(){
        $('#zip_code').val('');
        $('#address1').val('');
        $('#address2').val('');
        $('#address3').val('');
    })
})

記事を書いてて、ふと
"クリアボタンとかいらなくね?"
と、思ったのは私だけでしょうか。また検索すればいいだけですよね。
なんなら、間違って押してしまったユーザーにとってはストレスでしかありません。

今回は、簡易的なフォームを作って動作を確認するためだけに実装しましたが、
実際のサービスでフォームの内容を考える場合はきちんとそのあたりも設計した方がいいですね。

あと、このままのコードで動作を実行すると
Chrome検証ツールのConsoleに、サードパーティーやCSRFによるリークに関する注意が issueとして表示されます。
今回は個人的な動作確認なので、割愛。

データ取得に関する説明

APIの提供元でサンプルの取得データが確認できます。
image.png

一部見切れてしまっていますが、"message","results","status"という要素があります。
今回使いたいのはresultsの中身です。

            } else {
                let result = data.results[0];
                $('#address1').val(result.address1);
                $('#address2').val(result.address2);
                $('#address3').val(result.address3);
            }

配列は[0]から始まるので、一旦、中身をresultという変数に置き換えます。
そして、レスポンスサンプルで確認できるように、address1〜address3をそれぞれフォームのvalueに入れてあげればいいわけです。

注意事項

今回の例は配列の最初の要素を住所候補としてブラウザに返していますが、
場所によっては、同じ郵便番号でも住所が3パターン存在したりします。

例:0790177
image.png

こういった場合は押した回数で分岐させるような構造がいいんでしょうか。
Amazonで試してみたら7ケタ目を入力した直後に住所検索の処理が走るので、上記の例だと上美唄町しか出てきませんでした。AmazonのAPIでは候補が一つだけなんですかね。

実装中に詰まった部分

  • 久しぶりにAjax触ったので、そもそも最初は流れが思い出せなかった。
  • JSON形式でデータを受け取るにあたって、同じドメイン上のAPIしか叩いたことがなかったので、クロスドメインでAPIを叩く処理を実装する部分に時間がかかった。(記述はシンプルなので、検索して情報を見つけてからは大したことなかった)

最後に

これで住所入力の手間が省けるフォーム部分が完成したので、実際使用する場合には他の項目と一緒にPOSTでphpスクリプトに送ってやればいいわけですね。

今回、①外部APIを叩く ②Ajaxの復習 という目的が無事達成できたので良かったです。
今後もこれぐらい軽めに取り掛かれる実装をプライベートでもガンガンやっていきたいと思います。

ここまで読んでいただきありがとうございました。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravelで画像をリサイズ&勝手に回転しないようにする(EXIFも削除)

はじめに

Laravelで画像投稿機能を作ったは良いけど、

 「投稿される画像が大きかったり小さかったりする..」
 「アップロード後に画像が勝手に回転してしまう..」
 「画像に埋め込まれた隠し情報が危険って聞いたけど..」

そんな問題を解消していきます。

画像の大きさの問題はCSSで指定すれば表示は小さくできますが、
CSSは表示を変えるだけで保存される画像は大きいままです。
これではデータベース周りに負荷がかかってしまいます。

また画像にはEXIF情報というものが埋め込まれていて、撮影したスマートフォンの情報だったり画像の向き、さらには撮影した場所などが記録されているものもあります。

これらを簡単に同時に最適化する方法をシェアしていきます。

やること

以下の手順で行います。
 ① 現状のEXIF情報をみてみる
 ② InterventionImageをインストールする(Composerを使用)
 ③ InterventionImageを使えるようにする(Configの設定をする)
 ④ Controllerで実際に画像処理を行う

実際にやってみる

① まず現状の画像のEXIF情報をみてみる

現在画像処理を行っているControllerにおいてdd()メソッドを実行します。
(ちなみにdd()は「dump and die」の略なんですね)

〇〇Controller.php
dd($image->exif()); // $imageが画像データです

これでブラウザにEXIF情報が表示できます。
ずらっと配列で表示されて、撮影した機種など色々な情報が格納されているのがわかると思います。。

② InterventionImageをインストールする

この余計なEXIFの削除とリサイズ処理と回転(画像の向き)の最適化処理を同時に簡単にできるのがInterventionImageというものです。
PHPの画像処理ライブラリであるGDとImagickをサポートするパッケージで、
Laravelで画像処理をするならこれ一択、という位置付けのものですね。
GDやImagickを簡単に扱うことができます。

公式はこちら
http://image.intervention.io/getting_started/installation#laravel

以下のコマンドを実行してインストールします。

$php composer.phar require intervention/image


③ InterventionImageを使えるようにする

Config/app.phpに以下の記述を追加します。
追加するのはprovidersaliasesの2箇所です。

Config/app.php
'providers' => [
    Intervention\Image\ImageServiceProvider::class,
],

~中略~

'aliases' => [
    'Image' => Intervention\Image\Facades\Image::class,
],



さらにdriver設定の追加を行います。(以下のコマンドを実行)

$php artisan vendor:publish --provider="Intervention\Image\ImageServiceProviderLaravelRecent"



最後に、Configで設定をいじったのでキャッシュをクリアします。(以下のコマンドを実行)

$php artisan config:clear


③ Controllerで実際に処理を行う

ファサードを使う宣言をする

まずは画像処理を行うControllerにおいて以下を記述します。

〇〇Controller.php
use Intervention\Image\Facades\Image; // Imageファサードを使う
use Illuminate\Support\Facades\Storage; // Storageファサードを使う

画像処理を行う

InterventionImageでは本当に色々なことができるのですが、
ここでは実際によく使うであろう縦横比を維持したリサイズと、
ファイルをjpg形式に変換する例を示します。

〇〇Controller.php
$resized_image = Image::make($posted_image)->fit(640, 360)->encode('jpg');



$posted_imageが加工前の画像で、make()でInterventionImageに加工前の画像を読み込んでいます。
fit()は幅と高さを指定しつつ、縦横比が合わない場合はトリミングしてくれるメソッドです。

単純なリサイズならresize()でも良いのですが、画像がゆがんでしまうのでこのfit()が便利だと思います。

※InterventionImageでの網羅的な画像処理はこちらのサイトが参考になります。
https://blog.capilano-fw.com/?p=1574#_resize

画像の回転とEXIF情報の処理

最後に本題(?)の画像が勝手に回転する問題とEXIF情報の処理です。
これにはorientate()を使います。

〇〇Controller.php
$resized_image->orientate()->save();

これで自動回転しつつEXIF情報を削除してくれます。
->save()がないと処理が反映されないので注意です。

ファイル保存する

ファイル保存はstoreメソッドが使えない(GDがサポートしていないというエラーになる)ので
Laravelの機能(ファサード)のStorage::putを使います。

〇〇Controller.php
Storage::put('public/image/' . $image_name, $resized_image); 



これで良い感じの大きさになり、画像の向きも正しい向きになり、不要な埋め込み情報も削除された画像が投稿できるようになったはずです!
おつかれさまでした〜。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CodeIgniter3でci-phpunit-test 2.xを利用したUnitTestいろいろ③(DBアクセスを伴うModelクラスのUnit Test)

概要

ci-phpunit-testで、DBアクセスを伴うModelクラスのUnit Testを実装します。

CodeIgniter3でci-phpunit-test 2.xの環境を用意する方法については、CodeIgniter3 + PHPUnit 9.4.0 + ci-phpunit-test dev-2.x を利用した Unit Test環境の整備に載っています。

このModelクラスのUnit Testの実装では、事前にCodeIgniter3でci-phpunit-test 2.xを利用したUnitTestいろいろ②(Migrationの利用)の実装をしておく必要があります。

ソースの構成

.application
├── models
│   └── Gmo_result_notification_logs_model.php
├── tests
│   ├── models
│   │   └── Gmo_result_notification_logs_model_test.php

各ソースの内容とポイント

テスト対象となるDBアクセスを含むモデルクラス

特に変わったことはしていない、通常のモデルクラスです。
ログを削除する処理は基本的に必要ないですが、UnitTestがしやすいように削除処理も入れています。

application/models/Gmo_result_notification_logs_model.php
<?php

/**
 * GMOから実行される結果通知プログラムのログ情報モデルクラス
 */
class Gmo_result_notification_logs_model extends CI_Model {

    /** ID(自動採番) */
    public $id;
    /** 決済方法(credit:クレジット,cvs:コンビニ ...etc) */
    public $pay_method;
    /** 結果通知プログラムに送られてきたパラメータをJSON変換したもの */
    public $parameter;
    /** 登録日時 */
    public $create_date;

    /** モデルで操作するテーブル名 */
    private $_table = 'gmo_result_notification_logs';

    public function __construct() {
        parent::__construct();
    }

    /**
     * ログを登録する
     */
    public function insert_log() {
        $this->db->insert($this->_table, $this);
    }

    /**
     * ログを削除する
     */
    public function delete_log() {
        $this->db->delete($this->_table, array('id' => $this->id));
    }

    /**
     * 最新10件のログを取得する
     */
    public function get_last_ten_logs() {
        $query = $this->db->limit(10)
            ->order_by('id', 'desc')
            ->get($this->_table, 10);
        return $query->result();
    }
}

DBアクセスを含むModelクラスのテストクラス

ポイント

  • setUpBeforeClass() でMigrationを実行し、テスト用のDBの初期構築を行っている。
    • ci-phpunit-testで実行されるとき、ENVIRONMENT=testingで実行されるため、ここでloadされるデータベースはapplication/config/testing/database.phpの定義を参照している。
  • tearDownAfterClass() でMigrationのrollbackを実行し、version=0の状態に戻している。
    • 各CI_Migrationを継承するクラスのdown()が実行されて最初の状態に戻るイメージ
application/tests/models/Gmo_result_notification_logs_model_test.php
<?php

/**
 * Gmo_result_notification_logs_modelクラスのテスト
 */
class Gmo_result_notification_logs_model_test extends TestCase
{
    /**
     * テストクラス内の初回(1回)のみ実行する初期化処理
     */
    public static function setUpBeforeClass():void
    {
        // テスト用DBを初期化(migration)して利用
        $CI =& get_instance(); 
        $CI->load->database();
        $CI->load->library('migration');
        $CI->migration->current();
    }

    /**
     * テスト初期処理(各テストメソッドの実行前処理)
     */
    public function setUp():void
    {
        $this->resetInstance();

        $this->CI->load->model('Gmo_result_notification_logs_model');
        $this->obj = $this->CI->Gmo_result_notification_logs_model;
    }

    /**
     * テストクラス内の最後(1回)のみ実行する終了処理
     */
    public static function tearDownAfterClass():void
    {
        $CI =& get_instance(); 
        $CI->load->library('migration');
        $CI->migration->version(0);
    }

    /**
     * @test
     */
    public function ログの登録が正常に完了すること():void
    {
        $this->obj->id = 999;
        $this->obj->pay_method = 'credit';
        $this->obj->parameter = '{"aaa": "bbb", "ccc": "ddd"}';
        $this->obj->create_date = date('Y-m-d h:m:s');

        $this->obj->insert_log();
        $this->assertTrue(true); // ここまできたらOK
    }

    /**
     * @test
     * @depends ログの登録が正常に完了すること
     */
    public function 登録済みのログが削除できること():void
    {
        $this->obj->id = 999;
        $this->obj->delete_log();
        $this->assertTrue(true); // ここまできたらOK
    }

    /**
     * @test
     * @depends 登録済みのログが削除できること
     */
    public function 登録した順序の降順で取得できること():void
    {
        $insert_values = array(
            array(
                'id'=> 1,
                'pay_method'=> 'credit',
                'parameter'=> '{"aaa": "bbb", "ccc": "ddd"}',
                'create_date'=> date('Y-m-d h:m:s')
            ),
            array(
                'id'=> 2,
                'pay_method'=> 'cvs',
                'parameter'=> '{"eee": "ffff", "gggg": "hhh"}',
                'create_date'=> date('Y-m-d h:m:s')
            ),
            array(
                'id'=> 3,
                'pay_method'=> 'paypay',
                'parameter'=> '{"iii": "jjj", "kkk": "lll"}',
                'create_date'=> date('Y-m-d h:m:s')
            )
        );

        foreach ($insert_values as $value) {
            $this->obj->id = $value['id'];
            $this->obj->pay_method = $value['pay_method'];
            $this->obj->parameter = $value['parameter'];
            $this->obj->create_date = $value['create_date'];

            $this->obj->insert_log();
        }

        // ログを取得
        $logs = $this->obj->get_last_ten_logs();

        // 降順に並び替え
        $reverse_values = array_reverse($insert_values);

        for ($i = 0; $i < count($reverse_values); $i++) { 
            $this->assertEquals($reverse_values[$i]['id'], $logs[$i]->id);
            $this->assertEquals($reverse_values[$i]['pay_method'], $logs[$i]->pay_method);
            $this->assertEquals($reverse_values[$i]['parameter'], $logs[$i]->parameter);
            $this->assertEquals($reverse_values[$i]['create_date'], $logs[$i]->create_date);
        }
    }

}

実際のテスト実行

まだ何もしていない状態では、ENVIRONMENT=testingのDB接続先となっているphpunitdbのデータベース内には何もテーブルがない状態。

# mysql -u root -p
MariaDB [(none)]> connect phpunitdb;
MariaDB [phpunitdb]> show tables;
Empty set (0.001 sec)

phpunitを実行してテストを動かす。

# cd /var/www/html/codeigniter3/application/tests
# ../../vendor/bin/phpunit --testdox

PHPUnit 9.4.0 by Sebastian Bergmann and contributors.

Warning:       No code coverage driver available
Warning:       Your XML configuration validates against a deprecated schema.
Suggestion:    Migrate your XML configuration using "--migrate-configuration"!

Gmo_api_model_test
 ✔ G m o決済リンク urlが正常に取得できること
 ✔ G m o決済リンク url取得失敗の詳細が exceptionで伝播されること
 ✔ G m oカード会員編集 urlが正常に取得できること

Gmo_result_notification_logs_model_test
 ✔ ログの登録が正常に完了すること
 ✔ 登録済みのログが削除できること
 ✔ 登録した順序の降順で取得できること

Time: 00:00.047, Memory: 8.00 MB

OK (6 tests, 29 assertions)

テストを実行したので、データベースの状態を再確認してみる。

# mysql -u root -p
MariaDB [(none)]> connect phpunitdb;
MariaDB [phpunitdb]> show tables;

+---------------------+
| Tables_in_phpunitdb |
+---------------------+
| migrations          |
+---------------------+
1 row in set (0.000 sec)

MariaDB [phpunitdb]> select * from migrations;
+---------+
| version |
+---------+
|       0 |
+---------+
1 row in set (0.002 sec)

このように、UnitTest自体は正常に完了し、phpunitdbにはmigrationsテーブルのみが残った状態となっています。(本当は作られているはずのgmo_result_notification_logsテーブルが残っていない。)
version=0となっており、tearDownAfterClass()で指定したバージョンまでrollbackされていることが確認できる。

最後に

今回の対応では、特にテーブルを作った後、初期データのロードなどは行っていないため、毎回初期化しても耐えられるレベルのものでした。
初期データのロードが大量にあったりする場合は、テストクラス内で依存するデータのみをロードするようにしたり、データに依存しないテスト(Mockを利用したテスト)にすることで、規模が大きくなったときに対応する必要がありそうです。

とはいえ、小さいサービスのうちは、こういったテストを作っておくことで、Migrationの検証にもなるし、いいかなぁとは思っています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Laravel ベースレイアウトにDBから取得したデータを表示する

目的

  • ベースレイアウトにテーブル内に格納されているデータを表示する方法をまとめる

実施環境

  • ハードウェア環境
項目 情報
OS macOS Catalina(10.15.5)
ハードウェア MacBook Pro (13-inch, 2020, Four Thunderbolt 3 ports)
プロセッサ 2 GHz クアッドコアIntel Core i5
メモリ 32 GB 3733 MHz LPDDR4
グラフィックス Intel Iris Plus Graphics 1536 MB
  • ソフトウェア環境
項目 情報 備考
PHP バージョン 7.4.8 Homebrewを用いてこちらの方法で導入→Mac HomebrewでPHPをインストールする
Laravel バージョン 8.6.0 commposerを用いてこちらの方法で導入→Mac Laravelの環境構築を行う
MySQLバージョン 8.0.19 for osx10.13 on x86_64 Homwbrewを用いてこちらの方法で導入→Mac HomebrewでMySQLをインストールする

前提情報

  • 本記事は@injectディレクティブの使用方法を簡単にまとめるものである。
  • @injectディレクティブはサービスやリポジトリインターフェースを呼び出す事のできるディレクティブである。
  • 本記事は若干抽象的な説明が多くわかりにくいと思うので具体例を別途記事にする予定である

詳細

  1. ベースレイアウトへのディレクティブの記載

    1. 下記の記載方法でベースレイアウトにディレクティブを記載する。

      @inject(サービスからの戻り値を格納する変数, サービス)
      
    2. 例えば変数$strアプリ名ディレクトリ/app/Services/TestServiceに記載されているとあるメソッドの戻り値を格納したい場合は下記のように記載する。

      @inject('str', 'App\Services\TestService)
      
  2. 得た値の出力

    1. ディレクティブを記載したベースレイアウトファイル内で下記のように記載する事により値を出力する事ができる。

      {{ 戻り値を格納した変数名->サービスファイルに書かれたデータ取得メソッド名()}}
      
    2. 例えば変数$strに格納されているアプリ名ディレクトリ/app/Services/TestServiceに記載されているとあるgetStr()メソッドの戻り値を表示したい場合は下記のように記載する。

      {{ $str->getStr() }}
      

参考文献

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CodeIgniter3でci-phpunit-test 2.xを利用したUnitTestいろいろ②(Migrationの利用)

概要

CodeIgniter3の機能にあるMigrationを利用します。
ここではci-phpunit-testでModelクラスのUnit Testを行う際に、自分なりに便利だなと思われる仕掛けも含めて構築します。

CodeIgniter3でci-phpunit-test 2.xの環境を用意する方法については、CodeIgniter3 + PHPUnit 9.4.0 + ci-phpunit-test dev-2.x を利用した Unit Test環境の整備に載っています。

ci-phpunit-test用の仕掛けとは

ci-phpunit-testを利用したModelクラスのUnit Testで実現したいなと考えていること。

  • phpunit実行時に毎回DBが初期化されること
  • 予期せぬデータが投入された状態ではなく、予期した状態で再現性のあるDBアクセスを含んだUnit Testを実行できること
  • 開発環境(ENVIRONMENT = development)を侵害せずにUnit Testを行えること

こういったことを実現したくて、何かいい方法がないか模索し、とりあえず形になったものをこのQiita記事にまとめました。
※ここでは、上記のようなphpunitを実現するための、Migrationの構築がメインになります。

ソースの構成

.application
├── config
│   ├── development         # ← 開発環境用
│   │   └── database.php
│   ├── testing             # ← phpunit(テスト)用
│   │   └── database.php
├── controllers
│   └── Migrate.php
├── migrations
│   └── 001_add_gmo_result_notification_logs.php

各ソースの内容とポイント

config

CodeIgniterのデフォルトとして、ENVIRONMENT=developmentで動作し、ci-phpunit-testの動作時はENVIRONMENT=testingで動作する。

参考
https://github.com/kenjis/ci-phpunit-test/blob/master/docs/HowToWriteTests.md#testing-environment

そのため、developmenttesting専用のデータベースアクセス用の設定ファイルdatabase.phpを用意しておけば、開発環境への影響無しに、phpunitの実行によってデータベースを初期化し、CRUDを実行して再現性のあるUnit Testが実行可能となります。

config配下にdevelopmentとtestingの2つのディレクトリを作成し、database.phpファイルをコピーして、application/config/database.phpを削除しています。
config配下にある各設定ファイルの挙動として、以下優先順位でcodeigniterが読み込むようです。

  1. application/config/{設定}.php
  2. application/config/{ENVIRONMENT}/{設定}.php

そのため、application/config/database.phpが残ったままだと、developmentやtesting配下に作ったdatabase.phpを読み込んでくれないため、直下のdatabase.phpファイルは削除しています。

参考
https://codeigniter.jp/user_guide/3/general/environments.html
https://codeigniter.jp/user_guide/3/libraries/config.html

application/config/development/database.php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

$active_group = 'default';
$query_builder = TRUE;

$db['default'] = array(
    'dsn'   => '',
    'hostname' => 'localhost',
    'username' => 'yu',
    'password' => 'yupass',
    'database' => 'yudb',
    'dbdriver' => 'mysqli',
    'dbprefix' => '',
    'pconnect' => FALSE,
    'db_debug' => (ENVIRONMENT !== 'production'),
    'cache_on' => FALSE,
    'cachedir' => '',
    'char_set' => 'utf8mb4',
    'dbcollat' => 'utf8mb4_general_ci',
    'swap_pre' => '',
    'encrypt' => FALSE,
    'compress' => FALSE,
    'stricton' => FALSE,
    'failover' => array(),
    'save_queries' => TRUE
);
application/config/testing/database.php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

$active_group = 'phpunit';
$query_builder = TRUE;

// PHPUnitから利用するデータベース定義
$db['phpunit'] = array(
    'dsn'   => '',
    'hostname' => 'localhost',
    'username' => 'yu',
    'password' => 'yupass',
    'database' => 'phpunitdb',
    'dbdriver' => 'mysqli',
    'dbprefix' => '',
    'pconnect' => FALSE,
    'db_debug' => (ENVIRONMENT !== 'production'),
    'cache_on' => FALSE,
    'cachedir' => '',
    'char_set' => 'utf8mb4',
    'dbcollat' => 'utf8mb4_general_ci',
    'swap_pre' => '',
    'encrypt' => FALSE,
    'compress' => FALSE,
    'stricton' => FALSE,
    'failover' => array(),
    'save_queries' => TRUE
);

Migrationを管理するMigrateコントローラクラス

基本的にはmigrationライブラリのマイグレーション用メソッドを実行するだけのコントローラクラスです。
一応Webアプリケーションなので、ブラウザからマイグレーションが実行されないように、コマンドラインからのみのリクエストを受け付けるようにしています。

application/controllers/Migrate.php
<?php
defined('BASEPATH') OR exit('No direct script access allowed');

/**
 * Migration用のControllerクラス
 */
class Migrate extends MY_Controller {

    /**
     * 全ての利用者がアクセス可能なControllerとして定義
     * 一応コンストラクタ内でコマンド実行じゃなければエラーにするようにしている。
     */
    protected $access = "*";

    function __construct()
    {   
        parent::__construct();
        if(!$this->input->is_cli_request()) {
            show_404();
            exit;
        }   
        $this->load->library('migration');
    }   

    /**
     * currentバージョンにマイグレーションする
     */
    function current()
    {   
        if ($this->migration->current()) {
            log_message('error', 'Migration Success.');
        } else {
            log_message('error', $this->migration->error_string());
        }   
    }   

    /**
     * 指定されたバージョンにロールバックする
     */
    function rollback($version)
    {   
        if ($this->migration->version($version)) {
            log_message('error', 'Migration Success.');
        } else {
            log_message('error', $this->migration->error_string());
        }   
    }   

    /**
     * 最新バージョンにマイグレーションする
     */
    function latest()
    {   
        if ($this->migration->latest()) {
            log_message('error', 'Migration Success.');
        } else {
            log_message('error', $this->migration->error_string());
        }   
    }

}

migrationsのファイル

application/migrations配下に定義するマイグレーション用のファイルについては、次のどちらかのルールに基づいたファイル名をつけてバージョンが分かるようにする必要があります。

  1. シーケンス番号をファイル名の先頭に付与(シーケンス番号は001から始まる連番する)
    1. 例: 001_add_gmo_result_notification_logs.php
    2. application/config/migration.phpの定義
      1. $config['migration_type'] = 'sequential';
  2. Timestampをファイル名の先頭に付与(YYYYMMDDHHIISS)
    1. 例:20201011100000_add_gmo_result_notification_logs.php
    2. application/config/migration.phpの定義
      1. $config['migration_type'] = 'timestamp';

参考
https://codeigniter.jp/user_guide/3/libraries/migration.html#id2

application/migrations/001_add_gmo_result_notification_logs.php
<?php

defined('BASEPATH') OR exit('No direct script access allowed');

/**
 * GMO-PGの結果通知プログラムによって渡されるパラメータ情報などを保持するログテーブル 
 */
class Migration_Add_Gmo_result_notification_logs extends CI_Migration
{
    public function up()
    {
        $this->dbforge->add_field([
            'id'       => [
                'type'           => 'int',
                'unsigned'       => TRUE,
                'auto_increment' => TRUE
            ],
            'pay_method'    => [
                'type'       => 'varchar',
                'constraint' => '10',
            ],
            'parameter' => [
                'type'       => 'json'
            ],
            'create_date' => [
                'type'       => 'datetime'
            ]
        ]);
        $this->dbforge->add_key('id', TRUE);
        $this->dbforge->create_table('gmo_result_notification_logs');
    }

    public function down()
    {
        $this->dbforge->drop_table('gmo_result_notification_logs');
    }
}

実際にMigrationファイルを利用してテーブルを作成

まずはDBの状態を確認。
developmentとtestingの2つのデータベース接続先はすでに存在している前提です。

# mysql -u root -p
Enter password: 

MariaDB [(none)]> connect yudb;
MariaDB [yudb]> show tables;
Empty set (0.001 sec)

今のところdevelopment用のデータベースには何もテーブルが存在しない状態です。
※application/controllers/Migrate.phpのコントローラを利用してコマンドからマイグレーションを実行します。
 この場合、CodeIgniterのデフォルトのENVIRONMENTであるdevelopmentの方の接続情報が利用されます。

# cd /var/www/html/codeigniter3/public/
# php index.php migrate/current
# mysql -u root -p
Enter password: 

MariaDB [(none)]> connect yudb;
MariaDB [yudb]> show tables;

+------------------------------+
| Tables_in_yudb               |
+------------------------------+
| gmo_result_notification_logs |
| migrations                   |
+------------------------------+
2 rows in set (0.000 sec)

MariaDB [yudb]> select * from migrations;
+---------+
| version |
+---------+
|       1 |
+---------+
1 row in set (0.001 sec)

上記 migrationsテーブルの取得結果 version=1と取得できているのは、application/config/migration.phpの$config['migration_version'] = 1;の番号です。
システム改修を行って、バージョンを変更する場合は、この設定を変更してマイグレーションを実行します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CodeIgniter3でci-phpunit-test 2.xを利用したUnitTestいろいろ①(curlをMock化したAPIのUnit Test)

概要

CodeIgniter3でのWebアプリケーション開発で、ci-phpunit-testを利用したテストコーディングを行いました。
試行錯誤しつつ、なんとなくこんな感じでテストすればいいのかな?というソースになったので、メモとして残します。

CodeIgniter3でci-phpunit-test 2.xの環境を用意する方法については、CodeIgniter3 + PHPUnit 9.4.0 + ci-phpunit-test dev-2.x を利用した Unit Test環境の整備に載っています。
composerで標準的にセットアップされるものでは、バージョンが合わずに正常動作しないため、ちょっと大変でした。

ここでは、とりあえずcurlをMock化して、外部サービスのAPI実行を処理しているModelクラスのUnit Testについてまとめます。

ソースの構成

.application
├── models
│   └── Gmo_api_model.php
├── libraries
│   └── Curl_request.php
├── tests
│   ├── models
│   │   └── Gmo_api_model_test.php
│   └── phpunit.xml

各ソースの内容とポイント

phpunit.xmlの修正

必須の内容ではありませんが、phpunitを実行するときに、毎回サンプルのWelcomコントローラのテストが動作するのが邪魔だったので、<exclude>./controllers/Welcome_test.php</exclude>の定義をphpunit.xml に追加しています。

application/tests/phpunit.xml
<phpunit
    bootstrap="./Bootstrap.php"
    backupGlobals="true"
    colors="true">
    <testsuites>
        <testsuite name="CodeIgniter Application Test Suite">
            <directory suffix="test.php">./</directory>
            <exclude>./_ci_phpunit_test/</exclude>
            <exclude>./controllers/Welcome_test.php</exclude>
        </testsuite>
    </testsuites>
    <filter>
        <whitelist>
            <directory suffix=".php">../controllers</directory>
            <directory suffix=".php">../models</directory>
            <directory suffix=".php">../views</directory>
            <directory suffix=".php">../libraries</directory>
            <directory suffix=".php">../helpers</directory>
            <directory suffix=".php">../hooks</directory>
        </whitelist>
    </filter>
    <logging>
        <log type="coverage-html" target="build/coverage"/>
        <log type="coverage-clover" target="build/logs/clover.xml"/>
        <log type="junit" target="build/logs/junit.xml"/>
    </logging>
</phpunit>

curl専用のクラス

curlを使ったAPI呼び出しを行う処理をラッピングする独自のCurl_requestクラス。
curlのfunctionをそのままModel内で実行されてしまうとMock化できないため、外出しにしています。

application/libraries/Curl_request.php
<?php

defined('BASEPATH') OR exit('No direct script access allowed');

interface Http_request
{
    public function set_option($name, $value);
    public function execute();
    public function get_info();
    public function close();
}

/**
 * API呼び出しを行うcurlをラッピングする独自クラス。
 * PHPUnitでMock化するために作成。
 */
class Curl_request implements Http_request
{
    private $handle = null;

    public function __construct() {
    }

    public function init($url) {
        $this->handle = curl_init($url);
    }

    public function set_option($name, $value) {
        curl_setopt($this->handle, $name, $value);
    }

    public function execute() {
        return curl_exec($this->handle);
    }

    public function get_info() {
        return curl_getinfo($this->handle);
    }

    public function close() {
        curl_close($this->handle);
    }
}

テスト対象となるモデルクラス

GMO-PGに用意されているプロトコルタイプのAPIを操作するためのモデルクラスです。

  • ポイント
    • curl_setoptやcurl_execを直接実行せず、libraries/Curl_requestを利用してAPIを操作している。
    • イメージ的にはDIのSetter Injectionのような感じで、インスタンス変数$curlがCurl_requestの参照変数となるように定義。
      • テストクラスからはこのインスタンス変数にMockオブジェクトをセットすることでUnit Testを行う。
application/models/Gmo_api_model.php
<?php

/**
 * GMO-PGへのAPI操作を責務とするモデル
 */
class Gmo_api_model extends CI_Model {

    /** セッションに格納されたログインユーザ情報 */
    private $user = null;

    /** curl API実行用オブジェクト */
    public $curl = null;

    public function __construct() {
        parent::__construct();

        // curlでのAPI実行用ライブラリのロードとインスタンス変数へのセット
        $this->load->library('Curl_request');
        $this->curl = $this->curl_request;
    }

    /**
     * API操作に必要なユーザ情報をセットする
     */
    public function init($u) {
        $this->user = $u;
    }

    /**
     * GMO決済画面へのURLを生成して返却する
     */
    public function get_payment_url($flg_conf_member = false)
    {
        // 決済URL取得API
        $url = 'https://pt01.mul-pay.jp/payment/GetLinkplusUrlPayment.json';

        // GMOの決済で指定するオーダーIDは一意でなければいけない(注文ID先頭4桁を会員IDにした。)
        $orderId = str_pad($this->user['user_id'], 4, '0', STR_PAD_LEFT) . 'OR' . date('YmdHis');

        // json形式のパラメータを生成するための配列パラメータ定義
        $arr_param = array(
            'geturlparam'=> array(
                'ShopID'=> SHOP_ID,
                'ShopPass'=> SHOP_PASS,
                'TemplateNo'=> '1'
            ),
            'configid'=> 'test01',
            'transaction'=> array(
                'OrderID'=> $orderId,
                'Amount'=> 10000,
                'Tax'=> 1000,
                'PayMethods'=> ['credit']
            ),
            'credit'=> array(
                'JobCd'=> 'CAPTURE',
                'Method'=> '1'
            )
        );

        // GMO会員IDのパラメータを設定するか判別してパラメータに追加
        $arr_member_id = array(
            'credit'=> array(
                'MemberID'=> $this->user['user_id']  // GMO会員IDの指定
            )
        );

        if ($flg_conf_member) {
            if ($this->gmo_exists_member($this->user['user_id'])) {
                $arr_param = array_merge_recursive($arr_param, $arr_member_id);
            }
        } else {
            // $flg_conf_member=falseの場合は無受験に会員IDを指定する。
            $arr_param = array_merge_recursive($arr_param, $arr_member_id);
        }

        // 配列→json変換
        $param = json_encode($arr_param);
        return $this->get_gmo_linkurl($url, $param);
    }

    /**
     * GMO決済画面へのURLを生成して返却する
     */
    public function get_secure_payment_url($flg_conf_member = false)
    {
        // 決済URL取得API
        $url = 'https://pt01.mul-pay.jp/payment/GetLinkplusUrlPayment.json';

        // GMOの決済で指定するオーダーIDは一意でなければいけない(注文ID先頭4桁を会員IDにした。)
        $orderId = str_pad($this->user['user_id'], 4, '0', STR_PAD_LEFT) . 'OR' . date('YmdHis');

        // json形式のパラメータを生成するための配列パラメータ定義
        $arr_param = array(
            'geturlparam'=> array(
                'ShopID'=> SHOP_ID,
                'ShopPass'=> SHOP_PASS,
                'TemplateNo'=> '1'
            ),
            'configid'=> 'test01',
            'transaction'=> array(
                'OrderID'=> $orderId,
                'Amount'=> 10000,
                'Tax'=> 1000,
                'PayMethods'=> ['credit']
            ),
            'credit'=> array(
                'JobCd'=> 'CAPTURE',
                'Method'=> '1',
                'TdFlag'=> '2', // 3Dセキュア認証を契約に従って実施
                'Tds2Type'=> '1'
            )
        );
        // 3Dセキュア認証テスト用カード
        // 3DS1.0用 = https://faq.gmo-pg.com/service/detail.aspx?id=1681&a=102&isCrawler=1
        // 3DS2.0用 = https://faq.gmo-pg.com/service/detail.aspx?id=2379&a=102&isCrawler=1

        // GMO会員IDのパラメータを設定するか判別してパラメータに追加
        $arr_member_id = array(
            'credit'=> array(
                'MemberID'=> $this->user['user_id']  // GMO会員IDの指定
            )
        );

        if ($flg_conf_member) {
            if ($this->gmo_exists_member($this->user['user_id'])) {
                $arr_param = array_merge_recursive($arr_param, $arr_member_id);
            }
        } else {
            // $flg_conf_member=falseの場合は無受験に会員IDを指定する。
            $arr_param = array_merge_recursive($arr_param, $arr_member_id);
        }

        // 配列→json変換
        $param = json_encode($arr_param);
        return $this->get_gmo_linkurl($url, $param);
    }

    /**
     * GMOカード会員編集画面への遷移URLを生成して返却する
     */
    public function get_member_url()
    {
        // カード編集URL取得API
        $url = 'https://pt01.mul-pay.jp/payment/GetLinkplusUrlMember.json';

        // json形式のパラメータを生成するための配列パラメータ定義
        $arrayParam = array(
            'geturlparam'=> array(
                'ShopID'=> SHOP_ID,
                'ShopPass'=> SHOP_PASS,
                'TemplateNo'=> '1'
            ),
            'configid'=> 'test01',
            'member'=> array(
                'Cardeditno'=> 'CardEdit'.$this->user['user_id'],
                'MemberID'=> $this->user['user_id']  // GMO会員IDの指定
            )
        );

        // 配列→json変換
        $param = json_encode($arrayParam);
        return $this->get_gmo_linkurl($url, $param);
    }

    /**
     * 指定されたGMO会員IDがGMOサイトに存在するか確認し、
     * その結果をtrue / falseで返却する
     */
    public function gmo_exists_member($member_id) {
        $param = [
            'SiteID'           => SITE_ID,
            'SitePass'         => SITE_PASS,
            'MemberID'         => $member_id
        ];

        // リクエストコネクションの設定
        $this->curl->init('https://pt01.mul-pay.jp/payment/SearchMember.idPass');
        $this->curl->set_option(CURLOPT_POST, true);
        $this->curl->set_option(CURLOPT_RETURNTRANSFER, true);
        $this->curl->set_option(CURLOPT_CUSTOMREQUEST, 'POST');
        $this->curl->set_option(CURLOPT_POSTFIELDS, $param);

        // リクエスト送信
        $response = $this->curl->execute();
        $curlinfo = $this->curl->get_info();
        $this->curl->close();

        // 会員IDが見つかればtrue / 正しく見つからない「E01390002」場合はfalseを返却
        $http_stscd = $curlinfo['http_code'];
        parse_str( $response, $data );

        if($http_stscd != 200) {
            $gmo_errcd = '';
            if (array_key_exists('ErrCode', $data)) {
                $gmo_errcd = $data['ErrCode'];
            }
            $errmsg = <<< EOD
            $url . 'SearchMember.idPass API実行が失敗しました。 : '
            'HTTPステータスコード : ' . $http_stscd
            'GMOエラーコード : ' . $gmo_errcd
            EOD;
            throw new Exception($errmsg);
        }

        $err_info = '';
        if (array_key_exists('ErrInfo', $data)) {
            $err_info = $data['ErrInfo'];
            if ($err_info == 'E01390002'){
                return false;
            } else {
                throw new Exception('SearchMember.idPassのErrInfoで予期せぬリターン:' . $err_info);
            }
        }
        return true;
    }

    /**
     * プロトコルタイプのAPIを利用し、キー型のパラメータ指定方法によるAPI実行結果から、URL情報だけを抽出して返却する。
     * API実行に失敗した場合はExceptionをthrow
     */
    private function get_gmo_linkurl($url, $param) {

        // リクエストコネクションの設定
        $this->curl->init($url);
        $this->curl->set_option(CURLOPT_POST, true);
        $this->curl->set_option(CURLOPT_RETURNTRANSFER, true);
        $this->curl->set_option(CURLOPT_HTTPHEADER, array('Content-Type: application/json; charset=utf-8'));
        $this->curl->set_option(CURLOPT_CUSTOMREQUEST, 'POST');
        $this->curl->set_option(CURLOPT_POSTFIELDS, $param);

        // リクエスト送信
        $response = $this->curl->execute();
        $curlinfo = $this->curl->get_info();
        $this->curl->close();

        log_message('debug', 'kiteru?');
        log_message('debug', $response);

        $resJson = json_decode($response, true);

        // LinkUrlが取得できなければエラーとして扱う
        if (!array_key_exists('LinkUrl', $resJson)) {
            $http_stscd = $curlinfo['http_code'];

            $errmsg = <<< EOD
            $url . ' API実行が失敗しました。 : '
            'HTTPステータスコード : ' . $http_stscd
            'GMO Error : ' . $response
            EOD;

            throw new Exception($errmsg);
        }

        // URL取得APIの実行結果からリンク情報を取得し返却
        return $resJson['LinkUrl'];
    }
}

テストクラス

  • ポイント
    • getMockBuildersetMethodsを使ってCurl_requestクラスをMock化
    • Mock化したCurl_requestcurl_execcurl_getinfoの実行結果をMock用の結果に差し替えてテストを実行
application/tests/models/Gmo_api_model_test.php
<?php

/**
 * Gmo_api_modelクラスのテスト
 */
class Gmo_api_model_test extends TestCase
{
    // Curl_requestのモックオブジェクト
    private $curl_mock = null;

    /**
     * テスト初期処理(各テストメソッドの実行前処理)
     */
    public function setUp():void
    {
        $this->resetInstance();
        $this->CI->load->model('Gmo_api_model');
        $this->CI->load->library('Curl_request');
        $this->obj = $this->CI->Gmo_api_model;
    }

    /**
     * Curl_requestクラスのモックオブジェクトを初期化する。
     */
    private function init_curl_mock() {
        // Curl_request.phpで利用しているHttp_requestインターフェースのモックを作成してAPI実行結果をMock化
        $this->curl_mock = $this->getMockBuilder('Curl_request')
                                    ->setMethods(['init','set_option','execute','get_info','close'])
                                    ->getMock();
    }

    /**
     * @test
     */
    public function GMO決済リンクUrlが正常に取得できること(): void
    {
        // Mock化したAPIのcurl_exec実行結果を定義しreturnで利用
        $this->init_curl_mock();
        $ret_exec = array(
            'OrderID'=> 'sample-123456789',
            'LinkUrl'=> 'https://[ドメイン]/v2/plus/tshop11223344/checkout/0258d6e9232978d004bf776c26acb435c7bc9eca33b40798a714a9dde2dfe0c5',
            'ProcessDate'=> '20200727142656'
        );
        $this->curl_mock->method('execute')->willReturn(json_encode($ret_exec));
        // MockオブジェクトでCurl_requestへの参照を切り替え
        $this->obj->curl = $this->curl_mock;

        // Gmo_api_modelを初期化(ログインユーザ情報をセット)
        $user = array(
            'user_id'=> 1
        );
        $this->obj->init($user);

        $url = '';
        try {
            $url = $this->obj->get_payment_url();
        } catch(Exception $e) {
            $this->fail('決済URLの取得に失敗 : ' . $e->getMessage());
        } 
        // 決済URLの接続URLが取得できていればOK
        $this->assertGreaterThanOrEqual(0, strpos($url, 'https://stg.link.mul-pay.jp/v2/plus'));
        $this->assertGreaterThanOrEqual(10, strpos($url, 'checkout/'));
    }

    /**
     * @test
     */
    public function GMO決済リンクUrl取得失敗の詳細がExceptionで伝播されること(): void
    {
        // Mock化したAPIのcurl_exec実行結果を定義しreturnで利用
        $this->init_curl_mock();
        $ret_exec = array(
            array(
                'ErrCode'=> 'EZ1',
                'ErrInfo'=> 'EZ1004005'
            ),
            array(
                'ErrCode'=> 'EZ1',
                'ErrInfo'=> 'EZ1004001'
            )
        );
        $this->curl_mock->method('execute')->willReturn(json_encode($ret_exec));

        // Mock化したAPIのget_info実行結果を定義しreturnで利用
        $ret_info = array(
            'http_code'=> '400'
        );
        $this->curl_mock->method('get_info')->willReturn($ret_info);

        // MockオブジェクトでCurl_requestへの参照を切り替え
        $this->obj->curl = $this->curl_mock;

        // Gmo_api_modelを初期化(ログインユーザ情報をセット)
        $user = array(
            'user_id'=> 1
        );
        $this->obj->init($user);

        $url = '';
        try {
            $url = $this->obj->get_payment_url();
        } catch(Exception $ex) {
            $err_msg = $ex->getMessage();
            $this->assertMatchesRegularExpression('/API実行が失敗しました。/', $err_msg);
            $this->assertMatchesRegularExpression('/HTTPステータスコード/', $err_msg);
            $this->assertMatchesRegularExpression('/ErrCode/', $err_msg);
            $this->assertMatchesRegularExpression('/ErrInfo/', $err_msg);
            $this->assertMatchesRegularExpression('/400/', $err_msg);
            $this->assertMatchesRegularExpression('/EZ1/', $err_msg);
            $this->assertMatchesRegularExpression('/EZ1004005/', $err_msg);
        } 
    }

    /**
     * @test
     */
    public function GMOカード会員編集Urlが正常に取得できること(): void
    {
        // Mock化したAPIのcurl_exec実行結果を定義しreturnで利用
        $this->init_curl_mock();
        $ret_exec = array(
            'Cardeditno'=> 'CardEdit1',
            'LinkUrl'=> 'https://stg.link.mul-pay.jp/v2/plus/tshop99999999/member/7c1987623098alkje1617e7370c6899bb3df87e4dd52079e957c1acb42d5b44f5b67',
            'ProcessDate'=> '20201010143013',
            'WarnList'=> array(
                'warnCode'=> 'EZ4',
                'warnInfo'=> 'EZ4136014'
            )
        );
        $this->curl_mock->method('execute')->willReturn(json_encode($ret_exec));
        // MockオブジェクトでCurl_requestへの参照を切り替え
        $this->obj->curl = $this->curl_mock;

        // Gmo_api_modelを初期化(ログインユーザ情報をセット)
        $user = array(
            'user_id'=> 1
        );
        $this->obj->init($user);

        $url = '';
        try {
            $url = $this->obj->get_member_url();
        } catch(Exception $e) {
            $this->fail('GMOカード会員編集Urlの取得に失敗 : ' . $e->getMessage());
        } 
        // GMOカード会員編集Urlの接続URLが取得できていればOK
        $this->assertGreaterThanOrEqual(0, strpos($url, 'https://stg.link.mul-pay.jp/v2/plus'));
        $this->assertGreaterThanOrEqual(10, strpos($url, 'member/'));
    }

}

phpunitの実行

以下のコマンドを実行することで、上記までで開発したUnit Testのコードが実行され結果が表示されます。

# cd /var/www/html/codeigniter3/application/tests/
# ../../vendor/bin/phpunit --testdox

PHPUnit 9.4.0 by Sebastian Bergmann and contributors.

Warning:       No code coverage driver available
Warning:       Your XML configuration validates against a deprecated schema.
Suggestion:    Migrate your XML configuration using "--migrate-configuration"!

Gmo_api_model_test
 ✔ G m o決済リンク urlが正常に取得できること
 ✔ G m o決済リンク url取得失敗の詳細が exceptionで伝播されること
 ✔ G m oカード会員編集 urlが正常に取得できること

Time: 00:00.047, Memory: 8.00 MB

最後に

本当はSpring FrameworkのDI Containerみたいなのがいて、勝手にInjectionしてくれて、テストできるのがよかったんですが、そんな感じの情報が見つからなかったのでとりあえずこんな感じに作ってみました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Flutter × Laravel × Stripe で単発決済

はじめに

約半年振りの投稿です。
最近はWeb開発から少し離れFlutterの開発を行なっております。

巷で流行っている言語&AndroidとIOSの開発を同一言語でできるというのが大変魅力で学習をはじめました。

KBOYのFlutter大学
https://www.youtube.com/channel/UCReuARgZI-BFjioA8KBpjsw

自分はこのチャンネルから学習を始めました。
超丁寧に説明していただけるので、初学者に大変オススメです!

経緯

もともと決済処理はWebViewで決済処理を行う予定でした。
しかし、調べているうちに

https://digiryte.com/blog/posts/online-payments-made-easier-by-digiryte-by-flutter-and-ruby-on-rails

こちらの記事を発見しRailsでもできるならLaravelでもできるんじゃね?
と思ったのが事の発端です。

言語違いではありますが、実装例があるのに自分で実装できないのはちょっと悔しいと思い試行錯誤の末実装できました。

Stripeとは

こちらの記事が大変参考になります。
https://qiita.com/t-kuni/items/bfbec1dcc695c0f18282

Flutterで画面の作成

画面作成の前にStripeを導入するにあたって必要なパッケージを導入します。

pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
...
# この辺は自分の環境にあったものを導入してください。
stripe_payment: ^1.0.6
stripe_sdk: ^3.0.1+1
main.dart
import 'package:flutter/material.dart';
import 'package:stripe_payment/stripe_payment.dart';
import 'package:stripe_sdk/stripe_sdk_ui.dart';


void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey();
  final formKey = new GlobalKey<FormState>();
  final card = new StripeCard();
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            CardForm(card: card, formKey: formKey), // クレジットカード入力欄を表示します。
            FlatButton(
              child: Text("決済する"),
              onPressed: () async {
                if (formKey.currentState.validate()) {
                  formKey.currentState.save();

                  final CreditCard _creditCard = CreditCard(
                      number: card.number,
                      expMonth: card.expMonth,
                      expYear: card.expYear,
                     );
                      StripePayment.createTokenWithCard(
                      _creditCard,
                    ).then((token) {
                      // apiに処理を渡します。              
                      final url = 'http://127.0.0.1/api/sample_stripe';
                      await http.post(
                       url,
                       body: {
                         "stripeToken": stripeToken,
                       }
                     );
                    } else {
                    print('処理が通りませんでした。');
                  }
               },
            ),
          ],
        ),
      ),
    );
  }
}

スクリーンショット 2020-10-11 4.44.06.png
上記の画像のようなページが作成されます。

処理の流れとしては下記のような流れになります。
1.CardFormクラスを作成し、クレジットカード情報を入力
2.「決済する」ボタンをタップ
3.CreditCardクラスを作成し入力した値を渡す。
4.stripeTokenを作成
5.Laravel側にstripeTokenを渡す。

作成できない場合は下記のURLを参考に作成してみてください。
https://pub.dev/packages/stripe_sdk

Laravel側でAPIの作成

api.php
Route::post('/sample_stripe', 'SampleStripeController@stripe');
SampleStripeController.php
function stirpe(Request $request)
    {
        //  Stripeシークレットキーの取得
        Stripe::setApiKey(config('app.stripe_secret_key'));
        // 顧客の作成
        $customer = Customer::create(array(
            'email' => text@example.com,
            'source' => $request->stripeToken //tokenを受け取る
        ));

        $charge = Charge::create(array(
            'description' => 'ストライプ決済',
            'amount' => 500,
            'currency' => 'jpy',
            'customer' => $customer->id,
        ));
    }

Stripe管理画面

スクリーンショット 2020-10-11 5.36.11.png
画像のように決済処理が完了しました。
Flutter側だけで処理しようとするとどうしてもわからない部分が多かったので重要な処理はLaravel側に投げて解決させました。

最初はstripeTokenが取得できずStripeの顧客にデータが登録できませんでしたが、「createTokenWithCard」を利用することで顧客データの登録ができるようになりました!

本記事では実装していませんが、決済が終了した後メールを送信してあげたりするともっといいかもしれませんね!

本システムを作成するにあたって苦戦したところ

Flutter × Laravel × Stripe の記事が存在しなかったことが苦戦しました。
APIに処理を渡すということが理解できていてもstripeTokenが取得できなかったりRailsの記事の内容をLaravelに置き換えて実装してもエラー続きでした。
理解はできていても実装できないという大変歯がゆい思いをしながら開発しました(笑)

指摘等がございましたら是非お願いします。

決済処理やAPIを利用できると実装の幅が増えると思うのでぜひ参考にしてみてください!

参考URL

https://medium.com/flutterdevs/stripe-payment-in-flutter-d7f87f9a193c
https://digiryte.com/blog/posts/online-payments-made-easier-by-digiryte-by-flutter-and-ruby-on-rails
https://pub.dev/packages/stripe_sdk

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

なぜスクールはRubyを学習させるのか

なぜJavaではなくRubyを推奨するのか

iStock-973074712-490x265.jpg

結論

挫折しにくく比較的理解しやすいから(参考文献豊富)

実際に転職で求められるスキルとは

レバテックでの言語別求人
sub1(1).png
こんな感じでJavaが全体の約35%と高割合を占めていて、PHPと合わせる全体の約50%という結果となっています。
kangaeruhito.png

Rubyの需要・・・・・

ってなる人、分かります。正直Wantedlyや他媒体の求人を見ていてもRubyエンジニアを求めてる企業はかなり少ないです。じゃあなんでスクールは推すのかというと、、
アプリケーションを自力で作りやすいから!!!
これに尽きると思います。Railsを使って便利なgemも使えば簡単に実装できますし、個人スケールで簡単にアプリ作成ができます。

だからこそ企業が見る視点とは

スクール経由の求人企業なら何を学んできたのかを全て知り尽くされています。だからこそ、自発的に学んだ事をアプリに取り入れたり、自己研鑽しているかを重視しているような気がします。(多分)

結論

スクールでは実装に必要最低限の事しか教えてもらえず、あぁ〜もう無理やぁ〜ってなる気持ちも分かりますが、どちらかというとその部分を1番見ているポイントだと思うので最後まで諦めずに自発的に学んで転職・就職を成功させましょう!!!

現場からは以上です!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Codewars] Unique In Order/ Create Phone Number

概要

Codewarsの問題 Unique In OrderCreate Phone Number の回答の復習とベストプラクティスをまとめる個人メモです。

Unique In Order

問題文

Implement the function unique_in_order which takes as argument a sequence and returns a list of items without any elements with the same value next to each other and preserving the original order of elements.

文字列か配列が渡ってくるので、ユニークな値となるよう配列を返す実装です。

uniqueInOrder("AAAABBBCCDAABBB") == {'A', 'B', 'C', 'D', 'A', 'B'}
uniqueInOrder("ABBCcAD")         == {'A', 'B', 'C', 'c', 'A', 'D'}
uniqueInOrder([1,2,2,3,3])       == {1,2,3}

回答

引数が文字列の場合、str_splitを使用し配列に変換します。
その後次の要素と値が異なる場合は、$retに格納する実装です。

function uniqueInOrder($iterable){
  $unique = "";
  $ret = array();
  if(is_string($iterable) == 1){
    $iterable= str_split($iterable);
  }
  for($i=0; $i<count($iterable);$i++){
    if($unique != $iterable[$i]){
      array_push($ret, $iterable[$i]);
    }
    $unique = $iterable[$i];
  }
  return $ret;
}

ベストプラクティス

文字列の比較は比較演算子? :を使用し一行で記載していました。
また、array_reduce処理の簡約化を行っています。

function uniqueInOrder($iterable){ 
  $arr = is_string($iterable) ? str_split($iterable) : $iterable;
  $ret = array_reduce($arr, function($carry, $item) {
    if ($item != end($carry)) {
      $carry[] = $item;
    }
    return $carry;
  }, []);

  return $ret;
}

Create Phone Number

問題文

Write a function that accepts an array of 10 integers (between 0 and 9), that returns a string of those numbers in the form of a phone number.

配列で渡される値を電話番号の形式で出力させます。


createPhoneNumber([1,2,3,4,5,6,7,8,9,0]); // => returns "(123) 456-7890"

回答

vsprintfを使用し、配列情報をフォーマットすることで実装することが可能です。

function createPhoneNumber($numbersArray) {
  return vsprintf("(%d%d%d) %d%d%d-%d%d%d%d", $numbersArray);
}

ベストプラクティスもほぼ似たような回答です。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む