ハイパーマッスルエンジニア

Vim、ShellScriptについてよく書く

10年もののシステムをPHP・Twig・jQuery・BootstrapからNext.jsにリプレイスするときにやったこと

f:id:rasukarusan:20220416113107p:plain
 

Twig, jQuery, Bootstrap で構成されていたシステムをNext.jsでリプレイスしたときにやったことを書く。

前提

  • IE対応必須

ディレクトリ設計

github.com

を参考にした。
ユニットテスト用の__test__は基本的に作らず、format.tsformat.test.tsは同じ階層に配置するようにした。テストが存在するのかの確認コストと、書くときのハードルを極力下げるようにするため。

コンポーネント分割については、アトミックデザインを採用しようとも思ったが、結果やめて下記のような分割単位とした。

components
  ├── button
  ├── label
  │    ├── H1.tsx
  │    └── Title.tsx
  └── icon

アトミックデザインでいうところのatoms(原子)とmolecules(分子)までを最大として、それ以降の大きな単位はfeatures配下のcomponentsで作成する形。

アクセスログ

Webビーコンでサーバー上のアクセスログに記載。

チームへの勉強会実施

チームメンバー全員がNext.jsに精通しているのならば不要。そうでないなら実施するべし。
コードレビューしてもらえるぐらいにはメンバーの理解を進めておく必要がある。

社内テスト

リプレイスなので、本来挙動上は何も変わらないはずだが必ず差異が出てくる。そのための認識合わせも含めて社内テストの期間はしっかりとる。

あとでやることリストの作成

リプレイスをしていくにあたって重要だが後で対応しようってものが必ず出てくるためそれをメモしていくためのシートを作成しておくと心理的に良い。

GoogleAnalytics, GoogleOptimize, GoogleTagManagerの対応

SPAになると初回ページ読み込みでしかGAが発火しないため、ページ遷移してもGAにデータが送信されない。
ページ遷移ごとに発火されるようにする設定が必要となる。 GoogleTagManagerでGoogleAnalytics, GoogleOptimizeを管理している場合は以下の設定が必要となる。

GoogleAnalyticsの設定

「履歴の変更」をトリガーに加える。

GoogleOptimizeの設定

カスタムイベントの追加。Next.js側でイベントの送信をする。

// gtm.ts
/**
 * Google Optimizeの発火
 */
export const optimize = (): void => {
  window.dataLayer.push({ event: 'optimize.activate' })
}

// _app.tsx
const router = useRouter()

useEffect(() => {
  gtm.optimize()
}, [router.pathname])

独自の神クラス化しているjsファイルの移設

すでに動いているサービスなら必ずあるであろう神クラス化したファイル。jQuery、Bootstrapプラグインも使いたい放題でメンタルやられそうになるが、1つずつ対処していくしかない。魔法はない。

  • 不要な処理の削除、必要な処理だけをリプレイス対象とする
  • jQueryで書かれている箇所をReactで書き直す
  • jQureyプラグイン, Bootstrapプラグインで書かれている箇所の代替ライブラリを探す

最悪<script dangerouslySetInnerHTML={{}} />で直接読み込んで乗り切りれるかもしれん。

ヘッダー、フッター、サイドバーなど共通部分の作成

web componentsを使えば旧フロントエンドと新フロントエンドで共通化できる。
しかしIE未対応のため旧フロントエンドと新フロントエンドで二重管理にすることにした。最初は苦渋の選択であったが、今では二重管理にしてよかったと思えている。旧フロントに新技術の導入をすると影響範囲も大きいので、下手に共通化などせず分離しておくのが正解な気がする。風呂敷広げすぎたらあかん。

既存APIサーバーとの連携

認証をどうするかや、APIサーバーを叩く際にクライアントから直接叩くのか、Next.jsのnode.jsサーバーを経由して叩くのかなど。
node.jsサーバーを経由して叩くほうがレスポンスの一部を秘匿するなどBFFのような役割をもたせることができるので良い。既存APIがすでにオープンAPIになっているなら話は別。

$_SESSIONなどPHP特有の機能の外だし

$_SESSIONの値をテンプレートファイルの引数に渡したりしている箇所は、新フロントエンドからAPIを叩いて$_SESSIONの値を取得できるようにする。
もしくはセッションをRedisなどに保存している場合は、新フロントエンドでRedisクライアントを作成して直接取得しにいってもよい。

旧フロントエンド -> 新フロントエンドへのルーティング

nginxでやるのか、ホスティング先のルーティング機能でやるのか。
いずれにせよ注意したいのは旧フロント環境で/apiがすでに存在している場合、Next.jsのAPI Routes(/pages/api)とバッティングする可能性がある。

ちなみにnginxでやる場合は下記の設定で可能。
nginx.conf

 # 新フロント環境からのリクエスト処理。'new-front-container'は新フロントのdockerコンテナ名
location ^~ /_next {
    proxy_pass http://new-front-container:3000;
}
location ~ ^/(new_front_url) { # (new_front_url | new_front_url2)のように対象ページを追加
    proxy_pass http://new-front-container:3000;
}

旧フロント↔新フロントへのログイン引き継ぎ

旧フロント→新フロントの場合、PHPSESSIDを新フロント側で取得し、Redisに問い合わせる。Redisに問い合わせる際は新フロントから直接でもいいが、既存のAPIサーバーを経由して取得しsession_decode()して新フロントに返すと言う流れのほうが楽。

新フロント→旧フロントの場合、新フロントでログイン時にCOOKIEに暗号化したログイン情報を保存しておき、旧フロント側で復元してログイン済みにするという流れ。

COOKIEに保存する際はhttpOnlyにするのは必須。

ライブラリ選定

  • ユニットテスト、モック
  • 日付
  • 状態管理
  • 通信
  • OGP
  • CSSフレームワーク

デプロイ先はVercelかそれ以外か

結論から言うとVercelは利用しなかった。初めのうちは「ゼロコンフィグでいくんじゃ! next/imageもISRもNext.jsの機能をフルに使いたいからVercel一択!!」となっていたのだが、冷静に考えるとなし。すでに動いているサービスの中にNext.jsを組み込むとなると、Vercelという選択肢を取ることは少ないのかなと思った。理由は下記。

  • Vercelが落ちたときのリカバリーはどうするか
  • VercelのProプランからEnterpriseプランに上げざるを得なくなった場合、払えるか
  • 障害発生時に調査可能か、Vercelの裏側で何が起きているのか把握できるか

結局デプロイ先はAzure App Serviceにした。元々すでに動いているサービスはAzureにデプロイしていることもあり、また、障害ポイントをAzure以外のVercel(AWS)など増やしたくなかったため。

デプロイ先をVercel以外にすることのメリット・デメリット

デメリット

  • next/imageが使えない
  • ISRが使えない
  • .next/staticなどの静的ファイルのCDN対応が別途必要となった

メリット

  • 障害ポイントが減る
  • IP制限やBasic認証など自由に設定できるようになる
  • Vercel特有の何かを覚える/調べる必要がなくなる

パフォーマンス計測

チーム内でリプレイス前のパフォーマンスを計測し、基準を作っておくといい。リプレイス後の比較に使うため、ひいてはリプレイスしたらこんなにパフォーマンス上がりましたッと言うためである。 Webのパフォーマンス計測はGoogleが提唱しているFCP、LCP、FIDを項目として追えばいい。 リプレイス前のパフォーマンス計測にはSentryを利用した。それぞれ下記のような形で基準を出した。

FCP(要素をレンダリングするまでの時間): 3000ms以内であること
LCP(コンテンツの表示速度): 3000ms以内であること
FID(TBT)(ユーザーの初回操作からの反応速度): 100ms以内であること

リプレイス後の計測にはLighthouseとPageSpeed Insightsを使う。 開発時はChromeDevToolのLighthouse、デプロイしてURLでアクセスできるようになったらPageSpeed Insightsで計測する。Lighthouseは実行時の環境に依存してしまうため、PageSpeed Insightsのほうが普遍的な結果が出るはず。

終わりに

このプロジェクトは1人で完遂した。おそらく多くても2人ぐらいで進めていくのがベストだと思う。リプレイスはめちゃくちゃおもしろいので、1人でガツガツと進められるのは非常に良かった。仮に2人でやっていたとしたら熱量の差に萎えてスケジュール通り終わらなかったと思う。

【メモ】PHPフレームワークSlimをdocker-composeで環境構築する

f:id:rasukarusan:20210501121545p:plain
 

途中でまとめるのが面倒になってしまったのでメモ書きとして残しておく

リポジトリ github.com

環境

  • M1 MacBookAir(Big Sur)
  • Docker Desktop(3.3.0)

初期ディレクトリ構築

mkdir php-slim
cd php-slim

# slimのインストール
composer create-project slim/slim-skeleton:3.1 app

migrationの導入

Phinxを使う。

# インストール
composer require robmorgan/phinx
# 初期化
php vendor/bin/phinx init

# 必要なディレクトリ作成
mkdir db
mkdir db/migrations
mkdir db/seeds

# マイグレーションファイルの作成
php vendor/bin/phinx create Logs

phinx.phpにDB接続情報を書く。 docker-compose.ymlが下記のような感じだったら、

  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: app
      MYSQL_USER: docker
      MYSQL_PASSWORD: docker
    ports:
    - 3306:3306

phinx.phpはこんな感じ。

<?php

return
[
    'paths' => [
        'migrations' => '%%PHINX_CONFIG_DIR%%/db/migrations',
        'seeds' => '%%PHINX_CONFIG_DIR%%/db/seeds',
    ],
    'environments' => [
        'default_migration_table' => 'phinxlog',
        'default_environment' => 'development',
        'development' => [
            'adapter' => 'mysql',
            'host' => '127.0.0.1',
            'name' => 'app',
            'user' => 'docker',
            'pass' => 'docker',
            'port' => '3306',
            'charset' => 'utf8',
        ],
    ],
    'version_order' => 'creation',
];

マイグレーションファイルの作成コマンドを実行するとdb/migrations/20210406131917_logs.phpみたいなファイルが生成される。このファイルにテーブルの情報を書いていく。

db/migrations/20210406131917_logs.php

<?php

use Phinx\Migration\AbstractMigration;

final class Logs extends AbstractMigration
{
    /**
     * Change Method.
     *
     * Write your reversible migrations using this method.
     *
     * More information on writing migrations is available here:
     * https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
     *
     * Remember to call "create()" or "update()" and NOT "save()" when working
     * with the Table class.
     */
    public function change(): void
    {
        $table = $this->table('logs');
        $table->addColumn('level', 'string', [
            'default' => null,
            'limit' => 255,
            'null' => true,
            'comment' => 'INFO/WARN/ERROR',
        ]);
        $table->addColumn('created', 'timestamp', [
            'default' => 'CURRENT_TIMESTAMP',
        ]);
        $table->create();
    }
}

マイグレーションの実行

php vendor/bin/phinx migrate

ロールバック

php vendor/bin/phinx rollback -e development

composer.jsonのscriptsとして書いておくと便利ですね。

composer.json

"scripts" : {
    "migrate" : [
        "php vendor/bin/phinx migrate"
    ],
    "rollback" : [
        "php vendor/bin/phinx rollback -e development"
    ]
}

実行

composer migrate
composer rollback

詰まった箇所

apache2のルートディレクトリの変更

変更箇所は2箇所

/etc/apache2/httpd.conf
/etc/apache2/site-available/000-default.conf

どちらのファイルにも/var/www/htmlになっている箇所があるのでそれを変更する

ルートディレクトリを変更したらInternalServerErrorがでた

https://qiita.com/YAJIMA/items/68de1bdeb71a921a718d

エラー文

Invalid command 'RewriteEngine', perhaps misspelled or defined by a module not included in the server configuration

解決方法

a2enmod rewrite
# 再起動
service apache2 restart

それでもNot Foundと出る

AllowOverride Allになっていることを確認する。Noneになっていたらpublic配下の.htaccessが読み込めなくなってしまう。 apache2.conf

<Directory /var/www/html/public>
    Options Indexes FollowSymLinks
    AllowOverride All
    Require all granted
</Directory>

【メモ】VimでPHPの開発環境を整える

f:id:rasukarusan:20210501114904p:plain

やること

  • LSPの導入
  • Formatterの導入
  • GTAGS(GNU Gloabal)の導入

環境

  • M1 MacBookAir(Big Sur)

LSPの導入

coc.nvimでcoc-phpls導入して終了。

:CocInstall coc-phpls

これで補完や定義ジャンプができるようになる。あとは好みだけどUltisnipsで自作のsnippetを入れたりした。

Formatterの導入

やることは 1. php-cs-formatterのインストール 2. 設定ファイル.php_csの設置 3. vimの設定

1. php-cs-formatterのインストール

github.com

保存時に自動フォーマットがかかるようにする。 php-cs-fixerでフォーマットは行う。インストールはcomposerもしくはbrewで行う。

# composerの場合
composer global require friendsofphp/php-cs-fixer
# brewの場合
brew install php-cs-fixer

2. 設定ファイル.php_csの設置

.php_csを設置する。書き方は下記を参考に。 qiita.com

vim ~/.php_cs

3. vimの設定

Aleで自動整形したかったが、設定しても実行できなかったのでautocmdでやることにした。下記を.vimrcに追加。

" =============================================
" php-cs-formatterによる自動整形
" =============================================
function! s:format_php() abort
  :r! php-cs-fixer -q fix % --config=$HOME/.php_cs
  :e! " 再読み込み
endfunction
augroup phpsetting
    autocmd!
    autocmd BufWritePost *.php :call s:format_php()
augroup END

GNU Gloabalの導入

Macにgtagsをインストールする

coc.nvimで大体は事足りるが、定義ジャンプはGTAGSを使ったほうが追いやすい場合があるので、導入する。

参考:http://blog.matsumoto-r.jp/?p=2369

公式サイトから6.4のGlobalをダウンロード。最新の6.6では./configureで下記のエラーが出て失敗してしまう。

error: POSIX.1-2008 realpath(3) is required.

やること

cd ~/Downloads/global-6.4
./configure --prefix=/usr/local/global
make
sudo make install

成功すると/usr/local/globalが作られている。/usr/local/globalにはbinディレクトリがあって、その中に実行ファイルがあるので、PATHが通っているところにシンボリックリンクを生成する。

sudo ln -s /usr/local/global/bin/gtags /usr/local/bin/gtags
sudo ln -s /usr/local/global/bin/global /usr/local/bin/global

これでコマンドが実行できるようになっているはずなので、タグを作る。

gtags -v

Vimからgtagsを使えるようにする

denite-gtagsを使う。

github.com

dein.toml

[[plugins]]
repo = 'Shougo/denite.nvim'
hook_add = 'source ~/.config/nvim/plugin_settings/denite.vim'

[[plugins]]
repo = 'ozelentok/denite-gtags'
hook_add = 'source ~/.config/nvim/plugin_settings/denite-gtags.vim'

denite.vim

autocmd FileType denite call s:denite_my_settings()
function! s:denite_my_settings() abort
  nnoremap <silent><buffer><expr> <CR>
  \ denite#do_map('do_action')
  nnoremap <silent><buffer><expr> p
  \ denite#do_map('do_action', 'preview')
  nnoremap <silent><buffer><expr> <C-t>
  \ denite#do_map('do_action','tabopen')
  nnoremap <silent><buffer><expr> q
  \ denite#do_map('quit')
  nnoremap <silent><buffer><expr> i
  \ denite#do_map('open_filter_buffer')
endfunction

denite-gtags.vim

noremap [denite-gtags]  <Nop>
nmap <Space> [denite-gtags]
" 今のファイルの関数などの一覧
nnoremap [denite-gtags]f :Denite -buffer-name=gtags_file -prompt=> gtags_file<CR>
" カーソル下の単語の定義元を表示
nnoremap [denite-gtags]d :<C-u>DeniteCursorWord -buffer-name=gtags_def -prompt=> gtags_def<CR>
" カーソル下の単語の参照先を表示
nnoremap [denite-gtags]r :<C-u>DeniteCursorWord -buffer-name=gtags_ref -prompt=> gtags_ref<CR>

基本的にはcoc.nvimを使っていき、cocでジャンプできない定義をgtagsで試してみると言う感じ。composerでインストールしたパッケージなどはcocでジャンプできないことが多く、そんなときに役に立つ。

phpのempty()には気をつけろと言うけど具体的にどういうケースやねん

巷でよく「empty()は挙動をわかっていないと使ってはいけない」というのを見るが具体的にどういう場面で注意したらいいのかイマイチ理解していなかった。

そこで「気をつけてはいたが実際にempty()で痛い目に会った」話をしたい。

empty()とは

empty()で気をつけろと言われているのはtrueとなるケースが様々であることだ。
empty()TRUEになるケースは以下。

empty
$var=1 FALSE
$var="" TRUE
$var="0" TRUE
$var=0 TRUE
$var=NULL TRUE
$var TRUE
$var=array() TRUE
$var=array(1) FALSE

他にも比較値はあるが詳しくは下記サイトの表がよくまとまってる。

d.hatena.ne.jp

ざっくりいえば何かしら値が入ってたらFALSE = 空だったらTRUEというイメージ。
そう、あくまでイメージなのが注意。emptyという関数名に騙されてはいけない。

実際にハマった点

注意すべきは表で言うところの0の場合。

empty
$var="0" TRUE
$var=0 TRUE

0の場合でも空だと判定されてしまう。これでハマった。
巷で「0の場合はtrueになるから気をつけて」と言われているので頭では理解していた。
しかし大事なのは0の場合がtrueになることを「覚えていること」ではなく「0が来る場合を想定できているか」ということだ。

以下のようなCSVから値を取得し、それを元に処理をするコードがあるとする。(実際はもっと前処理などがあるがそこは省く)

test.csv

// 商品コード, 原価, 税率
item_code,cost,tax
item1,3000,8
// CSVから値を取得
$csv = self::getValueFromCsv('test.csv');
$tax = $csv['tax'];
// 税率カラムに値が何もなかったら税率を8%にする
if(empty($tax)) { 
    $tax = 8;
}
// 単価を計算
$price = $csv['cost'] * (1+$tax/100);

割とツッコミどころの多いコードだが今はおいておこう。
一見するとemptyを使いこなせてはいそうだが、ある場合のときに$priceの値が意図しないものとなる。
ちなみに上記の場合だと$price = 3240だ。これは意図した挙動である。

どういうときに何が起こるのか

では以下のような思考CSVの入力者(ユーザー)にあった場合どうなるか。

ユーザー「とりあえず税抜きで単価を出したいから税率は0に設定するか」

// 商品コード, 原価, 税率
item_code,cost,tax
item1,3000,0

この場合、empty($tax)はtrueを返す。つまり税率が8%に設定されてしまう。
もともと税率を設定したくなかったから0にしていたのに税率が設定されてしまうやんけ!とお問い合わせが来てしまうわけだ。(てかこのコードだとtaxに空文字が入ってきてもそうなるな、まあ置いておこう。)
ちなみに想定しているのは$price = 3000だがこれだと$price=3240になってしまう。

もしくはこんな場合

ユーザー「とりあえずテストでCSVアップロードしたいから商品コードは"0"にしよう」

test.csv

// 商品コード, 原価, 税率
item_code,cost,tax
0,3000,8
// CSVから値を取得
$csv = self::getValueFromCsv('test.csv');
$item_code = $csv['item_code'];

// 商品コードに値が何もなかったら例外を出す
if(empty($item_code)) { 
    throw new Exception('商品コードが空です')
}

これもアウト。ユーザーとしては「ちゃんと入力しているのにエラーが出る!」と怒り心頭ですわ。

つまりよ

つまり何が言いたいかってユーザーの入力を信用するなってことなんですよね。
ちょっと上手いことまとめられてないのでアレがアレなんだが、一見すると便利な関数もちゃんと意味わかってないと使ってはいけない。
=> ちゃんと色んなケース想定してる?っていうこと。

別にempty()はダメゼッタイって言っているわけではなく、想定できている=empty()でも問題ないという検証&テストコードが作成されていれば全く問題ない。
ただ、少しでも可能性があるのならば別の方法で判定すべきだ。
先の商品コードの例で言えば、

// 商品コードに値が何もなかったら例外を出す
if($item_code === '') { 
    throw new Exception('商品コードが空です')
}

のように空文字判定で良い。

まとめ

いかんな、、、実際にハマったことを書いて見てくれた人に気をつけてと言いたかったが伝わっている気が全然しない。
やはり一回自分でしくじるのが一番勉強になったりする。

Laravel+Alamofireで画像のアップロードがうまくいかない件

f:id:rasukarusan:20190202192844p:plain:w400

iOSのインカメラでキャプチャを撮ってサーバー側に保存する、といった処理をしようとした際、どうもサーバー側で画像が受け取れない。
なぜか画像が$_FILEではなく$_POSTに入ってきてしまい、かつバイナリ文字列のような形で来てしまう。

結論としてはAlamofireでuploadする際、filenameを指定していなかったからだった。laravelは関係ない。
ちなみに以下に調査内容を書いていくが、結局根本的な原因は判明していない。

環境

  • Swift 4.0
  • PHP 7.0.9
  • Laravel 5.5.44
  • Alamofire 4.7.3

クライアント(Alamofire)側

巷でよく見る以下のようなコード。
画像とテキストをmultiPartFormDataでPOSTする。 しかしこのコードだとうまくアップロードできなかった。

Alamofire.upload(
    multipartFormData: { multipartFormData in
        multipartFormData.append(capture, withName: "capture", mimeType: "image/jpeg")
        multipartFormData.append(comment, withName: "comment")
    },
    to: "https://hogehoge/api/image", 
    method: .post,
    encodingCompletion: { encodingResult in
        switch encodingResult {
        case .success(let upload, _, _):
            upload.responseJSON { response in
                let responseData = response
            }
        case .failure(let encodingError):
            print(encodingError)
        }
    }
)

以下のようにappendの際filenameを指定すると画像のアップロードに成功する。 ファイル名はサーバー側で変更するし指定しなくていいだろうと思っていたのがいけなかったみたい。

multipartFormData.append(capture, withName: "capture", mimeType: "image/jpeg")
↓
multipartFormData.append(capture, withName: "capture", fileName: "upload.jepg", mimeType: "image/jpeg")

サーバー(laravel)側

サーバー側はシンプルに以下のようなコード。画像アップロードのためのAPIをlaravelで作成。
Requestからアップロードされたファイルを取得して日付ファイル名で保存する。

<?php

public function store(Request $request) {
    // 画像の保存
    if($request->file('capture')->isValid()) {
        $file_name = Carbon::now()->format('YmdHis').'.jpeg';
        $request->capture->storeAs('images', $file_name);
        return json_encode(['result' => true]);
    }
    return json_encode(['result' => false]);
}

問題の切り分け

クライアントのコードが悪いのか、サーバー側のコードが悪いのかを調査する。

laravelでAPIが叩かれたときにログを出す

なにはともあれログを出さないことには原因追求ができないため、APIが叩かれたときにログを出すように設定した。 設定は以下を参考にさせていただいた。めっちゃ簡単。 qiita.com

curlで画像アップロードをしてみる

curl -X POST http://0.0.0.0:8080/api/images \
-F comment=hoge \
-F capture=@/Users/rasukarusan/Desktop/home.png \

ログ

[2019-01-21 22:43:44] local.DEBUG: POST {"path":"/","query":"comment=hoge"}

画像の指定キーcaptureがないが、どうやら正常っぽい。サーバー側にはちゃんと画像が保存されていた。

# 画像の確認
$ ls /Users/rasukarusan/Desktop/laravel-app/storage/app/images

20190121224359.jpeg

ということはサーバー側の処理は問題ない。クライアントのコードが悪いと思われる。

Alamofireで画像アップロードをしてみる

おそらくAlamofireでの送り方が悪いのだろうと思いつつ、試しに送信してみる。

ログ

[2019-01-21 23:16:26] local.DEBUG: POST {"path":"/","query":"capture=%FF%D8%FF%E0%00%10JFIF%00%01%01%00%00H%00H%00%00%FF%E1%00XExif%00%00M00%00%FF%E1%00XExif%00%00MM%00%2A%00%00%00%08%00%02%01%12%00%03%00%00%00%01%00%01%00%00%87i%00%04%00%00%00%01%00%00%00%26%00%00%00%00%00%03%A0%01
%00%03%00%00%00%01%00%01%00%00%A0%02%00%04%00%00%00%01%00%00%01h%A0%03%00%04%00%00%00%01%00%00%01%E0%00%00%00%00%FF%ED%008Photoshop+3.0%008BIM%04%04%00%00%00%00%00%008BIM%04%25%00%00%00%00%00%10%D4%1D%8C%D9%8F.....%FF%DD%00%04%00%1,"comment:hoge"
[2019-01-21 23:16:26] local.ERROR: Undefined index: capture {"exception":"[object] (ErrorException(code: 0): Undefined index: capture at /Users/rasukarusan/Desktop/laravel-app/app/Http/Controllers/HogeController.php:41)
[stacktrace]

ものすごい量の文字列が出力が出力され、「captureなんていうキー見つからないよ」と言われる。
いやログにcaptureってあるがな、、、てかcurlの時はcaptureなかったのになんでAlamofireで送信するといきなりでてくるんや、、、。

画像の大量文字列はサーバー側でどこに格納されているのか?

エラーログが吐き出されているのはif($request->file('capture')->isValid()) {の部分。 ここの$request->file('capture')でファイルを取得しようと試みるも'capture'キーがないと怒られている。 そもそもlaravelの$request->file('capture')$_FILESに格納されているものを取得しているはずなので、「もしかしたら$_FILESに画像が入ってないんじゃね?」と推測。

以下のコードで$_FILES, $_POST, $_GETのどこに格納されているか検証した。

<?php 
public function store(Request $request) {
    \Log::debug(isset($_FILES));
    \Log::debug(isset($_POST));
    \Log::debug(isset($_GET));
    exit;
    // 画像の保存
    if($request->file('capture')->isValid()) {
        ....
    }
}

ログ

[2019-01-21 23:18:52] local.DEBUG: 
[2019-01-21 23:18:52] local.DEBUG: 1
[2019-01-21 23:18:52] local.DEBUG: 

$_FILESじゃなくてPOSTに入ってた。試しに$_POST['capture']をデバッグしてみた。

<?php 

public function store(Request $request) {
    \Log::debug(isset($_POST));
    \Log::debug($_POST['capture']);
    exit;
}

ログ

[2019-01-22 00:03:25] 1
[2019-01-22 00:03:25] local.DEBUG: array (
  'capture' => '<FF><D8><FF><E0>' . "\0" . '^PJFIF' . "\0" . '^A^A' . "\0" . '' . "\0" . 'H' . "\0" . 'H' . "\0" . '' . "\0" . '<FF><E1>'...

うっほ、、、バイナリ文字列で入ってきとるがな。 ってことはfile_get_contentsで保存できるのではと思いやってみるとちゃんと保存できた。

<?php 

public function store(Request $request) {
    // 画像の保存
    $file_name = Carbon::now()->format('YmdHis').'.jpeg';
    $file_path = $_SERVER['DOCUMENT_ROOT'].self::PATH_SAVE_IMAGE.$file_name;
    file_put_contents($file_path, $request->capture);
}
# 画像の確認
$ ls /Users/rasukarusan/Desktop/laravel-app/storage/app/images

20190122000359.jpeg

ということで本来の目的の「画像の保存」は達成できた。
がしかし全然納得していないので原因を探ってみた。ここから闇が始まる。

Alamofireの挙動を追う

なぜfileNameを指定するとちゃんと$_FILESに入り画像がアップロードできるのか、そもそもどういう経路で画像のアップロードが行われるのか調査する。 さあ、楽しいライブラリ探索の時間だ。

・Alamofire.uploadの流れ

今回肝になりそうなのはAlamofire.uploadの第一引数であるmultipartFormDataなので、ここを追う。

Alamofire.upload(
    multipartFormData: { multipartFormData in
        multipartFormData.append(capture, withName: "capture", mimeType: "image/jpeg") // ←特にappendの挙動が知りたい
        multipartFormData.append(comment, withName: "comment")
    },

定義元はMultipartFormData.swift。ここでappend(で検索。

public func append(_ data: Data, withName name: String) 
public func append(_ data: Data, withName name: String, mimeType: String) // ←fileNameを指定しない場合に実行される
public func append(_ data: Data, withName name: String, fileName: String, mimeType: String)  // ←fileNameを指定する場合に実行される
public func append(_ fileURL: URL, withName name: String) 
public func append(_ fileURL: URL, withName name: String, fileName: String, mimeType: String) 
public func append(_ stream: InputStream, withLength length: UInt64, name: String, fileName: String, mimeType: String)

検索してみてふと気づいたが、fileNameを指定した場合と指定しない場合で実行されるメソッドが違うということ。
いや至極当然のことなんだがappendでメソッド名が統一されているのでつい全部同じメソッドで実行されていると勘違いしてしまった。

ということは

  1. 指定した場合に実行されるメソッド
  2. 指定しない場合に実行されるメソッド

の挙動を調べれば原因がわかるはず。

append()の挙動(fileNameを指定した場合と指定しない場合)

各メソッドの中身は以下。

// fileNameを指定しない場合
public func append(_ data: Data, withName name: String, mimeType: String) {
    let headers = contentHeaders(withName: name, mimeType: mimeType)
    let stream = InputStream(data: data)
    let length = UInt64(data.count)

    append(stream, withLength: length, headers: headers)
}

// fileNameを指定する場合
public func append(_ data: Data, withName name: String, fileName: String, mimeType: String) {
    let headers = contentHeaders(withName: name, fileName: fileName, mimeType: mimeType)
    let stream = InputStream(data: data)
    let length = UInt64(data.count)

    append(stream, withLength: length, headers: headers)
}

ほとんど同じ処理をしている。が、唯一違うところがlet headers = contentHeaders()で渡す引数。 つまりcontentHeaders()内で何かfileName特有の処理をしているはず。

contentHeaders()の挙動

contentHeaders()の中身は以下。
appendのように同名メソッドで複数用意されているかと思ったが、contentHeaders()はデフォルト引数が設定されているので共通っぽい。

private func contentHeaders(withName name: String, fileName: String? = nil, mimeType: String? = nil) -> [String: String] {
    var disposition = "form-data; name=\"\(name)\""
    if let fileName = fileName { disposition += "; filename=\"\(fileName)\"" }

    var headers = ["Content-Disposition": disposition]
    if let mimeType = mimeType { headers["Content-Type"] = mimeType }
    print(headers)
    return headers
}

処理から推測するに、fileNameを指定した場合と指定しない場合ではContent-Dispositionの値が変わるようだ。

  • fileNameを指定する場合
"Content-Disposition": "form-data; name="capture"; filename="upload.jpeg""
  • fileNameを指定しない場合
"Content-Disposition": "form-data; name="capture""

むむむなるほど。ということはContent-Dispositionにfilenameが存在しない場合、サーバー側はどういう解釈をするのかが鍵な気がしてきた。 (Content-Dispositionって何やねん、、)

Content-Dispositionとは

developer.mozilla.org を見ていて気になる一文を発見。

通常の HTTP レスポンスにおける Content-Disposition レスポンスヘッダーは、コンテンツがブラウザーでインラインで表示されることを求められているか、つまり、ウェブページとして表示するか、ウェブページの一部として表示するか、ダウンロードしてローカルに保存する添付ファイルとするかを示します。

よくブラウザで見るダウンロードダイアログと画像プレビューの話やな。

結局謎

headerをContent-Disposition: inlineで送信しているのならなんとなくわかる。$_POSTの値がバイナリだったこともプレビューのためかもしれない。

ただAlamofireではContent-Disposition: form-data; name="hoeg"のようにfileNameを指定していようがいまいがinlineでは送らない。必ずform-dataを指定している。
また、RFC7578を見てみても、filenameを指定しない時はinlineで読み込むといった挙動もしていない。

結局自分の見るべき箇所が間違っていたのか、、、わからない。

laravel+apacheでTesting 123...と出てしまう問題の解決法

f:id:rasukarusan:20190126020540p:plain

サーバーにlaravelで作ったアプリを設置するときに若干詰まった。

結局シンプルな変更漏れっていうオチなんですけどね。

解決1

DOCUMENT_ROOTを設定するときにhttpd.confの設定で変更漏れがあった。

DocumentRoot /var/www/html/laravel-app/public
ServerName example.com

#<Directory "/var/www/html">
<Directory "/var/www/html/laravel-app/public"> # ←こっちも変更する
...

解決2

いざAPIを叩こうと思ったら以下のエラー。

f:id:rasukarusan:20190126015937p:plain

ディレクトリの権限は変更済みだしなんでだろうと思って色々調べたら解決方法が出てきた。

.htaccessの先頭に以下を追加

Options +FollowSymLinks

.htaccess

Options +FollowSymLinks
<IfModule mod_rewrite.c>
    <IfModule mod_negotiation.c>
        Options -MultiViews -Indexes
    </IfModule>

    RewriteEngine On
...

しっかりLaravelのドキュメントに書いてあったんだけどローカルでテストしていた時は出なかったエラーなのでちょっと詰まった。

Options +FollowSymLinksは「シンボリックリンクを許可する」という設定だが、どうやらmod_rewriteが内部でシンボリックリンクを使っているので必要になるっぽい。(laravelとは無関係)

httpd.apache.org

これもしっかりApacheのドキュメントに書いてあるんだよなあ...

まとめ

一回まとまった時間とってapacheやnginxとかhttpdとかインフラの基礎的なところ勉強しないとやばい。

あとやっぱりリリース作業まですると色々学ぶこと多いなあと思タヨ。

apacheのDOCUMENT_ROOTを知る方法

httpdの変更が反映されない

とあるサーバーで作業しているときにDocument Root変更したいなと思い/etc/httpd/conf/httpd.confを編集してservice httpd restartをしても全く反映されない現象に出会った。

結論から言うと参照しているhttpd.confが別のものだったからなんだが、そこに行き着くまでを備忘録として記録しておく。

環境

apacheがどのhttpdを使用しているか確認

$ ps aux | grep apache

apache   23508  0.0  1.7 531692 17620 ?        S    00:26   0:00 /usr/sbin/httpd -DFOREGROUND
apache   23509  0.0  2.0 429980 20692 ?        S    00:26   0:00 /usr/sbin/httpd -DFOREGROUND
...

/usr/sbin/httpd を使用していることがわかる

httpdの設定確認

# 先程確認したhttpdでコマンドを叩く
$ /usr/sbin/httpd  -S -D SSL

VirtualHost configuration:
*:80                   is a NameVirtualHost
         default server 155.129.XXX.XXX (/etc/httpd/conf.d/my.conf:2)
         port 80 namevhost 155.129.XXX.XXX (/etc/httpd/conf.d/my.conf:2)
         port 80 namevhost XXXXXXXX.jp (/etc/httpd/conf.d/my.conf:13)
...

/etc/httpd/conf.d/my.conf を使用していることがわかる

httpdの修正

あとはvimなりなんなりで修正して反映させる

# 編集
$ vim /etc/httpd/conf.d/my.conf
# 反映
$ service httpd restart

まとめ

解決に時間かかるときって大体しょうもないことが多いよね。

(DOCUMENT_ROOTを知る方法というかhttpd.confの場所を知る方法になってしまったがまあいい。)

Zend_Session_Namespaceの便利だけど厄介なところ〜保存方法は$session->hoge=XXXだけじゃない〜

今さらマサラタウンなZend Frameworkの話。 昔のサービスとかだと今でも健在なのか知らないけどどうなんだろう。とりあえず最近触る機会があったのでその時に詰まったことを書いていく。

今回書くのはZendのセッション管理をするクラス、Zend_Session_Namespaceの話。

Zend_Session_Namespaceでクラスオブジェクトを保存するときは気をつけて

class Hoge {
    protected $name;

    public function setProperty() {
        $this->name = 'TANAKA';
    }
}

$session = new Zend_Session_Namespace('hoge')
$session->hoge = new Hoge();

この時、$session->hoge->setProperty();としてやればクラスHogeのメソッドであるsetProperty()が実行できる。 実行すると$session->hogeの$nameに'TANAKA'がセットされる。 この状態で

echo $session->hoge->name;

とすれば'TANAKA'と表示されるだろう。ここまでは想定通りの動きである。 しかし、Zend_Session_Namespaceをなめてはいけない、内部的にはさらにもう一つ処理をしている。

なんとセッションファイルのhogeにもセットした値を書き込んでいる(更新している)のだ

セッションの保存ファイルの中身

Zend_Session_Namespaceで保存されるセッションファイルの動きを見てみよう。(デフォルトだと/tmpにsess_cabaXXXXのように保存されている)

まずは普通にセッションに保存してみる。

$session = new Zend_Session_Namespace('hoge')
$session->hoge = new Hoge();

セッションファイルは以下のような感じ。

▶cat /tmp/sess_cabeaa46f389c04badfb94d7712681b4
hoge|a:1:{s:4:"hoge";O:4:"Hoge":1:{s:7:"*name";N;}}

そして$session->hoge->setProperty();を実行してみよう。以下のようにセッションファイルが更新されているはずだ。

▶cat /tmp/sess_cabeaa46f389c04badfb94d7712681b4
hoge|a:1:{s:4:"hoge";O:4:"Hoge":1:{s:7:"*name";s:6:"TANAKA";}

見事に更新したプロパティの値が入っている。 便利、、、といえば便利だが割と初見殺しではある。

例えばセッションの管理をファイルではなくDBに保存するように修正する場合、セッションを保存している箇所を探すだろう。 その際、保存するのは$session->hoge = XXX; のように「=」で入れている箇所だけ見ているとアウトだ。 Zend_Session_Namespaceではセッションにインスタンスを保存していた場合、実行されたメソッドも見る必要がある。

なんでこういう仕組みになっているのか

結構じっくり調べてみたら、インスタンスのメンバ変数が変わったときにセッションファイルも更新されるのはZend_Session_Namespaceの機能ではなかった。(今まで書いたことは忘れてくださいごめんなさい)

そもそもの$_SESSIONで更新機能は存在していた。Zend_Session_Namespaceを使わず$_SESSIONを使って再現してみる。

$_SESSION['hoge'] = new Hoge();
$_SESSION['hoge']->setProperty();

これでも以下のようにセッションファイルは更新される。

▶cat /tmp/sess_cabeaa46f389c04badfb94d7712681b4
hoge|a:1:{s:4:"hoge";O:4:"Hoge":1:{s:7:"*name";s:6:"TANAKA";}

最初はZend_Session_Namespaceに実装されているマジックメソッド__set__getが関連しているのかと思ったが違った。 $_SESSIONの内部的な動きはつかめていないが、おそらくメンバ変数にアクセスするたびにファイル書き込みの処理が挟み込まれているのかなと推測する。そのあたりの仕組みってどうやって調べたらいいんだろ、phpのソース見るしかないのかな、、、

Vimでもデバッグできるんすわ(Vagrant+PHP+Vdebug)

Vimでもデバッグしたい

最近var_dumpやChrome.phpを使ったブラウザに変数を表示させるデバッグ方法に疲れてきたのでVimにデバッガ入れてみるかという話。 こんな感じでブレークポイント入れたり変数の確認とかのデバッグができます。

f:id:rasukarusan:20180729220611g:plain

環境

  • Vagrant(1.94)
  • PHP(5.16)
  • Mac(El Capitan)
  • Vim(8.1:Vagrantではなくローカルにインストールされているものです)

筆者の環境はVagrantでPHPが動作しており、ソースはローカルとVagrantがマウントしている状態。 いわゆるリモートデバッグがしたいという状況です。

死ぬほど参考になったサイト

基本的には以下のサイトに従って導入したが、以下の記事ではうまくいかなかったこともあったのでそれも含めて記載します。

PHPでVim使って開発していてvar_dump()を唱えているならVim Plugin のvdebugを使ってみろって - Qiita

https://blog.code-life.net/blog/2012/10/01/vim-vdebug-xdebug/

VagrantにXdebugをインストール

$ vagrant ssh

# Xdebugをインストール
[vagrant@muscle ~]$ sudo pecl install xdebug-2.0.3

# Cannot find autoconf. Please check your autoconf installation and the $PHP_AUTOCONF
# ERROR: `phpize' failedと出たら以下を実行してください
[vagrant@muscle ~]$ sudo yum install autoconf

筆者の環境ではCentOSのバージョンが低く、普通にyumでインストールできなかったのでXdebugのバージョンを指定してインストールしました。 XdebugのHPから自分のPHPのバージョンに合っているXdebugのバージョンを確認。

php.iniにXdebugの設定を追加

# xdebug.soのパスを確認
[vagrant@muscle ~]$ sudo find / -name 'xdebug.so'

[vagrant@muscle ~]$ sudo vi /usr/local/lib/php.ini
# php.iniに以下を追加
[Xdebug]
zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20192838/xdebug.so ; #<= ここはfindで見つかったxdebug.soのパス
xdebug.default_enable=1
xdebug.remote_enable=1
xdebug.remote_autostart=1
xdebug.remote_host=10.0.2.2 ; #<= Vagrantだとこのホストになるそうです。筆者の環境ではprivate_networkのIPを指定してもだめでした。
xdebug.remote_port=9001
xdebug.remote_log=/tmp/xdebug.log
xdebug.max_nesting_level=10000
xdebug.remote_connect_back=1

Xdebugを入れるとその分メモリを喰うのでphp.iniを編集してメモリの上限を上げておきます

[vagrant@muscle ~]$ sudo vi /usr/local/lib/php.ini
;memory_limit = 50M      ; Maximum amount of memory a script may consume (50MB)
memory_limit = 128M      ; Maximum amount of memory a script may consume (128MB) ; #<= Xdebugでメモリを喰うため

apacheを再起動

# vagrant reloadでも良い
[vagrant@muscle ~]$ sudo service httpd restart
httpd を停止中:                                            [  OK  ]
httpd を起動中:                                            [  OK  ]

インストールされているか確認

[vagrant@muscle ~]$ php -i | grep xdebug
xdebug
xdebug support => enabled
xdebug.auto_trace => Off => Off
xdebug.collect_includes => On => On
....
xdebug.remote_host => 10.0.2.2 => 10.0.2.2
xdebug.remote_log => /tmp/xdebug.log => /tmp/xdebug.log
xdebug.remote_mode => req => req
xdebug.remote_port => 9001 => 9001
xdebug.show_exception_trace => Off => Off
xdebug.show_local_vars => Off => Off
xdebug.show_mem_delta => Off => Off
xdebug.trace_format => 0 => 0
xdebug.trace_options => 0 => 0
xdebug.trace_output_dir => /tmp => /tmp
xdebug.trace_output_name => trace.%c => trace.%c
xdebug.var_display_max_children => 128 => 128
xdebug.var_display_max_data => 512 => 512
xdebug.var_display_max_depth => 3 => 3

上記が表示されていればインストールはOKです。
これでVagrant上の操作は終了です。ここからはローカルの設定です。

VimにVdebugをインストール

Vimでデバッグするのには「Vdebug」というプラグインを使用。 github.com

deinでインストール

call dein#add('joonty/vdebug')

vimrcにVdebugの設定を追加

" ========Vdebug======== "
let g:vdebug_options= {
\    "port" : 9001,
\    "timeout" : 20,
\    "on_close" : 'detach',
\    "break_on_open" : 0,
\    "remote_path" : "",
\    "local_path" : "",
\    "debug_window_level" : 0,
\    "debug_file_level" : 0,
\    "debug_file" : "",
\    "path_maps" : {
\       '/home/yourpath/web' : '/Users/'.$USER.'/workspace/web',
\    },
\    "window_arrangement" : ["DebuggerWatch", "DebuggerStack"]
\}

上記のpath_mapsが重要です。これをVagrant上でマウントしている先のパスと合わせます。左側がVagrantのパス、右側がローカルPCのパスとなります。 また、上記では書かれていませんが"server":"127.0.0.1"という設定をよく見ます。ただ、筆者の環境ではこれを書いている限りVagrantと疎通が取れずデバッグができなかったので削除しました。(remote_pathも同じ理由で空白しています)

以下は筆者の好みでデフォルトから変更しているもの。

キー名 意味
break_on_open いきなりブレークポイントまで行くかどうか
デフォルトは1で行かないようになっている
window_arrangement デバッガ画面に表示する項目
デフォルトは"DebuggerWatch", "DebuggerStack","DebuggerStatus"の3つが指定。

Xdebug helperのインストール

ブラウザからデバッグするために以下のChrome拡張を入れます。 chrome.google.com 設定は特にありません。デバッグしたい画面で以下の画像のように「Debug」を選択するだけです。

いざ実行

F10でブレークポイント、F5でデバッグ開始です。 デバッガ中はF3でステップ実行、F6をデバッグ終了、もう一度F6 を押すとデバッガ画面が閉じます。

F6二回押しで閉じずに:qとかで閉じるとvimが止まるか次回起動時にエラーが出るので注意してください。

vimrcでキーマップを変えることができます。デフォルトは以下になってます。

let g:vdebug_keymap = {
\    "run" : "<F5>",
\    "run_to_cursor" : "<F9>",
\    "step_over" : "<F2>",
\    "step_into" : "<F3>",
\    "step_out" : "<F4>",
\    "close" : "<F6>",
\    "detach" : "<F7>",
\    "set_breakpoint" : "<F10>",
\    "get_context" : "<F11>",
\    "eval_under_cursor" : "<F12>",
\    "eval_visual" : "<Leader>e"
\}

ちなみにこういうデフォルト値とか配列の指定どうやって見つけるの?という話だが、READMEに書いてあるのを見るのが一番だが書いていないときはソースを見に行くのが早道のときもある。 今回でいうなら.vim/dein/repos/github.com/joonty/vdebug/plugin/vdebug.vimを見てデフォルト値とか引数がわかった。vimのプラグインだったらXXX.vimというのを見に行けば大体解決する(と思う)。

非常に快適だ。