連載記事です。前回までに談話・話者ごとの発話総覧を作ったので、今回は最後の機能「サイト上での Excel と TextGrid の相互変換」を実装します。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。
先述のとおり、今回は既に TextGrid と Excel を変換する Python スクリプトが存在しているので(内容には詳しく触れません)、これを Laravel アプリに組み込んでサーバー上で実行することを目指します。
- 第1回: 日本語諸方言コーパスをDB化して遊ぶ (1) 構成を考える
- 第2回: 日本語諸方言コーパスをDB化して遊ぶ (2) SQLite3 で DB 化
- 第3回: 日本語諸方言コーパスをDB化して遊ぶ (3) PHP Laravel で操作する
- 第4回: 日本語諸方言コーパスをDB化して遊ぶ (4) サービスの全体像を決める
- 第5回: 日本語諸方言コーパスをDB化して遊ぶ (5) データベースの移行とモデルの作成
- 第6回: 日本語諸方言コーパスをDB化して遊ぶ (6) 談話ごとの発話総覧を作る
- 第7回: 日本語諸方言コーパスをDB化して遊ぶ (7) 話者ごとの発話総覧を作る
- 第8回: 日本語諸方言コーパスをDB化して遊ぶ (8) ファイル形式変換機能をつける ←今ここ
- 第9回: 日本語諸方言コーパスをDB化して遊ぶ (9) Heroku でデプロイする
事前準備
今回はサーバー上のストレージにファイルを保存し、それを変換し、ダウンロードする仕組みを作るので、最初にそのあたりの設定をしておきます。アップロードしたファイルは storage
フォルダに保存されますが、一般公開されるのは public
フォルダなので、慣例に従い public/storage
から storage/app/public
へシンボリックリンクを張ります。下記の artisan コマンドで勝手に貼ってくれます1。
php artisan storage:link
画面遷移図
画面遷移図を再掲します。1ページですけど。
コンポーネントのルーティング
コンポーネントがひとつしかないので、特に解説することはありません。
+ import ConvertComponent from "./components/ConvertComponent";
+ {
+ path: "/convert",
+ name: "convert",
+ component: ConvertComponent
+ }
コンポーネントの作成
1画面ではありますが、たくさん機能をつけるので前回までよりは複雑です。
<template>
<div>
<form enctype="multipart/form-data">
<input
type="file"
name="file"
id="fileRef"
style="display: none"
@change="fileSelected"
/>
<div class="input-group">
<input
type="text"
id="fileShow"
class="form-control"
placeholder="select file..."
readonly
/>
<div class="input-group-append">
<span class="input-group-btn">
<button
type="button"
class="btn btn-outline-success"
onclick="fileRef.click()"
>
Browse
</button>
</span>
<button
type="button"
class="btn btn-success"
@click="fileUpload"
>
Upload
</button>
</div>
</div>
</form>
<div class="pt-3">
<table class="table table-sm table-striped">
<thead>
<tr class="thead-dark">
<th colspan="2">
<div class="text-center">ファイル一覧</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="file of files" v-bind:key="file.name">
<td>
<span class="pl-3">{{ file.replace("public/", "") }}</span>
</td>
<td>
<div class="text-right">
<span
class="btn btn-success btn-sm"
@click="toTextgrid(file)"
v-if="file.indexOf('.xls') != -1"
>
to TextGrid
</span>
<span
class="btn btn-outline-success btn-sm disabled"
v-else
>
to TextGrid
</span>
<span
class="btn btn-success btn-sm"
@click="toExcel(file)"
v-if="file.indexOf('.txt') != -1 || file.indexOf('.TextGrid') != -1"
>
to Excel
</span>
<span
class="btn btn-outline-success btn-sm disabled"
v-else
>
to Excel
</span>
<a
v-bind:href="'./storage' + file.replace('public', '')"
v-bind:download="file.replace('public', '')"
>
<span class="btn btn-warning btn-sm">
download
</span>
</a>
<span
class="btn btn-danger btn-sm"
@click="deleteFile(file)"
>
delete
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
export default {
data: function() {
return {
files: [],
uploadingFileInfo: ""
};
},
methods: {
fileSelected(event) {
this.uploadingFileInfo = event.target.files[0];
fileShow.value = fileRef.value.replace("C:\\fakepath\\", "");
},
fileUpload() {
if (this.uploadingFileInfo) {
const formData = new FormData();
formData.append("file", this.uploadingFileInfo);
axios.post("/api/toolkit/upload", formData).then(res => {
fileRef.value = "";
fileShow.value = "";
this.uploadingFileInfo = "";
this.getFileList();
});
} else {
alert("アップロードするファイルを選択してください");
}
},
getFileList() {
axios.get("/api/convert/files").then(res => {
this.files = res.data;
});
},
to_textgrid(path) {
axios.post("/api/convert/toTextgrid", { filepath: path }).then(() => {
this.getFileList();
});
},
to_excel(path) {
axios.post("/api/convert/toExcel", { filepath: path }).then(() => {
this.getFileList();
});
},
deleteFile(path) {
axios.post("/api/convert/delete", { filepath: path }).then(() => {
this.getFileList();
});
}
},
mounted() {
this.getFileList();
}
};
</script>
ファイル選択フォーム
file
フォームは bootstrap のみではあまりいい感じになりません。いくつか簡便な手法が考案されていますが、今回は以下のサイトを参考にしました。
変換ボタン
[to TextGrid] や [to Excel] のボタンはファイルの拡張子によって切り替えて、適切な拡張子のときのみ、クリックで発火するようにしています。TextGrid とかいう形式があるせいで mimetype
による場合分けが使えないので、単純にファイル名に .txt
や .TextGrid
などの文字列が含まれるかどうかで場合分けしています2。場合分け自体は v-if
・v-else
でパパっと。
<!-- .txt/.TextGrid なら上の有効ボタンを表示する -->
<span
class="btn btn-success btn-sm"
@click="toExcel(file)"
v-if="file.indexOf('.txt') != -1 || file.indexOf('.TextGrid') != -1"
>
to Excel
</span>
<!-- そうでないなら下の無効ボタンを表示する -->
<span
class="btn btn-outline-success btn-sm disabled"
v-else
>
to Excel
</span>
ダウンロードボタン
各種変換や削除はクリックで関数を実行するようにしていますが、ダウンロードだけはファイルに直接リンクを貼っています。Laravel のサーバーからファイルをダウンロードする方法はいくつかあるのですが、Storage
ファサードや response()
を使った手法はどうもうまくいかなかったので(数敗)3、直接リンクを貼る方法を採用しました。
ファイルパスは後述のようなシンプルな方法で取得すると /storage/app
以下のパスを返す(=/public/filename.ext
のようなパスが返る)ので、適当に置換してシンボリックリンク先の (/public)/storage/filename.ext
に直接リンクを貼ります。
<a
v-bind:href="'./storage' + file.replace('public', '')"
v-bind:download="file.replace('public', '')"
>
<span class="btn btn-warning btn-sm">
download
</span>
</a>
コントローラへのルーティング
すべて FileController
に実装するので、関数名を適当に考えてルーティングを api.php
に書いておきます。
+ Route::get('/convert/files', 'FileController@getFileList');
+ Route::post('/convert/upload', 'FileController@upload');
+ Route::post('/convert/e_t', 'FileController@toTextgrid');
+ Route::post('/convert/t_e', 'FileController@toExcel');
+ Route::post('/convert/delete', 'FileController@deleteFile');
コントローラの作成
先ほど使用することにした5つの機能を実装します。
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
class FileController extends Controller{
// ファイルをアップロードして保存する
public function upload(Request $request){
$filename = $request->file('file')->getClientOriginalName();
$request->file('file')->storeAs('public/',$filename);
}
// Excel を TextGrid に変換して保存
public function toTextgrid(Request $request) {
exec("which python", $pythonpath);
$scriptpath = app_path('Python/excel_to_textgrid.py');
$filepath = storage_path('app/' . $request->input('filepath'));
$command = $pythonpath[0] . ' ' . $scriptpath . ' ' . $filepath;
exec($command);
}
// TextGrid を Excel に変換して保存;上とほぼ同じ
public function toExcel(Request $request) {
exec("which python", $pythonpath);
$scriptpath = app_path('Python/textgrid_to_excel.py');
$filepath = storage_path('app/' . $request->input('filepath'));
$command = $pythonpath[0] . ' ' . $scriptpath . ' ' . $filepath;
exec($command);
}
// ファイルのリストを取得する
public function getFileList(){
// true は .gitignore などの dotfile を除外する
$files = Storage::allfiles('public/', true);
// SplFileInfo 型は javascript 上で扱いにくいので、ファイルパス文字列にして返す(悪?)
$filepaths = explode('#', implode('#', $files));
return $filepaths;
}
// ファイルを削除する
public function deleteFile(Request $request){
$filepath = $request->input('filepath');
Storage::delete($filepath);
}
}
Python スクリプトの実行
サーバーに Python がインストールされていさえすれば、PHP の exec
コマンドで Python を動かすことができます。後述しますが、Heroku ビルド時に Python と使用するモジュールを忘れずにインストールしておきましょう。
Heroku は Linux 系なので4、Linux コマンドを意識して書いていきます。今回はコンテナ仮想化などはせず Windows10 で開発しましたが、本記事で扱うのはちょっとしたものなので、大きな問題はありませんでした。
実行までの手順はシンプルです。今回使用するスクリプトは「対象ファイルのパスを与えると、そのファイルを変換して、同ディレクトリに保存する」ものですので、Python 実行ファイルのパス・スクリプトのパス・対象ファイルのパスを取得して、それをもとにコマンドを組み立てるだけです。今回、スクリプトは /app/Python
下に入れてあるので、app_path
などのパスヘルパを使用して無難にパスを取得します(ヘルパを使わないとルートのずれに対して不安定になる)。
<?php
// Excel を TextGrid に変換して保存
public function toTextgrid(Request $request) {
// 実行環境での python へのパスを取得
// Windows cmd なら exec("where python", $pythonpath);
exec("which python", $pythonpath);
// 実行したい python スクリプトのパスを取得
$scriptpath = app_path('Python/excel_to_textgrid.py');
// POST されてきた filepath を取得して適切な相対パスに変換
$filepath = storage_path('app/' . $request->input('filepath'));
// コマンドを組み立てて実行
// 環境に複数バージョンの Python がある場合は index に注意
$command = $pythonpath[0] . ' ' . $scriptpath . ' ' . $filepath;
exec($command);
}
なお、ここで使用したスクリプトは入力ファイルと同じディレクトリに出力ファイルを保存する設定になっています。
完成図
こんな感じになっているはずです。
改善点
既に言及したエラーハンドリングなどはもとより、セキュリティ上は exec
が大きな問題です。一般にユーザーが改竄できるデータを PHP の exec
関数にそのままぶち込むのは大変危険ですので、適切にエスケープする必要があります。今回はいちおう Laravel のパスヘルパを通しているので大丈夫じゃないかな、と思いますが、ヘルパの正確な挙動を把握していない限りは万全を期したほうがよいでしょう。
次回
Heroku に上げていきます(最終回)。
-
後述しますが、ローカルで張ったシンボリックリンクが Heroku 上で勝手に張られることはないので、必要な命令を
composer.json
に書き込んでおいて、ビルド時にシンボリックリンクが張られるようにしておく必要があります。 ↩ -
ほんとうはこんなフロントエンドのなんちゃって検証ではなく、サーバ側でちゃんと入力ファイルを検証しないとダメ。 ↩
-
パス解決に失敗したり、403エラーが出たり、POSTレスポンスにファイル内容は積まれてくるけどダウンロードまで行けなかったりしました。 ↩
-
Heroku では Dyno という、Amazon EC2 の巨大インスタンス上で動作する軽量 Linux コンテナが使われています。 ↩