連載記事です。前回までにルーティングとモデルの準備まで行なったので、第6回は談話ごとの発話総覧を作ります。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。
- 第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 でデプロイする
画面遷移図
画面遷移図を再掲します。
コンポーネントへのルーティング
画面遷移図のとおりにルーティングします。まずはパスとコンポーネントを紐づけてガワを作ります。props: true
にするとアドレスに含まれる変数を使用できます。
+ import DiscourseIndexComponent from "./components/DiscourseIndexComponent";
+ import DiscourseShowComponent from "./components/DiscourseShowComponent";
+ import UtteranceShowComponent from "./components/UtteranceShowComponent";
+ import UtteranceEditComponent from "./components/UtteranceEditComponent";
+ {
+ path: "/discourse",
+ name: "discourse.index",
+ component: DiscourseIndexComponent
+ },
+ {
+ path: "/discourse/:discourseid",
+ name: "discourse.show",
+ component: DiscourseShowComponent,
+ props: true
+ },
+ {
+ path: "/utterance/:discourseid/:utteranceid",
+ name: "utterance.show",
+ component: UtteranceShowComponent,
+ props: true
+ },
+ {
+ path: "/utterance/:discourseid/:utteranceid/edit",
+ name: "utterance.edit",
+ component: UtteranceEditComponent,
+ props: true
+ }
各コンポーネントの作成
それぞれのページのレイアウトを作成していきます。前回作成した app.blade.php
の <router-view>
位置に入る部品を作っていくのですが、<template>
でひな形を作成し、そこに <script>
で規定したデータや関数を加えていく寸法になります。
談話一覧
まず「談話一覧」ページは、データベースから「談話ID・収録地点・収録場所・収録年月日・話題・種別・データ名」をとってきてリスト表示するようにします。また、談話ごとに「発話一覧」ページへのリンクを貼ります。
<template>
<div>
<table class="table table-sm table-hover">
<thead class="thead-dark">
<tr>
<th>談話ID</th>
<th>収録地点</th>
<th>収録場所</th>
<th>収録年月日</th>
<th>話題</th>
<th>種別</th>
<th>データ名</th>
</tr>
</thead>
<tbody>
<tr v-for="d in discourses" v-bind:key="d.discourseid">
<td>
<router-link
v-bind:to="{
name: 'discourse.show',
params: {discourseid: d.discourseid}
}"
>
<button class="btn btn-success btn-sm">
{{ d.discourseid }}
</button>
</router-link>
</td>
<td>{{ d.prefecturename }}{{ d.placename }}</td>
<td>{{ d.recordplace }}</td>
<td>{{ d.recorddate }}</td>
<td>{{ d.topic }}</td>
<td>{{ d.genre }}</td>
<td>{{ d.reference }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
export default {
data: function() {
return {
discourses: []
};
},
methods: {
getDiscourses() {
axios.get("/api/discourse").then(res => {
this.discourses = res.data;
});
}
},
mounted() {
this.getDiscourses();
}
};
</script>
下部の <script>
部分で表示するためのデータをロードします。data
にデータを入れる箱だけ用意しておいて、methods
にデータを取得してその箱に格納する関数を定義する感じです。ここでは axios
で非同期的にデータを取得する処理だけ書いて、データベースとの実際のやり取りは別箇所で定義します(/api/discourse
というフェッチ先パスについては後述します)。mounted()
に関数を書いておくと、ページのロード完了時に自動的に実行されます。
上部の <template>
には bootstrap で適当にデザインをつけながら、先ほど取得したデータを1レコードずつ表示していきます。vue.js の記法を少し勉強する必要がありますが、それほど難しくありません。今回はテーブルの行をデータ数だけ繰り返し表示したいので <tr>
要素に v-for
を指定して、各要素は {{ }}
記法で記入していきます。
また <router-link>
は Vue Router の独自コンポーネントで、app.js
で規定したルーティングの名前を利用してリンクを貼ることができますので、これも利用します。右端に[閲覧]のような専用ボタンを用意してもよかったのですが、ページ幅の都合から、談話IDに発話一覧へのリンクを兼ねさせることにしました。
発話一覧
先ほどとほとんど同じですが、パスに含まれる談話ID (discourseid) を利用できるように props
内に指定しておきます。
<template>
<div>
<table class="table table-sm table-hover">
<thead class="thead-dark">
<tr>
<th class="text-nowrap">談話ID</th>
<th class="text-nowrap">発話ID</th>
<th>話者</th>
<th>方言</th>
<th>標準語</th>
<th>始点</th>
<th>終点</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in utterances" v-bind:key="u.utteranceid">
<td>{{ u.discourseid }}</td>
<td>{{ u.utteranceid }}</td>
<td>{{ u.speakerid }}</td>
<td>{{ u.dialecttext }}</td>
<td>{{ u.standardtext }}</td>
<td>{{ u.tmin }}</td>
<td>{{ u.tmax }}</td>
<td>
<router-link
v-bind:to="{
name: 'utterance.show',
params: {
discourseid: u.discourseid,
utteranceid: u.utteranceid
}
}"
>
<button class="btn btn-success btn-sm text-nowrap">
閲覧
</button></router-link
>
</td>
<td>
<router-link
v-bind:to="{
name: 'utterance.edit',
params: {
discourseid: u.discourseid,
utteranceid: u.utteranceid
}
}"
>
<button class="btn btn-success btn-sm text-nowrap">
編集
</button></router-link
>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
export default {
props: {
discourseid: String
},
data: function() {
return {
utterances: []
};
},
methods: {
getUtterances() {
axios.get("/api/discourse/" + this.discourseid).then(res => {
this.utterances = res.data;
});
}
},
mounted() {
this.getUtterances();
}
};
</script>
発話詳細
単一の発話について詳細を表示するだけなので、v-for
を使う必要はありません。発話についてのすべての情報を表示したいので、データベースとのやり取りでは何度も結合を行なう必要がありますが、それはコントローラの責任なので、コンポーネントを作成する段階では意識しなくても大丈夫です。utterance
テーブル自体にどんな属性があるかはとりあえず考えず、欲しい情報を欲しいだけ記述しておきましょう。
<template>
<div>
<table class="table table-sm table-hover">
<tbody>
<tr>
<td>談話ID</td><td>{{ utterance.discourseid }}</td>
</tr>
<tr>
<td>発話ID</td><td>{{ utterance.utteranceid }}</td>
</tr>
<tr>
<td>発話区間</td><td>{{ utterance.tmin }} ~ {{ utterance.tmax }}</td>
</tr>
<tr>
<td>方言</td><td>{{ utterance.dialecttext }}</td>
</tr>
<tr>
<td>標準語</td><td>{{ utterance.standardtext }}</td>
</tr>
<tr>
<td>話者ID</td><td>{{ utterance.speakerid }}</td>
</tr>
<tr>
<td>話者生年</td><td>{{ utterance.speakerbirthyear }}</td>
</tr>
<tr>
<td>話者性別</td><td>{{ utterance.speakersex }}</td>
</tr>
<tr>
<td>地点</td><td>{{ utterance.prefecturename }}{{ utterance.placename }}</td>
</tr>
<tr>
<td>収録場所</td><td>{{ utterance.recordplace }}</td>
</tr>
<tr>
<td>収録年月日</td><td>{{ utterance.recorddate }}</td>
</tr>
<tr>
<td>収録担当者</td><td>{{ utterance.recorder }}</td>
</tr>
<tr>
<td>編集担当者</td><td>{{ utterance.editor }}</td>
</tr>
<tr>
<td>話題</td><td>{{ utterance.topic }}</td>
</tr>
<tr>
<td>ジャンル</td><td>{{ utterance.genre }}</td>
</tr>
<tr>
<td>出典</td><td>{{ utterance.reference }}</td>
</tr>
</tbody>
</table>
<div class="text-right">
<router-link
v-bind:to="{
name: 'utterance.edit',
params: { discourseid: this.discourseid, utteranceid: this.utteranceid }
}"
>
<button class="btn btn-success btn-sm text-nowrap">
編集
</button></router-link
>
</div>
</div>
</template>
<script>
export default {
props: {
discourseid: String,
utteranceid: String
},
data: () => {
return {
utterance: {}
};
},
methods: {
getUtterance() {
axios.get("/api/utterance/" + this.discourseid + "/" + this.utteranceid )
.then(res => {
this.utterance = res.data[0];
});
}
},
mounted() {
this.getUtterance();
}
};
</script>
発話編集
方言テキストと標準語テキストだけ編集できるようにしたいので、この2つだけ v-model
でバインドした入力欄を作ります。また、ログには新旧テキストを保存したいので、データの読み込み段階で元のテキストを olddialecttext
および oldstandardtext
として保持しておいて、変更後のテキストを newdialecttext
および newstandardtext
として送信するようにしています。
<template>
<div>
<table class="table table-sm table-hover">
<tbody>
<tr>
<td>談話ID</td><td>{{ utterance.discourseid }}</td>
</tr>
<tr>
<td>発話ID</td><td>{{ utterance.utteranceid }}</td>
</tr>
<tr>
<td>話者</td><td>{{ utterance.speakerid }}</td>
</tr>
<tr>
<td>発話区間</td><td>{{ utterance.tmin }} ~ {{ utterance.tmax }}</td>
</tr>
<tr>
<td>方言</td>
<td>
<input
class="w-100"
type="text"
id="dialecttext"
v-model="utterance.dialecttext"
/>
</td>
</tr>
<tr>
<td>標準語</td>
<td>
<input
class="w-100"
type="text"
id="standardtext"
v-model="utterance.standardtext"
/>
</td>
</tr>
</tbody>
</table>
<div class="text-right">
<span>
<button
class="btn btn-success btn-sm text-nowrap"
@click="submit"
>
変更を反映
</button>
</span>
</div>
</div>
</template>
<script>
export default {
props: {
discourseid: String,
utteranceid: String
},
data: function() {
return {
utterance: {}
};
},
methods: {
getUtterance() {
axios
.get("/api/utterance/" + this.discourseid + "/" + this.utteranceid + "/edit")
.then(res => {
this.utterance = res.data[0];
this.utterance.olddialecttext = this.utterance.dialecttext;
this.utterance.oldstandardtext = this.utterance.standardtext;
});
},
submit() {
axios.post("/api/utterance/update", {
newUtterance: JSON.stringify(this.utterance)
})
.then(res => {
message.textContent = "更新されました。";
});
}
},
mounted() {
this.getUtterance();
}
};
</script>
コントローラへのルーティング
データベースとのやり取りやデータ処理はコントローラ上に実装するので、まずはコントローラへのルーティングを行ないます。SPA なので、ページ遷移するのではなく、API を介してデータだけ取得するようにします。API としてのルーティングは routes/web.php
ではなく routes/api.php
で行ないます。
以下のようにパスとコントローラの各関数を紐づけておきます。api.php
で定義したルーティングは使用時にプリフィックスとして api/
をつけてアクセスすることになります(たとえば最上段は /discourse
ではなく /api/discourse
に GET アクセスする)。
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/discourse', 'DiscourseController@index');
Route::get('/discourse/{discourseid}', 'DiscourseController@show');
Route::get('/utterance/{discourseid}/{utteranceid}', 'UtteranceController@show');
Route::get('/utterance/{discourseid}/{utteranceid}/edit', 'UtteranceController@edit');
Route::post('/utterance/update', 'UtteranceController@update');
各コントローラの作成
そうしたらコントローラを2つ作成します。
談話コントローラ
談話コントローラには、談話一覧を取得する関数 index
と談話ごとの発話一覧を取得する関数 show
を定義します。後者は発話コントローラに定義してもよかった気がしますが、とりあえずこちらに定義しておきます。
index
関数ですが、discourse
テーブルには県番号 prefecturenum や地点ID placeid はあるものの、県名 prefecturename や地点名 placename が存在しないので、他のテーブルと結合して取得します。
show
関数はセオリー通りで特に説明することはありません。
<?php
namespace App\Http\Controllers;
use App\Models\Discourse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DiscourseController extends Controller{
public function index(){
$data = DB::table('discourse')
->join('place', function ($join) {
$join
->on('discourse.prefecturenum', '=', 'place.prefecturenum')
->on('discourse.placeid', '=', 'place.placeid');
})
->join('prefecture', 'discourse.prefecturenum', '=', 'prefecture.prefecturenum')
->select('discourse.*', 'place.placename', 'prefecture.prefecturename')
->get();
return $data;
}
public function show(String $discourseid){
$data = DB::table('utterance')
->select('utterance.*')
->where('discourseid', '=', $discourseid)
->get();
return $data;
}
}
発話コントローラ
発話コントローラには、発話詳細のためのデータ取得 show
、発話編集のためのデータ取得 edit
、発話レコードのアップデート update
を定義します。
show
関数は先ほどと同様に、他テーブルと結合して必要な情報を取得します。条件にマッチする1レコードのみ取得すればよいので、コントローラ上で ->first()
してもいいのですが、今回はコンポーネント上で data[0]
として絞り込んでいます。
edit
関数については、発話編集画面に必要な情報はシンプルなので、条件に一致するレコードを返すだけです。
update
関数では、utterance の更新と changelog へのログ追加を行ないます。update
のみ POST アクセスとしてルーティングしましたので、パラメータは $request->input('param')
のように取得しています。本来はトランザクション処理にするべきですが、Laravel のデフォルト設定だとこうした 複数テーブルへの処理は自動的に異なるコネクションとなる ため、DB::begintransaction()
→ DB::rollbaack()
などとやってもうまくロールバックしません。そのためトランザクションは作成せずそのまま処理しています(ダメでは?)。
<?php
namespace App\Http\Controllers;
use App\Models\Utterance;
use App\Models\Discourse;
use App\Models\Changelog;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class UtteranceController extends Controller{
public function show(String $discourseid, String $utteranceid){
$record = DB::table('utterance')
->select('utterance.*')
->where('utterance.discourseid', '=', $discourseid)
->where('utterance.utteranceid', '=', $utteranceid)
->join('speaker', 'utterance.speakerid', '=', 'speaker.speakerid')
->join('discourse', 'utterance.discourseid', '=', 'discourse.discourseid')
->join('place', function ($join) {
$join
->on('discourse.prefecturenum', '=', 'place.prefecturenum')
->on('discourse.placeid', '=', 'place.placeid');
})
->join('prefecture', 'discourse.prefecturenum', '=', 'prefecture.prefecturenum')
->select('*')
->get();
return $record;
}
public function edit(String $discourseid, String $utteranceid){
$record = DB::table('utterance')
->select('utterance.*')
->where('discourseid', '=', $discourseid)
->where('utteranceid', '=', $utteranceid)
->get();
return $record;
}
public function update(Request $request){
$json = $request->input('newUtterance');
$json = mb_convert_encoding($json, 'UTF8', 'ASCII,JIS,UTF-8,EUC-JP,SJIS-WIN');
$arr = json_decode($json);
try {
$discourseid = $arr->discourseid;
$utteranceid = $arr->utteranceid;
$olddialect = $arr->olddialecttext;
$oldstandard = $arr->oldstandardtext;
$newdialect = $arr->dialecttext;
$newstandard = $arr->standardtext;
$log = new Changelog([
'discourseid' => $discourseid,
'utteranceid' => $utteranceid,
'olddialecttext' => $olddialect,
'oldstandardtext' => $oldstandard,
'newdialecttext' => $newdialect,
'newstandardtext' => $newstandard,
'updatedtime' => date_create($request->date)
]);
$log->save();
DB::table('utterance')
->where('discourseid', '=', $discourseid)
->where('utteranceid', '=', $utteranceid)
->update(['dialecttext'=>$newdialect, 'standardtext'=>$newstandard]);
} catch (\Exception $e) {
throw $e;
}
}
}
完成図
以下のようになっているはずです。
/discourse
一部日付データがバグってるのはご愛敬。
改善点
本連載では最低限必要な部分だけに絞って解説しているので、実際には説明文を書いたり、ロード中のスピナーを仕込んだり、セキュリティへの配慮をもうちょっと頑張ったりしています。たとえば上のコードでは次のような点が危険です。
- パスに含まれる文字列をエスケープをせずに使っている
-
try - catch
が実質無意味
そこらへんは PHP や JavaScript の一般的な注意点と同様なので、この記事では解説しません。
あと書き終わった時点で「更新処理後のリダイレクト実装してない!」と気づきましたが、気力が尽きたので今は放っておきます。
次回
第2の機能「話者ごとの発話総覧」を作りこんでいきます。