tech. tt

Teens Town 技術ブログ

「子どものまち」の入退場管理システムを作った話。

Laravel + Nuxt + Electron + Ionic で大規模システムを構築した話。3日くらい、ひとりで。

プロローグ

いきさつ

3月末に地元武蔵野市で開催させて頂いた「Teens Town むさしの」。

地域コミュニティが薄まっている今、地元に「 まち と 自分 を考えるきっかけ」を届けたい——そんな想いで子どものまちを開催しました!!!簡単に言うとキッザニアのようなイメージです。

そんなイベントを実施するにあたって、初回はやっぱりデータ収集したいよね〜となりまして。入退場&ブース参加の情報が全て把握できるようなシステムを構築しました!!当日使わなかったけど。(この話は末尾に...。)

イメージ

  • 企画の入退場を管理したい!
  • 参加証にQRコード貼って、スマホで読み込みたい!
  • 本部で随時情報が見られたらうれしいなぁ。

こんな感じでゆるく決めた内容を、サクッと作ってみました。

システム構成

f:id:up-tri:20200219140518p:plain

APIサーバー

データを集約するWebサーバーはVPSを利用。一時期LBを用いた冗長構成にしていました。

WebアプリケーションにはPHP/Laravelを採用。慣れていたので速攻で構築できました。

デスクトップアプリ

いわゆる本部用の管理ソフトです。Laravelのbladeを用いてwebシステムでも良かったのですが、スマホアプリ運用という前提があったのでスマホ版と共通なAPIを用いて実装することに。

結果このあたりの記事を参考にElectronにNuxtを載せて作成しました。Nuxtではelement-uiを用いました。デスクトップアプリなのでレスポンシブを考慮しないでいいか...と、レスポンシブ非対応な代わりに高機能な彼を。

スマホアプリ

みんな大好きIonic Framework!!業務でも利用しているので実装において特段困ることはありませんでした。が...本システムを制作した2018年12月から本番の2019年3月までの間にIonicにメジャーアップデートがやってきました。旧版を使い続けようとしたものの、ライブラリ周りも大幅更新されておりだいぶ手こずりました。(これが本番使えなかった理由

実装

1.【APIサーバー】ログイン機能

「アカウント増やしたくないなぁ...LINEにしよう!」ということで、LINEログインAPIを用いました。ただし技術サイトなどでよく書かれているようなLaravel + Socialiteでの実装では、LINEアカウントを持っているひとなら誰でもログインできてしまいます。そこで今回はLINE認証とLaravel側処理の間に1ステップ挟んでいます

https://blog.ef-4.co.jp/laravel%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%ABline%E3%83%AD%E3%82%B0%E3%82%A4%E3%83%B3%E3%82%92%E5%AE%9F%E8%A3%85%E3%81%97%E3%82%88%E3%81%86%EF%BC%81/ 上のリンクのうち、 app/Http/Controllers/Auth/SocialAccountController.php に以下の処理を追加しています。

$authKey = Cookie::get('authKey');
if (!$authKey) {
    Cookie::unqueue('authKey');
    return ['user' => null, "reason" => "forbidden", 'oauthFlg' => $oauthFlg];
}
if (AuthKey::where('authKey', $authKey)->doesntExist()) {
    Cookie::unqueue('authKey');
    return ['user' => null, "reason" => "forbidden", 'oauthFlg' => $oauthFlg];
}

$authKeyColumn = AuthKey::where('authKey', $authKey)->first();
if (strtotime($authKeyColumn->expired) - strtotime("now") < 0) {
    $authKeyColumn->delete();
    Cookie::unqueue('authKey');
    return ['user' => null, "reason" => "expired", 'oauthFlg' => $oauthFlg];
}

$authKeyColumn->delete();
Cookie::unqueue('authKey');

$uid = $provider . '_' . $providerUser->getId();
if (preg_match('/Oauth/', $uid) === 1) {
    $uid = str_replace('Oauth', '', $uid);
}
if (preg_match('/App/', $uid) === 1) {
    $uid = str_replace('App', '', $uid);
}

$user = User::where('userId', $uid)->first();

if (!$user) {
    $user = User::create([
        'userId' => $uid,
        'email' => $providerUser->getEmail(),
        'name' => $providerUser->getName(),
        'imageUrl' => $providerUser->getAvatar(),
    ]);
} else {
    $user->name = $providerUser->getName();
    $user->imageUrl = $providerUser->getAvatar();
    $user->save();
}

$user->accounts()->create([
    'provider_id' => $providerUser->getId(),
    'provider_name' => $provider,
]);

return ['user' => $user, 'oauthFlg' => $oauthFlg];

URLパラメーターauthKeyに登録用のユニークキーを乗せた上でログインを踏むと、そのユーザーがシステムに新規登録される仕組みです。(ユニークキーはLINEログインへリダイレクトする前にCookieに保存されています。)

この登録用ユニークキーは1回使い切りで、管理画面から生成できる仕様です。

2.【APIサーバー】コントローラの実装

上記の認証・API向けOAuth2.0認証を実装した後に実際のAPI開発へ取りかかります。一般に公開する情報は無いため、基本的に全てのエンドポイントに認証が必要になります。

今回は重要な基幹機能として二次元コード(QRコードという商標が一般的です)による入退場管理がありました。まずは二次元コードを生成するロジックからです。

2−1.コード生成ロジック

二次元コード生成には下記のライブラリを用いました。

https://github.com/SimpleSoftwareIO/simple-qrcode

やや古めのライブラリで公式サイトも死んでいます(えっ)が、使い方は単純です。技術ブログにもよく載っています。

use SimpleSoftwareIO\QrCode\Facades\QrCode;
/*  ...  */
$base64 = base64_encode(QRCode::format('png')->size(200)->generate($codeData));

今回は上記のようにbase64でエンコードをかけてからBladeに渡します。

2−2.PDF生成ロジック

今回二次元コードを紙ベースで用意して参加証に貼付するという形式を取ったため、印刷する工程が必要です。印刷するならPDF化したい!ということで、BladeからPDFを生成できるライブラリも導入。

https://github.com/barryvdh/laravel-dompdf

上記ライブラリは未だ開発が続けられています。安心。使い方としては、

use Barryvdh\DomPDF\Facade as PDF;
/*  ...  */
$pdf = PDF::loadView('pdf.sheet', ['hoge' => $anyData]);
return $pdf->stream('hogemoge.pdf');

という感じでPDF専用のBladeファイルを用意してあげます。第二引数に配列で色々渡せます、HTMLを返すいつものviewメソッドと似ていますね。

インスタンスのstreamメソッドを呼ぶことで、そのURLにアクセスしたときに直接PDFを表示させています。(ダウンロードさせたい場合は$pdf->download('hogemoge.pdf');とすると幸せになれます。)

次にresources/views/pdf/sheet.blade.php を編集しますが、ここが少し厄介でした。

やっかいポイント1:一部CSSが適用できない

CSS3に対応していないのでしょうか...便利なプロパティが軒並み使えませんでした。しかもその部分だけスルーされるならまだしも、無効なプロパティを含むとCSSスタイルが全て外れてしまいました。さてはて。

やっかいポイント2:外部参照が使えない

<link src="https://example.com/hoge.css">というような外部リソースを読み込むような記述ができません。今回はフォントファイルとCSSをプロジェクト内に含んでローカルで参照しています。オンラインのWebフォントが読み込めないのが痛かった...。

完成したソースコードはこんなイメージ。

&lt;!DOCTYPE html&gt;
&lt;html lang="ja"&gt;
    &lt;head&gt;
        &lt;meta http-equiv="Content-Type" content="text/html; charset=utf-8" /&gt;
        &lt;link href="{{ url('/theme/css/bootstrap.min.css') }}" rel="stylesheet" type="text/css" /&gt;
        &lt;style type="text/css"&gt;
            @font-face {
                font-family: mplus-1p;
                font-style: normal;
                font-weight: normal;
                src: url('{{ storage_path("fonts/mplus-1p-regular.ttf") }}') format("truetype");
            }

            @font-face {
                font-family: mplus-1p;
                font-style: bold;
                font-weight: bold;
                src: url('{{ storage_path("fonts/mplus-1p-regular.ttf") }}') format("truetype");
            }

            html,
            body {
                background-color: #fff;
            }

            body {
                font-family: mplus-1p !important;
                position: relative;
            }

            .profile {
                position: relative;
                display: inline-block;
                width: 30mm;
                height: 30mm;
                margin: 10px;
                border: 1px solid #000000;
            }

            .qrCell {
                width: 30mm;
                height: 30mm;
            }

            .nameCell {
                position: absolute;
                top: 0;
                left: 0;
                right: 0;
                width: 100%;
            }

            .nameCell__name,
            .nameCell__num {
                font-size: 10px;
                margin: 0;
                padding: 0;
                line-height: 10px;
                text-align: center;
            }

            .nameCell__num {
                margin-top: 21mm;
            }

            .qr {
                display: block;
                width: 30mm;
                height: 30mm;
            }
            .break {
                page-break-after: always;
            }

        &lt;/style&gt;
    &lt;/head&gt;

    &lt;body&gt;
        &lt;header&gt;{{ $datetime }}&lt;/header&gt;
        &lt;div class="paper p-0"&gt;
@for ($i = 0; $i &lt; count($membersData);$i++)
@if ($i%5==0)
            &lt;div&gt;
@endif
            &lt;div class="profile"&gt;
                &lt;div class="qrCell"&gt;
                    &lt;img class="qr" src="data:image/png;base64, {{ $membersData[$i]['qrCode'] }}" /&gt;
                &lt;/div&gt;
                &lt;div class="nameCell"&gt;
                    &lt;h1 class="nameCell__name"&gt;{{ $membersData[$i]["name"] }}&lt;/h1&gt;
                    &lt;p class="nameCell__num"&gt;{{ $membersData[$i]["memberId"] }}&lt;/p&gt;
                &lt;/div&gt;
            &lt;/div&gt;
@if ($i%5==4)
            &lt;/div&gt;
@endif
@if ($i%30==29)
            &lt;div class="break"&gt;&lt;/div&gt;
@endif
@endfor
        &lt;/div&gt;
    &lt;/body&gt;

&lt;/html&gt;

ごり押しも良いところ

2−3.参加者情報インポート機能

参加者の情報をCSVから読み込みたいので、一斉登録のAPIを実装しました。

public function addMembers(Request $request)
{
    $createResults = [];
    foreach ($request-&gt;members as $member) {
        $validator = Validator::make($member, [
            'memberId' =&gt; 'required|integer',
            'age' =&gt; 'required|integer',
            'name' =&gt; 'required|string|max:255',
            'email' =&gt; 'nullable|string|email|max:255',
            'message' =&gt; 'nullable|string',
        ]);

        if ($validator-&gt;fails()) {
            continue;
        } else {
            if (Member::where(['memberId' =&gt; $member["memberId"]])-&gt;exists()) {
                continue;
            }
            $result = Member::create([
                'memberId' =&gt; $member["memberId"],
                'age' =&gt; $member["age"],
                'name' =&gt; $member["name"],
                'email' =&gt; (in_array('member', $member) ? $member["email"] : null),
                'message' =&gt; (($member["message"] === "null" || $member["message"] === null) ? null : $member["message"]),
            ]);
            $createResults[] = $result;
        }
    }
    if (!$createResults) {
        \abort(406, 'Not acceptable.');
    }
    return $createResults;
}

といってもforeachで1要素ずつValidatorに投入しているだけです。

3.デスクットップアプリの実装

3−1.Nuxt.js on Electron

Electron は JavaScript, HTML, CSS といったWeb技術を利用してネイティブアプリケーションを作成するためのフレームワークです。(公式サイトより)

npm i -D electron@latestで導入できるのでNuxt.jsとの相性も良さそうです。先駆者の方の記事を参考に、settimeout関数による遅延だけ変えて実装です。

const http = require('http')
const {
  Nuxt,
  Builder
} = require('nuxt')
let config = require('./nuxt.config.js')
config.rootDir = __dirname
// Init Nuxt.js
const nuxt = new Nuxt(config)
const builder = new Builder(nuxt)
const server = http.createServer(nuxt.render)
// Build only in dev mode
let _NUXT_URL_ = ''
if (config.dev) {
  builder.build().catch(err =&gt; {
    console.error(err)
    process.exit(1)
  })
  // Listen the server
  server.listen()
  _NUXT_URL_ = `http://localhost:${server.address().port}`
  console.log(`Nuxt working on ${_NUXT_URL_}`)
} else {
  _NUXT_URL_ = 'file://' + __dirname + '/dist/index.html'
}

let win = null // Current window
const electron = require('electron')
const app = electron.app
const newWin = () =&gt; {
  win = new electron.BrowserWindow({})
  win.maximize()
  win.on('closed', () =&gt; (win = null))
  if (config.dev) {
    setTimeout(() =&gt; {
      // Wait for nuxt to build
      const pollServer = () =&gt; {
        http.get(_NUXT_URL_, res =&gt; {
          if (res.statusCode === 200) {
            win.loadURL(_NUXT_URL_)
          } else {
            console.log('restart poolServer')
            setTimeout(pollServer, 1000) // 参考記事では 300ms になっていますが、環境によってはエラーを吐きました
          }
        }).on('error', pollServer)
      }
      pollServer()
    }, 1000)
  } else {
    return win.loadURL(_NUXT_URL_)
  }
}
app.on('ready', newWin)
app.on('window-all-closed', () =&gt; app.quit())
app.on('activate', () =&gt; win === null &amp;&amp; newWin())

https://qiita.com/tamfoi/items/0f70bc146344ba5acaee

3−2.API通信の実装

まずは該当部分のソースコードサンプルを。

import axios from 'axios'
/*  ...  */
axios
    .get(process.env.apiEndpoint + '/member/get/all', {
        headers: {
            Authorization:
                'Bearer ' + window.localStorage.getItem('token'),
            'Content-Type': 'application/json',
        },
    })
    .then(res =&gt; { /*  ...  */});

...稚拙すぎて泣けてきます。このときは異なりますが、後ほど業務で用いたNuxtアプリケーションではちゃんとthis.$axios使っています。というのも、Nuxtでは@nuxt/axiosモジュールを導入することでNuxt付随のaxiosモジュールを用いることができます。

別プロジェクトで用いた構成がこちら。

plugins/aixos.js
export default function ({
  $axios,
  $store
}) {
  $axios.defaults.headers.common['Content-Type'] = 'application/json'
  $axios.defaults.headers.common['Accept'] = 'application/json'
  $axios.setToken($store.state.editorAuth.accessToken, 'Bearer')
}
</pre>
<h5>plugins/auth.js</h5>
<pre class="lang:js decode:true ">export default ({
  store,
  app: {
    $axios
  }
}) =&gt; {
  $axios.setToken(store.state.auth.accessToken, 'Bearer')
}
</pre>
<h5>store/auth.js</h5>
<pre class="lang:js decode:true">export const state = () =&gt; ({
  accessToken: null,
  profile: null
})

export const mutations = {
  setAccessToken(state, accessToken) {
    state.accessToken = accessToken
  },
  setProfile(state, profile) {
    state.profile = profile;
  }
}

export const actions = {
  async login({
    commit
  }, {
    email,
    password
  }) {
    const response = await this.$axios.$post('/oauth/token', {/* 認証情報 */})
      .catch(err =&gt; err.response)

    if (!response.access_token) {
      console.log('認証失敗')
    } else {
      console.log('認証成功')
      this.$axios.setToken(response.access_token, 'Bearer')
      commit('setAccessToken', response.access_token)
      const profile = await this.$axios.$get(/* ユーザー情報取得endpoint */)
      commit('setProfile', profile)
    }
  },
  async logout({
    commit
  }) {
    commit('setAccessToken', null)
  }
}
middleware/auth.js
export default function ({
  store,
  redirect
}) {
  if (!store.state.auth.accessToken) {
    redirect('/login')
  }
}
</pre>
<h5>middleware/guest.js</h5>
<pre class="lang:js decode:true">export default function ({
  store,
  redirect
}) {
  if (store.state.auth.accessToken) {
    redirect('/console/home')
  }
}

あとは、要認証ページにmiddleware: 'auth'をセットしたり非認証ページにmiddleware: 'guest'をセットするだけです。

3−3.統計情報の表示

f:id:up-tri:20200219141155p:plain

上の図のような、統計情報をグラフ化した表示を導入しました。今回はChart.jsをVue向けにラッピングしてくれているvue-chart.jsを利用しました。王道。

https://vue-chartjs.org/

ドーナツチャートを利用します。まずはドーナツチャート用のコンポーネントを用意。

/* DoughnutChart.vue */
&lt;script&gt;
import { Doughnut, mixins } from 'vue-chartjs'
const { reactiveProp } = mixins

export default {
    extends: Doughnut,
    mixins: [reactiveProp],
    props: ['options'],
    mounted() {
        this.renderChart(this.chartData, this.options)
    },
}
&lt;/script&gt;

&lt;style&gt;
&lt;/style&gt;

次にページ内で読み込んで適宜利用します。

&lt;DoughnutChart
  :chart-data="datacollection"
  :options="{maintainAspectRatio:false}"
  :width="300"
  :height="300"
/&gt;
&lt;!-- ... --&gt;
&lt;script&gt;
import DoughnutChart from '~/components/DoughnutChart'
export default {
    components: {
        DoughnutChart,
    }
}
&lt;/script&gt;

3−4.親切な細かい実装 − 処理のエラー時にエラーサウンドを鳴らす

errorSound() {
    var audioElem = new Audio()
    audioElem.src = '/error.mp3'
    audioElem.play()
}

他メソッド内でthis.errorSound();と呼び出せます。

4.スマホアプリの実装

こちらもOAuth認証を通した後に二次元コード読み込み機能を利用して簡単にAPI通信を行っているだけです。

先ほども書きましたが、このアプリを作成したのが旧版Ionic Framework上でしたので「下記ライブラリを利用したよ!」と記述しておくまでにとどめておきます。

二次元コードの読み込み

https://ionicframework.com/docs/native/qr-scanner

HTTP通信

https://ionicframework.com/docs/native/http

アプリケーションのビルド・公開

アプリケーションを作成した後は(限定的であっても)公開しなければなりません。プラットフォームごとに備忘録を載せておきます。

Electron(Windows / MacOS)編

yarnを導入している環境ではREADME.mdの通り、yarn run build --win --macでパッケージングできます。

実行するとVisual Studio / Xcode等で開発可能なパッケージの他に製品登録向けの.exe / .dmgファイル(インストーラ)が生成されます。

win-unpacked / mac ディレクトリも生成され、その中に.exe / .appが存在しますが、こちらはテスト用です。配布はできません。 身内で使う分には上記のインストーラを配布すれば大丈夫です。

Ionic(Android / iOS)編

アプリアイコンとスプラッシュスクリーンの画像素材を、resources/ディレクトリ内にicon.pngsplash.pngとして配置します。その後ionic resourcesコマンドを実行すると各OSに合わせた画像データを生成してくれます。

Android版リリース

1.ionic cordova build --release androidでAndroid版をリリースビルドします。

2.keytool -genkey -v -keystore [some_name].keystore -alias [alias name] -keyalg RSA -keysize 2048 -validity 10000でprivate keyを生成します。

3.jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 -keystore [some_name].keystore unsigned.apk [alias name]でAPK元データを生成します。

4.zipalign -vf 4unsigned.apk [app_name].apkで認証済みAPKファイルを生成します。

5.できあがったAPKファイルをGoogle Play Consoleでアップロードします。(※要 開発者アカウント

6.Google Play Consoleにて必要事項を入力のうえ、審査へ提出します。(審査時間:約30分)

iOS版リリース

iOS版のビルド・リリースにはMacOS端末が必要です。 また、Apple Developer Accountが必要です。 1.ionic cordova build --release iosでiOS版をリリースビルドします。

2.プロジェクトファイルをXcodeで開き、未入力の必要事項を入力します。

3.Archive Buildを実行し、App Store Connect(旧:iTunes Connect)へアップロードします。

4.App Store Connectにて必要事項を入力のうえ、審査へ提出します。(審査時間:約3日〜1週間)

iOS版リリースで陥ったところ

ログイン機能を実装・提供するアプリケーションの審査ではテスト用アカウント情報の入力が必要になります。

今回はLINE Loginでの実装だったのでLINEの捨て垢を送りました。笑

アプリケーションの限定公開

一般に公開したくないアプリをどうやって共有しようか...ここもよく陥る部分だと思います。

今回はAndroid: Closed Release / iOS: TestFlightを用いました。

https://support.google.com/googleplay/android-developer/answer/3131213?hl=ja

https://developer.apple.com/jp/testflight/

まとめ

ここに書いたモノ以外にもLet's EncryptによるSSLの導入など色々と難所はありましたが...一言でいうなら「この規模はひとりでやっちゃダメ」。

長文にお付き合い頂きありがとうございました。