(この記事はピクシブ株式会社 AdventCalendar 2017の12日目の記事です)
今回のあらすじ
リファクタリングDSLとしてのVim scriptの威力に再び社内がおののいてる
— tad3 (@tadsan) October 25, 2017
どうおののかせたかを紹介します。
問題
pixivのURLルート定義は以下のような形になっています:1
functiongetUrlRouteMap(){$route_map=['/'=>['controller'=>'IndexController',],'/discovery'=>['controller'=>'DiscoveryController',],'/user/:user_id/series'=>['controller'=>'UserSeriesIndexController','params'=>['user_id'=>'int',],],...'/about.php'=>['controller'=>'AboutController',],'/bookmark.php'=>['controller'=>'BookmarkController',],...];return$route_map;}
見事に順番がバラバラです2。これでは後からコードを読んだ人には何処に何が書かれているか分からない状態で良くありませんし、新しい定義を追加する時にも判断に迷うので各自が好き勝手な場所に書き始めてどんどんカオスになっていきます。数が少なければまだしも、このルート定義の数は800個以上あるという……
というわけで 各ルート定義をURLの辞書順でソートするというのが今回の目的です。
回答
./sort-routes lib/routes.php
#!/bin/bash
vim -N -u NONE -i NONE -e -s -S "$0.vim""$@"
/\$route_map = \[/
mark a
normal! %
mark b'a+1,'b-1g/'\/.*' => \[/.,/^ \{8}\],$/-1s/\n/XYZZY/'a+1,'b-1sort'a+1,'b-1 s/XYZZY/\r/gupdateqall!
解説
Vimでバッチ処理をする
vim -N -u NONE -i NONE -e -s -S "$0.vim""$@"
これはイディオムです。個々のオプションの意味は以下の通りです:
-N
—-u NONE
するとvi互換モードになって色々と不便なので、それを避ける-u NONE
—vimrc
を読み込まない。自分のvimrc
に依存したスクリプトだと他の人が実行できないので-i NONE
—viminfo
を読み込まない。レジスタの初期値が変動し得るのは避けたいし、上書きするのも避けたい-e -s
— バッチ処理中にバッファの状態を表示しない。この方が速い-S {file}
— Vim起動後に:source {file}
が実行される
後はVim scriptで望みのバッチ処理を記述すればOKです。
「え? Vim scriptで……?」と怪訝に思うかも知れませんが、Vimはテキストエディタなのでその操作方法はテキスト処理に特化したDSLですし、Vim scriptは普段Vimを使う時に打つ :e foo
や :w
の延長に過ぎないので、実際簡単です。
下準備: 頻繁に操作する範囲をメモしておく
/\$route_map = \[/ mark a normal! % mark b
全てのルート定義を頻繁に編集する事が分かっているので、以後の作業を楽にする為にその範囲をメモしておきます。
/\$route_map = \[/
— 正規表現にマッチする行(=全てのルート定義を包む[
)までカーソルを移動しmark a
— その行を後から'a
で参照できるようメモするnormal! %
—[
に対応する]
(=全てのルート定義を包む]
)までカーソルを移動しmark b
— その行を後から'b
で参照できるようメモする
具体的には以下の範囲がメモされます:
functiongetUrlRouteMap(){$route_map=[// 'a'/'=>[// ---.'controller'=>'IndexController',// |-- 実際にソートしたい範囲],// |...// ---'];// 'breturn$route_map;}
実際にソートしたい範囲は 'a
より1行下かつ 'b
より 1行上ですが、敢えて1行外側をメモしておきます。外側の行ならソートの過程で変化しないのですが、内側の行はソートの過程で増減します。内側の行をメモすると途中で行が消えて 'a
や 'b
で適切な範囲が指定できなくなるからです。
本題: VimでPHPのコードをシュワルツ変換してソートする
'a+1,'b-1g/'\/.*' => \[/.,/^ \{8}\],$/-1s/\n/XYZZY/'a+1,'b-1sort'a+1,'b-1 s/XYZZY/\r/g
Vimには sort
コマンドがあるので、特定の範囲を行単位でソートするなら 'a,'b sort
で済みます。ところが各ルート定義は複数行に渡っているので、このままだと sort
では太刀打ちできません。
でも
- 各ルート定義を1行に変換して
sort
して- 各行をルート定義に逆変換する
とすればソート可能です。
各ルート定義を1行に変換する
'a+1,'b-1g/'\/.*' => \[/.,/^ \{8}\],$/-1s/\n/XYZZY/
{range}g/{pattern}/{command}
で
{range}
の範囲の中にある{pattern}
にマッチする各行について{command}
を実行する
事ができます。
なので、この長いコマンドは
'a+1,'b-1
— ソートしたい範囲の中にある/'\/.*' => \[/
— 各ルート定義の開始行について.,/^ \{8}\],$/-1s/\n/XYZZY/
— 何か凄い事をする
という意味になります。
.,/^ \{8}\],$/-1s/\n/XYZZY/
は一瞥しただけだと謎のコマンドですが、実態は {range}s/{pattern}/{replacement}/
なので、単なる文字列置換です。具体的には
.,/^ \{8}\],$/-1
— カーソル行(=ルート定義の開始行)から正規表現にマッチする行の直前の行(=ルート定義の最後から2番目の行)について\n
— 改行文字をXYZZY
— 通常のソースコードには絶対に現れない文字列に置き換える3
となります。
実行結果は以下のようになります:
functiongetUrlRouteMap(){$route_map=['/'=>[XYZZY'controller'=>'IndexController',XYZZY],'/discovery'=>[XYZZY'controller'=>'DiscoveryController',XYZZY],'/user/:user_id/series'=>[XYZZY'controller'=>'UserSeriesIndexController',XYZZY'params'=>[XYZZY'user_id'=>'int',XYZZY],XYZZY],...'/about.php'=>[XYZZY'controller'=>'AboutController',XYZZY],'/bookmark.php'=>[XYZZY'controller'=>'BookmarkController',XYZZY],...];return$route_map;}
ソートする
'a+1,'b-1sort
これは見た通りですね。
実行結果は以下のようになります:
functiongetUrlRouteMap(){$route_map=['/'=>[XYZZY'controller'=>'IndexController',XYZZY],'/about.php'=>[XYZZY'controller'=>'AboutController',XYZZY],'/bookmark.php'=>[XYZZY'controller'=>'BookmarkController',XYZZY],'/discovery'=>[XYZZY'controller'=>'DiscoveryController',XYZZY],'/user/:user_id/series'=>[XYZZY'controller'=>'UserSeriesIndexController',XYZZY'params'=>[XYZZY'user_id'=>'int',XYZZY],XYZZY],...];return$route_map;}
各行をルート定義に逆変換する
'a+1,'b-1 s/XYZZY/\r/g
逆変換は g
を駆使したややこしい行指定がしなくて良いので簡単です。
実行結果は以下のようになります:
functiongetUrlRouteMap(){$route_map=['/'=>['controller'=>'IndexController',],'/about.php'=>['controller'=>'AboutController',],'/bookmark.php'=>['controller'=>'BookmarkController',],'/discovery'=>['controller'=>'DiscoveryController',],'/user/:user_id/series'=>['controller'=>'UserSeriesIndexController','params'=>['user_id'=>'int',],],...];return$route_map;}
余談
実務で遭遇した例はもう一段ややこしく、一部のルート定義に対して以下のようにコメントが付いていました:
functiongetUrlRouteMap(){$route_map=[...// 旧URLのサポート用// TODO: #123 がマージされたらこれは消す'/discover'=>['redirect'=>'/discovery',],'/discovery'=>['controller'=>'DiscoveryController',],...];return$route_map;}
つまり、コメントも維持しつつソートをする必要があったという事です。
さらに、今回の記事の問題は氷山の一角に過ぎず、これに先立って
- ルート定義は一部しか存在せず、残りは
htdocs/*.php
が直接存在する状態だった。ルート定義を生成しつつhtdocs/*.php
を消す必要があった htdocs/*.php
の中にコントローラークラスが記述されていた。ルート定義を生成する前に、クラス定義を別ファイルに分離する必要があった- 一部のコントローラークラスは名前が重複していた為、一意な名前に変更する必要があった
htdocs/*.php
の中身がベタなPHPスクリプトになっているものが少なからず存在していた。そういうものはコントローラークラスの体裁にまとめ直す必要があった
という楽しいリファクタリングが山盛りでした。もちろん全てVimで解決しました。
告知
ピクシブ株式会社では、このように大量のファイルを高速にリファクタリングするのが好きなエンジニア・アルバイトを募集しています。使用エディタは問いません。
明日は @RaggがRailsアプリのCSS設計の知見を披露してくれます。お楽しみに。