20200817のlaravelに関する記事は16件です。

【PHP】Herokuへデプロイしたアプリの投稿機能で500エラー

アカウント登録からデプロイまでとても簡単なHerokuでしたが、
作成したアプリケーションの記事投稿機能でPOSTする時に500エラーを出してしまったので、
対処する時に行った事を備忘録として残しておく。

環境

PHP 7.3.8
Laravel 6.18.35
DBはPostgreSQL
Heroku CLI導入済み

①ログを確認する

heroku logsコマンドでログを確認してみる
or
HerokuダッシュボードのMoreView logsからでもログが確認可能です。
スクリーンショット 2020-08-17 22.32.51.png
ただこの時点ではstatus=500が発生した事以上の情報が得られないので、
詳細な情報を出力する為にHerokuの環境変数にLOG_CHANNEL=errorlogを設定します。

ここでエラーが発見出来れば良いのですが、今回はエラーが検知出来なかったので、
次の手を考えます。

②デバッグモードにしてみる

デバッグモードをtrueにしてプッシュ

app.php
    /*
    |--------------------------------------------------------------------------
    | Application Debug Mode
    |--------------------------------------------------------------------------
    |
    | When your application is in debug mode, detailed error messages with
    | stack traces will be shown on every error that occurs within your
    | application. If disabled, a simple generic error page is shown.
    |
    */

    'debug' => env('APP_DEBUG', true),

↓それっぽいのが出てきましたね
スクリーンショット 2020-08-17 22.49.14.png

③調査結果から

結論、GDまたはImageMagickの導入が必要ということが分かった。

composer.json
    "require": {
        省略
        "ext-gd": "*"
    },

無事にHeroku環境で投稿機能が動作するようになりましたとさ。

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

php artisan serveをしたらエラーになった

laravelで作成したプロジェクトでサーバを起動しようとしたところこのようなエラーがでた

Version
・PHP 7.4.9
・Composer 2.0.0-alpha3
・Laravel Installer 3.2.0

$php artisan serve

Laravel development server started: http://127.0.0.1:8000
Sun Aug 16 19:18:09 2020 (10355): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8001
Sun Aug 16 19:18:09 2020 (10356): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8002
Sun Aug 16 19:18:09 2020 (10357): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8003
Sun Aug 16 19:18:09 2020 (10358): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8004
Sun Aug 16 19:18:09 2020 (10359): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8005
Sun Aug 16 19:18:09 2020 (10360): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8006
Sun Aug 16 19:18:09 2020 (10361): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8007
Sun Aug 16 19:18:09 2020 (10362): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8008
Sun Aug 16 19:18:09 2020 (10363): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8009
Sun Aug 16 19:18:09 2020 (10364): Fatal Error Unable to create lock file: Bad file descriptor (9)
Laravel development server started: http://127.0.0.1:8010
Sun Aug 16 19:18:09 2020 (10365): Fatal Error Unable to create lock file: Bad file descriptor (9)

ロックファイルが作成できないらしい。

↓この人と同じ現象だったので、ルートのtmpディレクトリに書き込みの権限を与えたところ解決した。
https://stackoverflow.com/questions/39098717/fatal-error-unable-to-create-lock-file-bad-file-descriptor-9-while-running

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

日本語諸方言コーパスをDB化して遊ぶ (9) Heroku でデプロイする

連載記事です。前回までに機能はだいたい作ったので、今回は本番環境 Heroku でビルドしていきます。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。

以下の記事を大いに参考したうえで、今回のプロジェクトに特化していきます。

git 管理

git 管理して heroku と連携させます。git のリモート URL に heroku を追加します。

cmd
git init
git add .
git commit -a -m "first commit"
heroku git:remote -a my-app-name 

buildpack の設定

今回は PHP Laravel のプロジェクトですが、Node.js と Python も使うのでこいつらも buildpack に含めておきます。Heroku のプロジェクトページからポチポチ追加してもいいでしょう。

cmd
heroku buildpacks:set heroku/php -a my-app-name
heroku buildpacks:add heroku/python -a my-app-name
heroku buildpacks:add heroku/nodejs -a my-app-name

Procfile の作成

Procfile はウェブサイトを起動するために実行するコマンドを heroku に伝えるためのファイルです。ルートに拡張子なしの Procfile というファイルを作成して、以下のように記述しておきます。無難に Apache を使えばよいでしょう。

Procfile
web: vendor/bin/heroku-php-apache2 public/

Python 用 requirement.txt の作成

使用する python スクリプトが xlrd などの非標準モジュールを利用する場合は、ルートに python 用の requirement.txt を用意しておくと、ビルド時に勝手にインストールしてくれます。今回は pip freeze > requirements.txt で現在インストールされているモジュールをすべて書き出して、そこから不要なものを削除して作成しました。

使用する Python のバージョンを指定したい場合は runtime.txt を作成すればよいようです。

環境変数の設定

ローカルの .env.gitignore で push 対象から外してあるはずですので、DB 関連の環境変数は heroku に手動で追加していきます。接続には時間がかかるのでワンライナーで終わらせましょう(バッククォートは無改行を示す)。

cmd
heroku config:set DB_CONNECTION=pgsql `
DB_HOST=*** `
DB_PORT=*** `
DB_DATABASE=*** `
DB_USERNAME=*** `
DB_PASSWORD=*** `
DATABASE_URL=postgres://***

ついでに以下の要素も設定しておきます。APP_KEYphp artisan key:generate で発行するものですが、たぶん composerにより既に設定されているので .env からのコピペでよいでしょう。

cmd
heroku config:set APP_NAME=my-app-name `
APP_KEY=***  ` 
APP_URL=https://my-app-name.herokuapp.com  

composer.json の変更

基本的に heroku run コマンドによる実行ではルート権限は行使できませんが、composer.json 内の scripts->compile にコマンドを書いておくとビルド時にルート権限で実行してくれます。ここでは、python スクリプトに実行権限を与え、/storage フォルダ以下に読み書き権限を与え、シンボリックリンクを張ります1

composer.json
{
    "scripts": {
        "compile": [
            "chmod +x app/Python/textgrid_to_excel.py",
            "chmod +x app/Python/excel_to_textgrid.py",
            "chmod --recursive 775 storage/app",
            "ln --symbolic --relative storage/app/public public/storage"
        ]
    }
}

.htaccess の変更

シンボリックリンクを有効にするために .htaccess の Option に以下を追記します。Option +somesetting で機能を有効化、Option -somesetting で無効化する書式です。

public/.htaccess
Option +FollowSymlinks

コンパイルのタイミング

Laravel プロジェクトは npm run devnpm run production のようなコマンドでコンパイルされますが、既定ではコンパイルされた /public/js/public/css は git の push 対象から外れていると思うので、開発環境でコンパイルしたものをそのまま使う場合は .gitignore から外しておきましょう。うっかりするとどこにもコンパイル済みファイルが存在しない状態が発生しエラー2の原因になります。

.gitignore
- /public/js
- /public/css

デプロイ

設定がすべて終わったらデプロイします。以下のコマンドを実行するだけで勝手にビルドしてくれます。

cmd
git push heroku master

おわりに

Docker なんかを利用していたら、本記事で詰まった点はある程度素通りできたのではないかと思いますが、本記事の扱う領域は筆者自身勉強し始めたばかりですので、よくわかりません(近いうちに勉強します)。

また、本記事も含めて本連載の内容は試行錯誤してたどり着いたものですので、無駄な工程や書き洩らしている工程、不適切な説明がたくさんあると思います。指摘していただけると喜びます。


  1. シンボリックリンクは heroku run php artisan storage:link でも貼れると思いますが、試していないので分かりません。 

  2. 404 や 500 ではなく、ファイルの参照がおかしくなって Syntax error : unexpected token '<' みたいなのが出る。 

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

日本語諸方言コーパスをDB化して遊ぶ (8) ファイル形式変換機能をつける

連載記事です。前回までに談話・話者ごとの発話総覧を作ったので、今回は最後の機能「サイト上での Excel と TextGrid の相互変換」を実装します。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。

先述のとおり、今回は既に TextGrid と Excel を変換する Python スクリプトが存在しているので(内容には詳しく触れません)、これを Laravel アプリに組み込んでサーバー上で実行することを目指します。

事前準備

今回はサーバー上のストレージにファイルを保存し、それを変換し、ダウンロードする仕組みを作るので、最初にそのあたりの設定をしておきます。アップロードしたファイルは storage フォルダに保存されますが、一般公開されるのは public フォルダなので、慣例に従い public/storage から storage/app/public へシンボリックリンクを張ります。下記の artisan コマンドで勝手に貼ってくれます1

cmd
php artisan storage:link

画面遷移図

画面遷移図を再掲します。1ページですけど。

func_3.png

コンポーネントのルーティング

コンポーネントがひとつしかないので、特に解説することはありません。

resources/js/app.js
+ import ConvertComponent from "./components/ConvertComponent";

+        {
+            path: "/convert",
+            name: "convert",
+            component: ConvertComponent
+        }

コンポーネントの作成

1画面ではありますが、たくさん機能をつけるので前回までよりは複雑です。

resouces/js/components/ConvertComponent.vue
<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-ifv-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 に直接リンクを貼ります。

downloadリンク
<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 に書いておきます。

routes/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);
}

なお、ここで使用したスクリプトは入力ファイルと同じディレクトリに出力ファイルを保存する設定になっています。

完成図

こんな感じになっているはずです。

/convert
convert.png

改善点

既に言及したエラーハンドリングなどはもとより、セキュリティ上は exec が大きな問題です。一般にユーザーが改竄できるデータを PHP の exec 関数にそのままぶち込むのは大変危険ですので、適切にエスケープする必要があります。今回はいちおう Laravel のパスヘルパを通しているので大丈夫じゃないかな、と思いますが、ヘルパの正確な挙動を把握していない限りは万全を期したほうがよいでしょう。

次回

Heroku に上げていきます(最終回)。


  1. 後述しますが、ローカルで張ったシンボリックリンクが Heroku 上で勝手に張られることはないので、必要な命令を composer.json に書き込んでおいて、ビルド時にシンボリックリンクが張られるようにしておく必要があります。 

  2. ほんとうはこんなフロントエンドのなんちゃって検証ではなく、サーバ側でちゃんと入力ファイルを検証しないとダメ。 

  3. パス解決に失敗したり、403エラーが出たり、POSTレスポンスにファイル内容は積まれてくるけどダウンロードまで行けなかったりしました。 

  4. Heroku では Dyno という、Amazon EC2 の巨大インスタンス上で動作する軽量 Linux コンテナが使われています。  

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

日本語諸方言コーパスをDB化して遊ぶ (7) 話者ごとの発話総覧を作る

連載記事です。前回、談話ごとの発話総覧を作ったので、今回は話者ごとの発話総覧を作ります。やることはあまり変わりません。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。

画面遷移図

画面遷移図を再掲します。前回よりずっとシンプルなつくりですが、発話単位で分割されているテキストを結合して、文単位に切りなおす操作を挟みます。

func_2.png

コンポーネントのルーティング

画面遷移図のとおりにルーティングします。今回作りこむ2画面を登録しましょう。

resources/js/app.js
+ import SpeakerIndexComponent from "./components/SpeakerIndexComponent";
+ import SpeakerShowComponent from "./components/SpeakerShowComponent";

+         {
+             path: "/speaker",
+             name: "speaker.index",
+             component: SpeakerIndexComponent,
+             props: true
+         },
+         {
+             path: "/speaker/:speakerid",
+             name: "speaker.show",
+             component: SpeakerShowComponent,
+             props: true
+         },

各コンポーネントの作成

話者一覧

特筆すべき箇所はありません。全話者の情報を取得して(getSpeakers 関数)、「話者ID」「話者生年」「話者性別」を表示するだけです。今回はページ幅に余裕があるので[閲覧]ボタンを別途用意しても大丈夫でしょう。

resources/js/components/SpeakerIndexComponent.vue
<template>
<table class="table table-sm table-hover">
    <thead class="thead-dark">
            <tr>
                <th>話者 ID</th>
                <th>話者生年</th>
                <th>話者性別</th>
                <th></th>
            </tr>
        </thead>
        <tbody>
            <tr v-for="s in speakers" v-bind:key="s.speakerid">
                <td>{{ s.speakerid }}</td>
                <td>{{ s.speakerbirthyear }}</td>
                <td>{{ s.speakersex }}</td>
                <td>
                    <div class="text-right">
                        <router-link 
                            v-bind:to="{ name: 'speaker.show', params: { speakerid: s.speakerid } }"
                        >
                            <button class="btn btn-success btn-sm text-nowrap">
                                閲覧
                            </button>
                        </router-link>
                    </div>
                </td>
            </tr>
        </tbody>
    </table>
</template>

<script>
export default {
    data: function() {
        return {
            speakers: []
        };
    },
    methods: {
        getSpeakers() {
            spinner.style.opacity = 1;
            axios.get("/api/speaker").then(res => {
                this.speakers = res.data;
                spinner.style.opacity = 0;
            });
        }
    },
    mounted() {
        this.getSpeakers();
    }
};
</script>

話者の発話総覧

話者情報を表示する部分と発話総覧の部分に分かれています。処理もそれぞれに用意します。

前者では、パスで話者IDを渡して(props の部分)、データベースから当該話者の情報だけ取ってきて表示します(getSpeakerInfo 関数)。

後者では、データベースから発話の全レコードを取得してきて、それをフロントエンドで加工して文単位に切りなおしています(getUtterances 関数)。この処理はサーバでやってもいいかもしれません。

resources/js/components/SpeakerShowComponent.vue
<template>
    <div>
        <table class="table table-sm table-hover">
            <tbody>
                <tr class="table-success">
                    <td nowrap>話者ID</td>
                    <td>{{ speakerInfo.speakerid }}</td>
                </tr>
                <tr class="table-success">
                    <td nowrap>話者生年</td>
                    <td>{{ speakerInfo.speakerbirthyear }}</td>
                </tr>
                <tr class="table-success">
                    <td nowrap>話者性別</td>
                    <td>{{ speakerInfo.speakersex }}</td>
                </tr>
            </tbody>
        </table>
        <div class="pt-3">
            <table class="table table-sm table-striped table-hover">
                <thead>
                    <tr class="thead-dark">
                        <th><div class="text-center">発話</div></th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="sentence in sentences" v-bind:key="sentence.id">
                        <td>
                            <div class="px-4">{{ sentence.d }}</div>
                            <div class="px-4">{{ sentence.s }}</div>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>
    </div>
</template>

<script>
export default {
    props: {
        speakerid: String
    },
    data: () => {
        return {
            sentences: [],
            speakerInfo: {}
        };
    },
    methods: {
        getUtterances() {
            axios
                .get("/api/speaker/" + this.speakerid + "/utterances")
                .then(res => {
                    // 取得した発話をいったん全て結合する
                    let dTextStr = "";
                    let sTextStr = "";
                    for (let i = 0, i_len = res.data.length; i < i_len; i++) {
                        // 文節区切りをつぶさないよう適宜スペースを入れる
                        dTextStr += " " + res.data[i].dialecttext;
                        sTextStr += " " + res.data[i].standardtext;
                    }
                    // 句点で切って this.sentences に入れていく
                    const dSentences = dTextStr.split("");
                    const sSentences = sTextStr.split("");
                    for (let i = 0, i_len = dSentences.length; i < i_len; i++) {
                        // sentences は id, d(ialect), s(tandard) の連想配列にする
                        if (dSentences[i] && sSentences[i]) {
                            this.sentences.push({
                                id: i,
                                d: dSentences[i].trim(),
                                s: sSentences[i].trim()
                            });
                        }
                    }
                });
        },
        getSpeakerInfo() {
            axios.get("/api/speaker/" + this.speakerid).then(res => {
                // correction で返ってくるので [0] で取得する
                this.speakerInfo = {
                    speakerid: res.data[0]["speakerid"],
                    speakerbirthyear: res.data[0]["speakerbirthyear"],
                    speakersex: res.data[0]["speakersex"]
                };
            });
        }
    },
    mounted() {
        this.getUtterances();
        this.getSpeakerInfo();
    }
};
</script>

コントローラへのルーティング

使用するコントローラはひとつ(SpeakerController)でよさそうなので、API のルーティングも悩むことはありません。関数の名前も適当に考えます。

routes/api.php
Route::get('/speaker', 'SpeakerController@index');
Route::get('/speaker/{speakerid}', 'SpeakerController@show');
Route::get('/speaker/{speakerid}/utterances', 'SpeakerController@getUtterances');

コントローラの作成

先ほど使うことにした3つの関数を実装していきます。前回と同様に、Controller で ->first() するのではなく、->get() してコレクションを返してフロントエンドで絞る感じにしています。

app/Http/Controllers/SpeakerController.php
<?php
namespace App\Http\Controllers;
use App\Models\Speaker;
use Illuminate\Support\Facades\DB;

class SpeakerController extends Controller{
    // 全レコード取得
    public function index(){
        $md = new Speaker();
        $data = $md->getData();
        return $data;
    }

    // 指定した話者 ID に一致する話者レコードを返す
    public function show(String $speakerid){
        $data = DB::table('speaker')
        ->select('speaker.*')
        ->where('speaker.speakerid', '=', $speakerid)
        ->get();
        return $data;
    }

    // 指定した話者 ID をもつ発話をすべて返す
    public function getAllUtterances(String $speakerid){
        $data = DB::table('utterance')
        ->select('utterance.*')
        ->where('utterance.speakerid', '=', $speakerid)
        ->get();
        return $data;
    }

}

完成形

うまくいけばこんな具合に表示されるはずです。

/speaker
speakerindex.png

/speaker/02_e_099::C
speakershow.png

次回

第3の機能「ファイルの変換」を作りこんでいきます。

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

日本語諸方言コーパスをDB化して遊ぶ (6) 談話ごとの発話総覧を作る

連載記事です。前回までにルーティングとモデルの準備まで行なったので、第6回は談話ごとの発話総覧を作ります。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。

画面遷移図

画面遷移図を再掲します。

func_1.png

コンポーネントへのルーティング

画面遷移図のとおりにルーティングします。まずはパスとコンポーネントを紐づけてガワを作ります。props: true にするとアドレスに含まれる変数を使用できます。

resouces/js/app.js
+ 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・収録地点・収録場所・収録年月日・話題・種別・データ名」をとってきてリスト表示するようにします。また、談話ごとに「発話一覧」ページへのリンクを貼ります。

resouces/js/components/DiscourseIndexComponent.vue
<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 内に指定しておきます。

resouces/js/components/DiscourseShowComponent.vue
<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 テーブル自体にどんな属性があるかはとりあえず考えず、欲しい情報を欲しいだけ記述しておきましょう。

resouces/js/components/UtteranceShowComponent.vue
<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 として送信するようにしています。

resouces/js/components/UtteranceShowComponent.vue
<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 アクセスする)。

routes/api.php
<?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 関数はセオリー通りで特に説明することはありません。

app/Http/Controllers/DiscourseController.php
<?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() などとやってもうまくロールバックしません。そのためトランザクションは作成せずそのまま処理しています(ダメでは?)。

app/Http/Controllers/DiscourseController.php
<?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
一部日付データがバグってるのはご愛敬。
discourseindex.png

/discourse/01_b_099
discourseshow.png

改善点

本連載では最低限必要な部分だけに絞って解説しているので、実際には説明文を書いたり、ロード中のスピナーを仕込んだり、セキュリティへの配慮をもうちょっと頑張ったりしています。たとえば上のコードでは次のような点が危険です。

  • パスに含まれる文字列をエスケープをせずに使っている
  • try - catch が実質無意味

そこらへんは PHP や JavaScript の一般的な注意点と同様なので、この記事では解説しません。

あと書き終わった時点で「更新処理後のリダイレクト実装してない!」と気づきましたが、気力が尽きたので今は放っておきます。

次回

第2の機能「話者ごとの発話総覧」を作りこんでいきます。

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

日本語諸方言コーパスをDB化して遊ぶ (5) データベースの移行とモデルの作成

連載記事です。これからは、前回ガワだけ作成した Laravel アプリの中身を作りこんでいきます。第5回は PostgreSQL への移行、モデルの作成を行ないます。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。

PostgreSQL の設定

Heroku の準備

第2回は開発用に SQLite でデータベースを作成していましたが、本番では PostgreSQL に移行します。本番環境のビルド直前に移行なんてしていると、思いもよらない面倒な事態が発生して工程をさかのぼりかねないので(1敗)、本番環境で使う技術を決めたら、下見がてら先に構築しておきます。

さて Heroku PostgreSQL の無料枠を使用したいので、この段階で Heroku に登録してウェブアプリ用のプロジェクトを作成しておきましょう。同時に Heroku CLI もインストールしておきます。

heroku login
heroku create my-app-name

git 管理やビルドの詳細設定については今後解説しますが、この段階でプロジェクトは git 管理しておくとよいでしょう。

DB の作成・接続

作成が済んだら Heroku PostgreSQL の無料枠(Hobby-Dev Free プラン)の準備をしましょう。Addon として Heroku PostgreSQL を追加します。プロジェクトページから数クリックで作成できます。Settings ページにデータベースの Credentials 情報があるので、今後はそれを利用してデータベースに接続することになります。とりあえず開発環境から1テスト接続するために、環境変数を .env に書きこんでおきましょう。

.env
DB_CONNECTION=pgsql
DB_HOST=*****
DB_PORT=*****
DB_DATABASE=*****
DB_USERNAME=*****
DB_PASSWORD=*****
DATABASE_URL=postgres://*****

CLI を使いたい場合は以下の参考リンクをご覧ください。

また、PHP の設定ファイル php.ini 内で以下の行が ; でコメントアウトされていたら、これを外します。こうすることで PostgreSQL 用のドライバが有効化されます。

php.ini
extension=pdo_pgsql

これで接続準備ができました。以下のようにして DB に接続できます。

heroku pg:psql --app my-app-name

データ移行

データの移行は、SQLite のダンプデータを出力して、それを PostgreSQL DB に流し込んでもいいのですが、Heroku CLI 経由だとめっちゃ時間がかかるのと、両 SQL 間の微妙な文法差に対応したりするのが面倒くさいです(1敗)。

cmd
// export as dump
sqlite3 my-db-name .dump > my-db-dump.sql

// import dump
heroku pg:psql --app my-app-name < my-db-dump.sql 

そこで、今回はダンプをそのまま利用せずに段階を踏んで移行することにしました。具体的には、PostgreSQL DB に空のテーブルを定義して、そこに SQLite DB を CSV 出力したものを読み込んでいきます。

まずテーブルを作成していきます。第2回で使用した CREATE TABLE 文がだいたい流用できますが2、いくつかマイナーチェンジが必要です。

  • INTEGER PRIMARY KEY AUTOINCREMENT を PostgreSQL 式表記の SERIAL PRIMARY KEY に変更
  • テーブル名に英大文字を使うと PostgreSQL は少し面倒なので、命名は小文字に統一
  • xmin, xmax が PostgreSQL の予約語なので tmin, tmax に変更
  • location もよくないので place に変更

また、PostgreSQL は SQLite よりいろいろな意味で厳密なので、SQLite では発覚しなかった定義ミスがこの段階で発覚したりします。最終的に次のような命令を入れました。

cojads_create_table.sql
CREATE TABLE IF NOT EXISTS prefecture(
  prefecturenum INTEGER PRIMARY KEY, 
  prefecturename TEXT
);
CREATE TABLE IF NOT EXISTS place(
  prefecturenum INTEGER, 
  placeid TEXT,
  placename TEXT, 
  PRIMARY KEY (prefecturenum, placeid),
  FOREIGN KEY (prefecturenum) REFERENCES prefecture(prefecturenum)
);
CREATE TABLE IF NOT EXISTS speaker(
  speakerid TEXT PRIMARY KEY, 
  speakersex TEXT, 
  speakerbirthyear TEXT
);
CREATE TABLE IF NOT EXISTS discourse(
  discourseid TEXT PRIMARY KEY, 
  prefecturenum INTEGER NOT NULL, 
  placeid TEXT NOT NULL, 
  fileid TEXT NOT NULL, 
  reference TEXT, 
  recorddate TEXT,
  recordplace TEXT, 
  recorder TEXT, 
  editor TEXT, 
  topic TEXT,
  genre TEXT,
  FOREIGN KEY (prefecturenum, placeid) REFERENCES place(prefecturenum, placeid)
);
CREATE TABLE IF NOT EXISTS utterance(
  discourseid TEXT, 
  utteranceid INTEGER, 
  speakerid TEXT,
  tmin REAL,
  tmax REAL,
  dialecttext TEXT NOT NULL,
  standardtext TEXT,
  PRIMARY KEY (discourseid, utteranceid),
  FOREIGN KEY (discourseid) REFERENCES discourse(discourseid),
  FOREIGN KEY (speakerid) REFERENCES speaker(speakerid)
);
CREATE TABLE IF NOT EXISTS changelog(
  updateid SERIAL PRIMARY KEY,
  discourseid TEXT NOT NULL, 
  utteranceid INTEGER NOT NULL, 
  olddialecttext TEXT, 
  oldstandardtext TEXT, 
  newdialecttext TEXT, 
  newstandardtext TEXT, 
  updatedtime TEXT,
  FOREIGN KEY (discourseid, utteranceid) REFERENCES utterance(discourseid, utteranceid)
);

さくっと実行します。

cmd
heroku pg:psql --app cojads-tools < cojads_create_table.sql

次にテーブルにレコードを入れていきます。まずは SQLite からテーブルごとに CSV 出力していきます。ヘッダー付きで出力しましょう。

cmd
sqlite> .headers on
sqlite> .mode csv
sqlite> .output tablename.csv
sqlite> SELECT * FROM tablename;

先ほどの CREATE TABLE 文に合わせてヘッダーの命名を微調整して、文字コードが UTF-8 なら Shift-JIS に変更して、改行が CR/LF なら LF と変更しておきます。また Heroku PostgreSQL の無料枠は 10,000 レコードが上限 なので、50,000 件以上ある utterance.csv はゴリゴリ削っておきましょう。今回は各談話につき ID200 までとしました。

cmd
sqlite> SELECT * FROM utterance WHERE utteranceid < 201;

準備が出来たら、外部キーに依存しないテーブルから順にインポートしていきます。ローカルの CSV ファイルを Heroku PostgreSQL に読ませるにはメタコマンド \copy を使用します。ファイル名は " ではなく ' で囲みます。

cmd
heroku pg:psql --app my-app-name
# \copy prefecture from 'prefecture.csv' with csv header
# \copy place from 'place.csv' with csv header
# \copy speaker from 'speaker.csv' with csv header
# \copy discourse from 'discourse.csv' with csv header
# \copy utterance from 'utterance.csv' with csv header
# \copy changelog from 'changeLog.csv' with csv header

そうしたらマイグレーションしておきます(必要?)。

cmd
php artisan migrate

Model の作成

さて、それでは Laravel の開発に戻ります。今回データベース関係の機能はほとんど Controller に持たせるので、Model は第3回でやったように適当に最低限の構造だけ持たせます。たとえば、discourse テーブルに対して以下のようなモデルを作ります。

App/Models/Discourse.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class Discourse extends Model{
    protected $table = 'discourse';
    protected $guarded = array('discourseid');
    public $timestamps = false;

    public function getData(){
        $data = DB::table($this->table)->get();
        return $data;
    }
}

次回

第1の機能「談話ごとの発話総覧」を作りこんでいきます。


  1. .env 自体は git push 対象とはせず、あとで別途 heroku に環境変数を直接設定することになります。 

  2. もっと言うと SQLite ダンプデータの CREATE TABLE 文を流用すればよい。 

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

日本語諸方言コーパスをDB化して遊ぶ (4) サービスの全体像を決める

連載記事です。第4回は前回お試しで作成した Laravel アプリを いったん反故にして 、サービスの全体像を決めて、ルーティングを作るところまで。完全に自分用の作業メモで、説明もいろいろ足りていないと思いますが、ご容赦ください。

実現したい機能

COJADS は現在、コーパス検索アプリケーション「中納言」にてモニター版が利用可能ですので、これとは機能的に差別化したいところです。

「談話/話者」ごとの発話総覧

「中納言」版では検索機能をメインに据えており、テキストデータを「談話」や「話者」といったまとまりでは提供していません。また、前述のとおり COJADS の公式サイトで提供されている生データは「談話」を軸に全結合してあり、取り回しが不便です。したがって、これをエンティティごとに分離して、サイト上で閲覧できるようにする、たとえば「談話」だけでなく「話者」ごとに発話を総覧できる機能を提供することには、一定の価値があると考えられます。

「発話」の編集

わざわざ SQL を書くことなく、サイト上からデータベースを操作し、発話を編集する機能をつけます。ログもとります1。そこまで必要性は感じられませんが、データベースの勉強がてら実装したいと思います。

Excel と TextGrid の相互変換

COJADS では音声・方言テキスト・標準語テキストが発話を単位に紐づけられていますが、公式サイトでは CSV 形式の生データのみが配布されており、音声データや TextGrid 形式(音声分析ソフト Praat 専用形式)のテキストデータは配布されていません。音声と直接紐づいているのは TextGrid ファイルであり、CSV と音声ファイルがあったところでうまく対応を観察することができません。

また TextGrid がいずれ配布されたとしても、CSV データを各自で編集してしまうと対応が崩れてしまい、音声分析が困難になります。そこで Excel ファイルと TextGrid ファイルを相互変換する機能をつけたいと思います。

既に手元に Excel と TextGrid を相互変換する Python スクリプトがありましたので、これを最大限に活かすべく、

  • ファイルをアップロードして
  • サーバー上で Python スクリプトにかけて変換して
  • それをダウンロードする

という仕組みにします。

使用技術

AWS を利用するほどでもないので、今回は Heroku のフリープランを利用して完全無料で上記の機能を実装します。ウェブフレームワークとしては前述のとおり PHP Laravel を使用します。また Heroku は本番環境で SQLite が使えないので2、データベースとしては Heroku PostgreSQL のフリープランを利用します。

Heroku は非常に簡便に利用開始できるサービスですが、その分ビルド後に root 権限が使えなかったり、特にフリープランでは制限されている部分も多く、初心者には難しい点がいくつかありましたので、そこらへんは第9回に説明を試みます。

また、せっかくなので SPA (Single Page Application) 化します。SPA を実現するフレームワークはいくつかありますが、今回は学習コストの低そうな Vue Router を採用します。下記のチュートリアルが大いに参考になりました。

画面遷移図

必要となるページを数え上げて、簡単な画面遷移図を作ります。Qiita で表示しても不快にならないよう、いくつかに分けて描いてみます。

エレガントではないですが、アイコニックで分かりやすく作画コストも低いので draw.io を使用しました。

「談話」ごとの発話総覧と編集機能は以下のようにしましょう。「談話」テーブルには非常に多くの情報が含まれている(ファイル記号、データ名、収録年月日、収録場所、収録担当者、編集担当者、話題、談話ジャンル)ため、これをすべて一画面に表示すると邪魔になります。そこで「談話一覧」ページには、データの性質に影響を与えない要素(収録担当者、編集担当者など)は表示しないようにして、「発話詳細」ページでだけ確認できるようにします。また、「発話編集」ページで更新処理を行なったら「発話詳細」ページに遷移させます。

func_1.png

「話者」ごとの発話総覧は、単純に発話をまとめるだけではつまらないので、簡単な文字列処理を噛ませて、発話ごとではなく文ごとに提示するようにしましょう。

func_2.png

ファイル変換画面は、とりあえずアップロードして、変換して、ダウンロードして、削除できればよいので、素朴なのを用意しておきます。

func_3.png

プロジェクトの準備

では作成していきましょう。まずは Laravel プロジェクトを作っていきます。やることは基本的に上記参考サイトと同じで、適当なフォルダで composer にプロジェクトを作ってもらい、必要なパッケージ等を Laravel (php artisan) や npm の助けを借りて導入していきます。

composer create-project laravel/laravel --prefer-dist cojads
composer require laravel/ui
php artisan ui vue
npm install
npm install --save vue-router

これからプロジェクトの内実を作っていきますが、ガワ(ルーティングやテンプレート)から作り始めて、内実(コンポーネントやコントローラ)はあとで作りこむ、という手順を貫きます。

SPA のテンプレート作成

今回は SPA を作るので、ガワ(テンプレート)として唯一のページ app.blade.php を作成します。Laravel プロジェクトではウェブサイトとして通常アクセスした際のルーティングが web.php で規定されるので、まずここを編集して任意のパスを app.blade.php にルーティングします。ただし今回のサービスはあとで /storage に直接アクセスする必要が出てくるので、/storage 以下にだけは特別なルーティングをかけないようにしておきます。

routes/web.php
<?php
use Illuminate\Support\Facades\Route;
Route::get('/{any}', function() {
    return view('app');
})->where('any', '^(?!storage).*');

そうしたら SPA のガワ app.blade.php を作ります。Vue Router の処理で <header-component> 位置にヘッダーが、<footer-component> 位置にフッターが、<router-view> 位置に各ページの内容が読み込まれるようになっています。

bootstrap で固定ヘッダーを作る関係で、<rooter-view> の上下に適当に余白を設けています(汚い)。

resources/views/app.blade.php
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <base href="/" />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="robots" content="noindex">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ config('app.name', 'COJADS App') }}</title>
    <link href="{{ mix('/css/app.css') }}" rel="stylesheet">
</head>
<body>

<div id="app">
    <header-component></header-component>
    <div class="px-5 py-5"><div class="py-3">
        <router-view></router-view>
    </div></div>
    <footer-component></footer-component>
</div>

<script src="{{ mix('/js/app.js') }}" defer></script>

</body>
</html>

コンポーネントのルーティング

続いてテンプレートに挿入されるコンポーネントのルーティングを行ないます。コンポーネントの登録とそのルーティングは app.js で管理されているので、ここを編集します。以下ではヘッダー・フッター・表紙ページ・談話一覧ページの要素を読み込んで適切なルーティングを行なっています。

resources/js/app.js
import VueRouter from "vue-router";

// register components
import HeaderComponent from "./components/HeaderComponent";
import FooterComponent from "./components/FooterComponent";
import HomeComponent from "./components/HomeComponent";
import DiscourseIndexComponent from "./components/DiscourseIndexComponent";

require("./bootstrap");
window.Vue = require("vue");
Vue.use(VueRouter);

// routing
const router = new VueRouter({
    mode: "history",
    routes: [
        {
            // ルートにアクセスすると HomeComponent をロードする
            path: "/",
            name: "home",
            component: HomeComponent
        },
        {
            // /discourse にアクセスすると DiscourseIndexComponent をロードする
            path: "/discourse",
            name: "discourse.index",
            component: DiscourseIndexComponent
        }
    ]
});

// ヘッダー・フッター用のカスタムエレメントを定義して、コンポーネントを読み込む
Vue.component("header-component", HeaderComponent);
Vue.component("footer-component", FooterComponent);

const app = new Vue({
    el: "#app",
    router
});

各コンポーネントの作成

ルーティングの構想ができたらページの内容を作っていきます。先ほど app.js で使用することにした4つのコンポーネント(.vueファイル)を /resources/js/components 下に作成します(discourse は次回)。

以下のファイルを作成する
+ resources/js/components/HeaderComponent.vue
+ resources/js/components/FooterComponent.vue
+ resources/js/components/HomeComponent.vue
+ resources/js/components/DiscourseIndexComponent.vue

ヘッダー

bootstrap でページ上部に固定するタイプのヘッダーを作ります。先取りになりますが、ヘッダーには <router-link> で各コンポーネントへのリンクを張っておきましょう。とはいえ、まだリンク先を用意していないので、この段階でコンパイルするなら適当にコメントアウトする必要があります。

resources/js/components/HeaderComponent.vue
<template>
    <header class="navbar fixed-top navbar-dark bg-dark">
        <span class="navbar-brand mb-0 h1">COJADS APP</span>
        <div>
            <span>
                <router-link v-bind:to="{ name: 'home' }">
                    <button class="btn btn-success btn-sm">Top Page</button>
                </router-link>
                <router-link v-bind:to="{ name: 'discourse.index' }">
                    <button class="btn btn-success btn-sm">Discourse List</button>
                </router-link>
                <router-link v-bind:to="{ name: 'speaker.index' }">
                    <button class="btn btn-success btn-sm">Speaker List</button>
                </router-link>
                <router-link v-bind:to="{ name: 'convert' }">
                    <button class="btn btn-success btn-sm">Toolkits</button>
                </router-link>
            </span>
        </div>
    </header>
</template>

<script>
export default {};
</script>

フッター

フッターも適当に作ります。

resources/js/components/FooterComponent.vue
<template>
    <footer class="navbar fixed-bottom navbar-dark bg-dark">
        <span class="navbar-brand mb-0 h1">(c) 2020 @a_eau_</span>
    </footer>
</template>

<script>
export default {};
</script>

ホーム画面

特に書くこともないので、最小限だけ作ります。

resources/js/components/HomeComponent.vue
<template>
    <div>
        Welcome to COJADS App!
    </div>
</template>

<script>
export default {};
</script>

コンパイル・ローカルサーバで確認

以上のコードを書き終えたら、一度コンパイルして確認しましょう。以下のコマンドを実行すると、もろもろの必要なものを取り込んでいい感じにコンパイルしてくれます。

cmd
npm run dev あるいは npm run production

コンパイルが済んだら Laravel の以下コマンドでローカルサーバを立ち上げて、localhost:8000 にアクセスしてみましょう。404 や 500 エラーが出ずに想定通りのページが表示されれば成功です。

cmd
php artisan serve

次回

Heroku で SQLite が使えないことを知って PostgreSQL に乗り換える回です。


  1. ログ一覧ページも作ったのですが、解説に真新しいところもないので本連載記事からは割愛しました。 

  2. というか本番環境で SQLite を使えるサービスのほうが少なそう。 

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

Laravelでmailgunを使いメール送信してみる

mailgunアカウント登録

mailgunのAPIを使うにはアカウント登録が必要です。
公式ページにアクセスし「Start Sending」をクリックしてください。

20200807.png

アカウント登録ページが表示されます。
必要情報を入力してください。
「Add payment info now」のチェックを外しておけば、クレジットカード情報は入力しなくて済みます。
※後から有償版に切り替えたい場合は、ダッシュボードの「Account Settings」>「Billing plan」から切り替えることができます。

20200807_2.png

アカウントを登録すると、公式からメールが2通届きます。
「Hi [登録者名], please verify your Mailgun account」のメールの方に記載されているURLをクリックしてメール認証を行います。

20200807_4.png

次に認証コードの登録を行います。
画像をなくしてしまったのですが、メール認証を行うと携帯番号を入力する画面が表示され、電話番号の入力を求められます。番号を入力して認証コードの送信依頼を行うと、番号を入力した端末に認証コードが送信されます。
送信された認証コードを、「Verlflcation Code」に入力して「Validate」を押してください。

20200807_5.png

認証に成功してダッシュボード画面が表示されればアカウント登録は完了です。

20200807_6.png

Laravelの下準備

Guzzle HTTPライブラリを入れる

以下のコマンドを実行します。

composer require guzzlehttp/guzzle

.envファイルに設定を記述

ドメインの確認

ダッシュボード画面から「Sending」>「Domains」をクリックします。
「Name」以下に表示されているものがドメインとなります。

20200807_7.png

API keyの確認

「Name」のドメインをクリックし、APIの「Select」>「cURL」をクリックするとAPI keyを確認することができます。

20200807_8.png

.envに記述

  • MAIL_DRIVER
    • mailgunと記述します
  • MAILGUN_SECRET
    • ドメインを記述します
  • MAILGUN_SECRET
    • API keyを記述します

※API keyやドメインをコピペするときに、最後に半角スペースなどが入らないように気を付けてください。

MAIL_DRIVER=mailgun
MAILGUN_DOMAIN=YOUR_MAILGUN_DOMAIN
MAILGUN_SECRET=YOUR_SECRET_KEY

これでLaravelの設定は完了です。

メール受信設定

ダッシュボード画面から「Sending」>「Domains」をクリックし、「Authorized Recipients」の「Email address」に受信するメールアドレスを入力して「Save Recipient」を押します。

20200814_3.png

入力したメールアドレス宛に「Would you like to receive emails from sankosc on Mailgun?」というタイトルのメールが届くので、「I Agree 」を押します。

20200814.png

Confirm画面が表示されるので「Yes」を押します。

20200814_1.png

Successと表示されれば受信設定は完了です。

20200814_2.png

メール送信

実装

以下のコマンドを実行します。

php artisan make:mail MailgunTest

app/Mail以下に生成されたMailgunTest.phpを編集していきます。

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;

class MailgunTest extends Mailable
{
    use Queueable, SerializesModels;

    /**
     * Create a new message instance.
     *
     * @return void
     */
    public function __construct()
    {
    }

    /**
     * Build the message.
     *
     * @return $this
     */
    public function build()
    {
        return $this->view('mails.mail')
        ->text('mails.mail')
        ->subject('タイトル')
        ->with([
            'text' => '本文',
          ]);
    }
}

buildメソッド内でメールテンプレートとなるviewファイルに、メールのタイトルと本文を記述しています。

次にresources/views以下にmailsフォルダを作成し、mail.blade.phpファイルを追加して以下のように編集します。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
  </head>
  <body>{{$text}}</body>
</html>

送信テスト

tinkerから送信テストをしてみます。

php artisan tinker
Psy Shell v0.10.4 (PHP 7.2.15 — cli) by Justin Hileman
>>> \Mail::to('送信先アドレス')->send(new App\Mail\MailgunTest());
=> null

メールが届きました。

20200817.png

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

既存のLaravel/VueCLIプロジェクトにVuetifyを導入する

環境

  • Laravel 7.25
  • PostgreSQL 12.3
  • vueCLI 4.4.1

やりたいこと

手順

コマンド

  • 公式サイトにはいろいろあるけど、この場合はこの2つでOK
npm install vuetify
npm install sass sass-loader fibers deepmerge -D
  • mdiアイコンを使うとき
npm install @mdi/font -D

resources/js/app.js

  • ★がついた部分が加筆部分
/**
 * First we will load all of this project's JavaScript dependencies which
 * includes Vue and other libraries. It is a great starting point when
 * building robust, powerful web applications using Vue and Laravel.
 */

require('./bootstrap');

window.Vue = require('vue');

/**
 * The following block of code may be used to automatically register your
 * Vue components. It will recursively scan this directory for the Vue
 * components and automatically register them with their "basename".
 *
 * Eg. ./components/ExampleComponent.vue -> <example-component></example-component>
 */

const files = require.context('./', true, /\.vue$/i)
files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default))



import Vue from 'vue';
import Vuetify from 'vuetify'; //★
import 'vuetify/dist/vuetify.min.css'; //★
import '@mdi/font/css/materialdesignicons.css'; //★

Vue.use(Vuetify); //★

/**
 * Next, we will create a fresh Vue application instance and attach it to
 * the page. Then, you may begin adding components to this application
 * or customize the JavaScript scaffolding to fit your unique needs.
 */

// ★
export default new Vuetify({
    icons: {
        iconfont: 'mdi'
    },
    theme: {
        dark: true,
    }
})

const app = new Vue({
    el: '#app',
    vuetify: new Vuetify(), //★
});

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

Laravel7でDB接続エラー / SQLSTATE[HY000] [2002] Connection refused

環境

  • Laravel 7.25
  • PostgreSQL 12.3

つまづいたこと

  • migration実行後、Laravel認証画面でユーザー新規登録をしようとしたらこのエラー
SQLSTATE[HY000] [2002] Connection refused  (SQL: select * from ~~.tables where ~~~)

前提

  • DBは作成してある
  • .envファイルはPostgres用に修正してある
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=sample
DB_USERNAME=sample_app
DB_PASSWORD=
  • migration実行済み
  • モデル作成済み

解決方法

  • サーバーを一度落としてつなぎ直す
  • env.ファイルなど、設定に関係するファイルを変更した場合はサーバーをつなぎ直す必要がある
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Laravel】ユーザー認証機能である「Auth」を日本語化する方法

開発環境

  • PHP 7.4.8
  • Laravel Framework 7.23.0

目的

ユーザー認証機能である「Auth」を日本語化する

Auth日本語化

【参考記事】
https://laraweb.net/tutorial/6949/

1. Laravelの日本語の設定

config/app.php
#下記内容の該当箇所を書き換える
'timezone' => 'Asia/Tokyo',     // タイムゾーン
'locale' => 'ja',               // 第一言語を日本語(ローカライゼーション)
'fallback_locale' => 'en',      // 該当言語が見つからない場合の言語
'faker_locale' => 'ja_JP',

2. 新規ディレクトリ、ファイル作成

①「ja.json」ファイル作成

  • resources/lang内にja.jsonファイル新規作成
  • 作成後、下記内容を記述(コピペOK)
ja.json
{
"Login": "ログイン",
"Register": "新規登録",
"Forgot Your Password?": "パスワードを忘れた場合",
"Reset Password": "パスワード再設定",
"Send Password Reset Link":  "パスワード再設定URLを送信",

"Name": "お名前",
"E-Mail Address": "メールアドレス",
"Password": "パスワード",
"Confirm Password": "パスワード(確認用)",
"Remember Me": "ログイン状態を保存",
"Logout": "ログアウト",

"Hello!": "ご利用ありがとうございます。",
"Reset Password Notification":  "パスワード再設定のお知らせ",
"You are receiving this email because we received a password reset request for your account.": "あなたのアカウントでパスワード再発行のリクエストがありました。",
"This password reset link will expire in :count minutes.": "再設定URLの有効期限は :count 分です。",
"If you did not request a password reset, no further action is required.": "もしパスワード再発行をリクエストしていない場合、操作は不要です。",
"If you’re having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser: [:actionURL](:actionURL)": "\":actionText\"ボタンを押しても何も起きない場合、以下URLをコピーしてWebブラウザに貼り付けてください。\n[:actionURL](:actionURL)",
"Regards": "よろしくお願いいたします"
}

②「ja」ディレクトリ作成

  • resources/lang内にjaディレクトリ作成
  • resources/lang/enからファイルをコピーし、jaディレクトリ内に作成
resources/lang/ja
auth.php
pagination.php
passwords.php
validation.php

③各ファイルの内容を変更

auth.php

auth.php
<?php

return [

    'failed' => 'ログインできません。入力した情報に誤りがないかご確認ください。',
    'throttle' => '何度もログインに失敗したため、:seconds秒後に再度お試しください。',

];

pagination.php

pagination.php
<?php

return [

    'previous' => '« 前へ',
    'next' => '次へ »',

];

passwords.php

passwords.php
<?php

return [

    'reset' => 'パスワードをリセットしました。',
    'sent' => 'パスワードリセット用URLを送信しました。',
    'token' => 'パスワードリセット用トークンが不正です。',
    'user' => 'メールアドレスに一致するユーザーが見つかりません。',

];

validation.php

validation.php
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Validation Language Lines
    |--------------------------------------------------------------------------
    |
    | The following language lines contain the default error messages used by
    | the validator class. Some of these rules have multiple versions such
    | as the size rules. Feel free to tweak each of these messages here.
    |
    */

    'accepted' => ':attributeを承認してください。',
    'active_url' => ':attributeに正しいURLを入力してください。',
    'after' => ':attributeは:dateより先の日付を入力してください。',
    'after_or_equal' => ':attributeは:date以降の日付を入力してください。',
    'alpha' => ':attributeは英字で入力してください。',
    'alpha_dash' => ':attributeは英数字とハイフン、アンダーバーのみで入力してください。',
    'alpha_num' => ':attributeは英数字で入力してください。',
    'array' => ':attributeは配列で入力してください。',
    'before' => ':attributeは:dateより前の日付を入力してください。',
    'before_or_equal' => ':attributeは:date以前の日付を入力してください。',
    'between' => [
        'numeric' => ':attributeは:min〜:maxの範囲で入力してください。',
        'file' => ':attributeは:min〜:max KBのファイルを選択してください。',
        'string' => ':attributeは:min〜:max文字の範囲で入力してください。',
        'array' => ':attributeは:min〜:max個の範囲内にしてください。',
    ],
    'boolean' => ':attributeはtrueかfalseにしてください。',
    'confirmed' => ':attributeが確認用と一致しません。',
    'date' => ':attributeを正しい日付で入力してください。',
    'date_equals' => ':attributeを:dateと一致するよう入力してください。',
    'date_format' => ':attributeの書式を:formatに沿って入力してください。',
    'different' => ':attributeと:otherは違うものを入力してください。',
    'digits' => ':attributeは:digits桁で入力してください。',
    'digits_between' => ':attributeは:min〜:max桁で入力してください。',
    'dimensions' => ':attributeの画像サイズが不正です。',
    'distinct' => ':attributeが重複しています。',
    'email' => ':attributeを正しい形式で入力してください。',
    'ends_with' => ':attributeを:valuesで終わるよう入力してください。',
    'exists' => '選択した値が不正です。',
    'file' => ':attributeはファイルを選択してください。',
    'filled' => ':attributeを入力してください。',
    'gt' => [
        'numeric' => ':attributeは:valueより多く入力してください。',
        'file' => ':attributeは:value KBより大きいファイルを選択してください。',
        'string' => ':attributeは:value文字より多く入力してください。',
        'array' => ':attributeは:value個より多くしてください。',
    ],
    'gte' => [
        'numeric' => ':attributeは:value以上で入力してください。',
        'file' => ':attributeは:value KB以上のファイルを選択してください。',
        'string' => ':attributeは:value文字以上入力してください。',
        'array' => ':attributeは:value個以上にしてください。',
    ],
    'image' => ':attributeは画像にしてください。',
    'in' => ':attributeは不正です。',
    'in_array' => ':attributeは:otherの範囲外です。',
    'integer' => ':attributeは数字で入力してください。',
    'ip' => ':attributeはIPアドレス形式で入力してください。',
    'ipv4' => ':attributeはIPv4形式で入力してください。',
    'ipv6' => ':attributeはIPv6形式で入力してください。',
    'json' => ':attributeはJSON形式で入力してください。',
    'lt' => [
        'numeric' => ':attributeは:valueより少なく入力してください。',
        'file' => ':attributeは:value KBより小さいファイルを選択してください。',
        'string' => ':attributeは:value文字より少なく入力してください。',
        'array' => ':attributeは:value個より少なくしてください。',
    ],
    'lte' => [
        'numeric' => ':attributeは:value以下で入力してください。',
        'file' => ':attributeは:value KB以下のファイルを選択してください。',
        'string' => ':attributeは:value文字以下入力してください。',
        'array' => ':attributeは:value個以下にしてください。',
    ],
    'max' => [
        'numeric' => ':attributeは:max以下で入力してください。',
        'file' => ':attributeは:max KB以下のファイルを選択してください。',
        'string' => ':attributeは:max文字以下入力してください。',
        'array' => ':attributeは:max個以下にしてください。',
    ],
    'mimes' => ':attributeは:values形式で選択してください。',
    'mimetypes' => ':attributeは:values形式で選択してください。',
    'min' => [
        'numeric' => ':attributeは:min以上で入力してください。',
        'file' => ':attributeは:min KB以上のファイルを選択してください。',
        'string' => ':attributeは:min文字以上入力してください。',
        'array' => ':attributeは:min個以上にしてください。',
    ],
    'not_in' => ':attributeは不正です。',
    'not_regex' => ':attributeの書式が不正です。',
    'numeric' => ':attributeは数字で入力してください。',
    'present' => ':attributeは存在する必要があります。',
    'regex' => ':attributeの書式が不正です。',
    'required' => ':attributeを入力してください。',
    'required_if' => ':otherが:valueの時、:attributeを入力してください。',
    'required_unless' => ':otherが:valuesでない時、:attributeを入力してください。',
    'required_with' => ':valuesが存在する時、:attributeを入力してください。',
    'required_with_all' => ':valuesが存在する時、:attributeを入力してください。',
    'required_without' => ':valuesが存在しない時、:attributeを入力してください。',
    'required_without_all' => ':valuesが存在しない時、:attributeを入力してください。',
    'same' => ':attributeと:otherが一致するよう入力してください。',
    'size' => [
        'numeric' => ':attributeは:sizeで入力してください。',
        'file' => ':attributeは:size KBのファイルを選択してください。',
        'string' => ':attributeは:size文字で入力してください。',
        'array' => ':attributeは:size個にしてください。',
    ],
    'starts_with' => ':attributeを:valuesから始まるよう入力してください。',
    'string' => ':attributeは門司で入力してください。',
    'timezone' => ':attributeを正しいタイムゾーンで入力してください。',
    'unique' => ':attributeは既に取得されているため、違うものを入力してください。',
    'uploaded' => ':attributeはアップロードに失敗しました。',
    'url' => ':attributeを正しいURLで入力してください。',
    'uuid' => ':attributeを正しいUUIDで入力してください。',

    /*
    |--------------------------------------------------------------------------
    | Custom Validation Language Lines
    |--------------------------------------------------------------------------
    |
    | Here you may specify custom validation messages for attributes using the
    | convention "attribute.rule" to name the lines. This makes it quick to
    | specify a specific custom language line for a given attribute rule.
    |
    */

    'custom' => [
        'attribute-name' => [
            'rule-name' => 'custom-message',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Custom Validation Attributes
    |--------------------------------------------------------------------------
    |
    | The following language lines are used to swap our attribute placeholder
    | with something more reader friendly such as "E-Mail Address" instead
    | of "email". This simply helps us make our message more expressive.
    |
    */

    'attributes' => [
        'email' => 'メールアドレス',
        'password' => 'パスワード',
    ],

];

日本語のメッセージファイル

「Laravel7.x公式ドキュメント」
https://github.com/laravel-ja/ja-docs-7.x

「Laravel6.xLTSの公式ドキュメント」
https://github.com/laravel-ja/ja-docs-6.x

「Laravel 5.5 日本語メッセージファイル」
https://github.com/minoryorg/laravel-resources-lang-ja

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

tinker 日本語

(inside container) apt install locales
(inside container) localedef -f UTF-8 -i ja_JP ja_JP.utf8
(inside container) export LANG=ja_JP.UTF-8

(inside container) php artisan tinker
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

$ git clone で取得したLaravelアプリのローカルサーバの起動に失敗する

目的

  • 記事名のエラーを解決した話をまとめる

補足情報

  • 本記事は一年以上前に記載した物が下書きボックスに眠っていた内容である。供養の意味も含めて一応記事にする。

問題発生までの経緯

  1. GitHubのリモートリポジトリを$ git cloneコマンドを用いて取得した。
  2. .envファイルを作成、自分の環境にあった内容を記載した。
  3. Laravelアプリ名ディレクトリで下記コマンドを実行してローカル開発環境用のサーバを起動した。

    $ php artisan serve
    

問題

  • コマンド$ php artisan serveを実行後、下記のエラーが出力された。

    php artisan serve
    PHP Warning:  require(/Users/ookawashun/workspace/work/laravel/calculation_drill_app/vendor/autoload.php): failed to open stream: No such file or directory in /Users/ookawashun/workspace/work/laravel/calculation_drill_app/artisan on line 18
    
    Warning: require(/Users/ookawashun/workspace/work/laravel/calculation_drill_app/vendor/autoload.php): failed to open stream: No such file or directory in /Users/ookawashun/workspace/work/laravel/calculation_drill_app/artisan on line 18
    PHP Fatal error:  require(): Failed opening required '/Users/ookawashun/workspace/work/laravel/calculation_drill_app/vendor/autoload.php' (include_path='.:/usr/local/Cellar/php/7.4.3/share/php/pear') in /Users/ookawashun/workspace/work/laravel/calculation_drill_app/artisan on line 18
    
    Fatal error: require(): Failed opening required '/Users/ookawashun/workspace/work/laravel/calculation_drill_app/vendor/autoload.php' (include_path='.:/usr/local/Cellar/php/7.4.3/share/php/pear') in /Users/ookawashun/workspace/work/laravel/calculation_drill_app/artisan on line 18
    

問題解決までの経緯

  1. アプリ名ディレクトリで下記コマンドを実行してcomposerのパッケージインストールを行った。

    $ composer install
    
  2. 再度ローカルサーバを起動するコマンドを実行したら正常にローカルサーバが起動した。

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

実務未経験者が顧客管理アプリを作ってみた 〜環境構築編その2〜

実務未経験者が顧客管理アプリを作ってみた 〜環境構築編その1〜

前回は仮想マシンにDockerをインストールするところまで進めた。(一ヶ月前)
今回はDockerComposeをインストールして実際にコンテナを立ち上げていく。
ちなみにDockerComposeとはymlファイルへの記述を基に、複数のコンテナの定義、実行ができる非常に便利なツールである。

手順

1.DockerComposeをインストールする
2.docker-compose.ymlを作成する
3.Dockerfileを作成する
4.コンテナを起動する

1.DockerComposeをインストールする

仮想マシン内で以下コマンドを実行する。
DockerCompose公式を参考にする。
https://docs.docker.jp/compose/install.html#linux

#インストール
$sudo curl -L https://github.com/docker/compose/releases/download/1.16.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
#実行権限の付与
$sudo chmod +x /usr/local/bin/docker-compose
#確認
docker-compose --version

2.docker-compose.ymlを作成する

一度仮想マシンから出て、docker-compose.ymlを作成する。
qiita-dockerディレクトリ内に作ろう。

%cd qiita-docker
%touch docker-compose.yml

Dockerfileを基にWebコンテナを構築すること、
qiita-dockerディレクトリ内に後ほど作成するLaravelアプリとWebコンテナの/var/www/htmlのデータを共有すること等を記述する。

docker-compose.yml
version: '3'

services:
  web:
    build: ./
    volumes:
      - ./qiita-docker/app:/var/www/html
    ports:
      - "8080:80"
    depends_on:
      - db 
  db:
    image: mysql:5.7
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: qiita
      MYSQL_USER: root
      MYSQL_PASSWORD: root

3.Dockerfileを作成する

続いてqiita-dockerディレクトリ内にDockerfileを作成していく。
ベースとなるDockerイメージを指定、拡張機能の追加など記述する。
今回はphp:7.2-apacheにエディタであるVim、MySQLとの連携に必要なpdo_mysql、Laravelアプリを作成するのに必要なComposer、zip、unzip等をインストール。

FROM php:7.2-apache

COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
RUN apt-get update
RUN apt-get install -y vim
RUN docker-php-ext-install pdo_mysql
RUN apt-get install -y git zip unzip

4.コンテナを起動する

さっそく仮想マシン内のqiita-docker直下でコンテナを立ち上げる。

$docker-compose up -d

ERROR: Couldn't connect to Docker daemon at http+docker://localunixsocket - is it running?

エラーが出ます。
Docker daemonにコネクトできない、と。
Dockerの使用権限はデフォルトではrootのみに与えられているようです。
sudoコマンドを使うもしくは、dockerグループにユーザーを追加しなければいけない。
今回はdockerグループにユーザーを追加する。

#dockerグループが存在しているか確認
$cat /etc/group | grep docker
#存在していなければ作成
$sudo groupadd docker
#現在ログイン中のユーザーをdockerグループに追加する
$sudo gpasswd -a $USER docker

反映させるために一度exitして仮想マシンに入り直す。
もう一度コンテナを立ち上げる。

$docker-compose up -d

指定したIPアドレスのポート番号にアクセスしてみよう。
スクリーンショット 2020-08-17 7.28.08.png
こちらの画面が表示されれば成功です!

次回はLaravelをインストールして表示させるところまで進めます。

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

【初Laravel】未経験がLaravelでカブトムシ繁殖家のためのWebアプリを作ってみた

はじめに

すっかり夏です!夏といえばカブトムシですね!みなさんカブトムシ採ってますか?
家では今、成虫・幼虫・卵あわせて100頭ほどのカブトムシとクワガタ達が暮らしています。

趣味でカブクワブリーダー、本職スマホゲームエンジニア(最近はHTML5が主戦場)、副業でジーズアカデミーのメンターをさせて頂いてます@zprodevです。

ご縁がありメンターさせていただいているジーズアカデミーでは、WebフレームワークとしてLaravelが選ばれており、メンター期間である卒業制作でもLaravelが使われることが多いです。

自分の専門はネイティブアプリやゲームですしLaravelは未経験ですが、Laravel特有の問題で躓く生徒さんもいるため、常々こう思っていました。

Laravel理解するために自分でも何か作ってみねば!
(ついでに、業務で少し触っただけのReactもフルスクラッチで書いてみたい!)

ということでLaravelとReactの勉強がてら、カブトムシ・クワガタ繁殖家のためのWebアプリを7月1日にリリースしました。

お気づきの方もいるかもしれませんが、タイトルは7月上旬にトレンドになっていた@hara_taku_さんの記事のパクリです!

【初アプリ】未経験がFlutterで肉牛繁殖農家のためのアプリを作ってみた - Qiita

ウシとムシという違いはあれど、リリース時期もコンセプトも似ていて勝手に親近感を覚えています。
@hara_taku_さんの記事はサービス設計する上でとても参考になるので、これから自分のサービスを開発したい方は一度読んでみることをオススメします。

本記事では、これからWebアプリを作りたい方向けに「Webアプリちゃんとする」という観点で、お作法やそれを実現するためのツールやライブラリ、独自の工夫なんかをゆるっと紹介します。

以下、虫苦手な人は閲覧注意

まず作ったもの紹介

集めて繋がるカブクワ情報共有サイトBeetlect(ビートレクト)
https://beetlect.com/

主なフレームワーク・ライブラリ

バックエンド
Laravel v7.2.2
Intervention Image v2.5.1

フロントエンド
React v16.13.1
react-router-dom v5.1.3
react-chartjs-2 v2.9.0
Material-UI v4.9.7

機能概要

飼育中のカブクワ情報を登録できる
スマートフォンで撮影した写真と共に、種類やコメント、その他詳細情報を個体情報として登録して電子管理できます。
普通はラベルシールなどを自作して飼育ケースに貼り付けて管理しますが、汚れたり無くしたり結構めんどくさいのです。
スクリーンショット 2020-08-09 10.20.26.png

幼虫は成長過程も記録できる
幼虫の育て方で成虫の大きさが変わるため、与えたエサや管理温度、体重の変化などを記録するのは重要なのです。
スクリーンショット 2020-08-09 10.26.31.pngスクリーンショット 2020-08-09 10.26.40.png

情報はみんなで共有
飼育を始めた頃は「どのくらいの期間で成虫になるのか?」とか「サイズはどのくらいを目指せば良いのか?」とか「飼育温度はどのくらいが良いのか?」とかの情報収集に苦労します。
各自の飼育情報管理を効率化すると共に、新米ブリーダーの助けになる情報を共有し、カブクワ界隈の盛り上げに貢献出来る訳です。
スクリーンショット 2020-08-09 10.51.52.png

【本題】ちゃんとしたいポイント

自分の知る範囲・調べた範囲で、ちゃんとしたWebアプリに必要な要素と、対応するメリットをゆるっと紹介します。

SPA(シングルページアプリケーション)

複数のHTMLファイルをURL遷移で繋ぐのでは無く、1つのHTMLファイルの中でJavaScriptによるHTML要素を書き換えでページを構築する手法。

主なメリット

  • 画面遷移時の無駄な通信や待ち時間を減らせる
  • 画面遷移アニメーション(トランジション)とかいい感じにできる
  • 結果、ネイティブアプリっぽいモダンなWebアプリになる

PWA(プログレッシブウェブアプリケーション)

端末のホーム画面に追加してネイティブアプリのように利用させる仕組み。

主なメリット

  • アクセシビリティの向上
  • フルスクリーンで表示できる
  • ブラウザによってはストレージ永続化効果がある

レスポンシブウェブデザイン

端末の画面サイズに合わせて柔軟にUIレイアウトを変更する手法。

主なメリット

  • PCでもスマートフォンでも最適な表示になる
  • いろいろな端末で利用できるというWebアプリ最大の利点を殺さない

OGP(Open Graph Protocol)

FacebookやTwitterでURLがシェアされた際、そのページの情報をSNS側に知らせて適切なリンク画像やタイトルを表示させるための仕組み。
FacebookとTwitterに対応させておけば、LINEやSlackなど様々なツールでも効果がある。

主なメリット

  • 利用者がシェアしやすい
  • シェアされた側にも内容が分かりやすい
  • 結果、SNSからの流入が期待できる

SEO(Search Engine Optimization)

Googleなどの検索エンジンが理解できるようページ構成を最適化する手法。

主なメリット

  • Googleなどの検索結果に正しく表示されるようになる
  • 結果、ブラウザ検索からの流入が期待できる

AMP(Accelerated Mobile Pages)

Googleが推進する、モバイルページを高速に表示させるための仕組み。

主なメリット

  • GoogleのCDNに事前にキャッシュされ、初回表示速度が高速化される(と思われる)

Beetlectでの対応状況

以下、Beetlectでの対応状況と対応方法や参考リンクになります。

SPA

Reactを使ってJavaScriptでの画面構築しています。
react-router-domを使ってURLでのページルーティングや、Laravel標準のページネーション機能を使って無限スクロールも実装してみました。
url.gif scroll.gif

PWA

iOS13.4未満ではPWAでWebカメラ(WebRTC)が使えないため、iOSは13.4以降のみPWAになるよう実装しました。
AndroidではMaskable Iconにも対応しています。
pwa.gif

↓のFavicon Generatorを使うと、主要OS・ブラウザを考慮したfaviconやアイコン系画像リソースと共にPWA用のmanifestファイルも簡単に生成できます。
https://realfavicongenerator.net/

ただmanifestの設定はアプリに合わせて調整した方が良いので、設定値の参考までに自分のPWAテスト実験用リポジトリを貼っておきます。
https://github.com/zprodev/pwa-test

レスポンシブウェブデザイン

Material-UIのGridを活用して実現しています
responsive.gif
Responsive UI - Material-UI

Material-UIはGoogleの提唱するマテリアルデザインをReactで実現するためのライブラリなので、フラットデザインとかスキューモーフィズムとか他のデザインを採用する場合は適宜ライブラリ選定が必要です。モダンなライブラリならレスポンシブウェブデザインへの対応策は何かしら用意されているんじゃないでしょうか。

OGP

TwitterやFacebookからOGP情報を収集に来るbotはJavaScriptを解釈しない為、サーバー側で制御する必要があります。
Twitterシェアボタンを実装している個体情報ページにアクセスがあった場合のみ、サーバー側でUserAgentをチェックしてOGP収集botかどうか判定し、botであれば動的にOGP系タグを設定したHTMLを返却すようにしました。
スクリーンショット 2020-08-11 2.48.04.png

個体情報ページ以外のURLは、SPAのベースのHTMLファイルに設定した共通情報が表示されます。
スクリーンショット 2020-08-11 2.45.28.png

Twitterの仕様
Twitterの確認用ツール

Facebookの仕様
Facebookの確認用ツール

SEO

無限スクロールで表示データが逐次追加される仕様なので、スクロールしてくれないGoogleのbotでは全てのページをインデックス登録してくれません。
とりあえずサイトマップの動的生成で全個体情報ページのリストを作成し、インデックスさせるようにしています。

site:beetlect.comでインデックス状況確認
サイトマップについて
GoogleがサポートしているSEO絡みのタグ

AMP

個体情報ページで試してみようと思いながら、手をつけられていません :innocent:

UX向上のための対応

Webアプリによって要否は異なりますが、BeetlectでのUX向上のための工夫なども一部紹介します。

WebP対応

サーバーにアップロードされた写真はWebPとJPEGで保存し、対応しているブラウザにはWebPを配信することで利用者の通信量削減や表示速度向上を図っています。
最初はサーバーのストレージ圧迫を懸念してJPEGのみ対応にしようと思っていましたが、SafariもiOS14からWebP対応というニュースを聞きつけ、勢いでWebP対応に踏み切りました。

クライアントからPNGの画像をアップロードし、サーバー側でIntervention Imageを使用してWebPとJPEGに変換しています。
ついでに、サムネイル的に使用する小さい画像も生成し、画面に応じて使い分けています。

画像遅延ロード

最近のChromium系ブラウザはネイティブでlazy loadingに対応しています。Safariでもデフォルトでは無効ですが、実験的な機能として既に搭載されているので設定変更すれば使えます。
Beetlectでは無限スクロールで少しづつ情報を取得することが実現できているので画像遅延ロードの効果は薄いですが、「ネイティブで使えるなら…」ということで実装しています。

Native image lazy-loading for the web

入力値のオートコンプリート

種名なんかは入力パターンが限られるので、過去に登録されている種名が候補として表示されるようにしました。
スクリーンショット 2020-08-14 19.01.27.png スクリーンショット 2020-08-14 19.01.51.png

Material-UIの機能で実現しています。
Autocomplete React component - Material-UI

入力値の即時バリデーション

入力桁数やフォーマットのバリデーションは即時に行い画面に反映するようにしています。
validation.gif valid.gif

チェック自体は正規表現などで泥臭くやってますが、エラーの表示はMaterial-UIの機能を活用しています。
Text Field React component - Material-UI

必須入力値は最小限に

投稿のハードルを下げるため、投稿時の必須情報は写真と種名のみにしました。ガチ勢は任意で詳細情報も入力できます。
require.gif

まとめ

Webアプリ開発を通して、LaravelとReactの思想や使い方をある程度理解できたかなと思います。
何か勉強するときは、自分が必要としているものを作るとか明確な目的を持って取り組むと、モチベーションが続くのでオススメです。

そして、カブクワ系エンジニアの方々は是非 Beetlect 使ってみてください!
非カブクワ系エンジニアの方々は、とりあえずカブクワ採集からはじめましょう!(去年は東京23区内で40匹ほどのカブクワを見つけましたよ)

最後に、うちのお気に入りのクワガタ達を置いておきますね:rainbow:
20200705_171207.jpg

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