- 投稿日:2020-11-23T20:36:03+09:00
【Vue + ApexCharts】データを集計して円グラフに表示させる
はじめに
皆さんおはこんばにちは。
エンジニア歴1年目、現場経験ゼロの弱小自称 Web エンジニアです。Vue のアプリケーション開発で、見た目がシャレ乙な ApexCharts を導入してみたは良いものの
どうやってデータを詰めたらいいんジャイ!!となったので
本記事を作成するにいたった次第でございます。やりたいこと
・ApexChartsを使って円グラフを表示させる。
・ストアに格納したデータの集計をその円グラフに反映させる。環境
・Vue CLI v3.5.0
・Node v12.18.4
・Npm 6.14.6
・Vuetify 2.3.18
・ApexCharts 1.6.0コードサンプル
本記事の目的は用意されたデータを如何にして ApexCharts に適用させるかなので
Vueプロジェクトの作成方法やデータの格納方法などは割愛します。もし、プログラムを実際に動かしてみて確認したい方がいましたら
以下のコードサンプルをご活用ください。
Vuetifyの導入に必要なターミナルコマンド
$ vue add vuetify
App.vue
<template> <v-app> <v-main> <router-view /> </v-main> </v-app> </template>
Form.vue
<template> <v-layout row wrap justify-center> <v-flex xs5 mt-5> <v-card> <v-card-title> <v-flex xs12 class="text-center"> 支出フォーム </v-flex> </v-card-title> <v-divider></v-divider> <v-card-text> <v-form ref="test_form"> <v-text-field v-model="expenditure.price" label="支出額:" :rules="[numberRules.required,numberRules.regex]"> <template v-slot:append>円</template> </v-text-field> <v-radio-group row label="カテゴリー:" v-model="expenditure.category"> <v-radio label="食費" value="食費"></v-radio> <v-radio label="交通費" value="交通費"></v-radio> <v-radio label="住居費" value="住居費"></v-radio> <v-radio label="その他" value="その他"></v-radio> </v-radio-group> </v-form> </v-card-text> <v-divider></v-divider> <v-card-actions> <v-flex class="text-center"> <v-btn dark depressed @click="submit" color="info">入力</v-btn> </v-flex> </v-card-actions> </v-card> </v-flex> </v-layout> </template> <script> import { mapActions } from 'vuex' export default { data() { return { //フォームに入力された値 expenditure:{}, //入力規則 numberRules: { required: value => !!value || "未入力は許さない", regex: value => /^[0-90-9]+$/.test(value) || "数字だけ入力してちょ", }, } }, methods: { submit() { if (this.$refs.test_form.validate()) { this.expenditure.price = Number(this.expenditure.price); //入力された支出額を数値に変換 this.addExpenditure(this.expenditure); //actions の addExpenditure を呼び出し this.$router.push({name:'Chart'}); //ルーティング this.expenditure = {}; //空に戻す } }, ...mapActions(["addExpenditure"]), } } </script>
Chart.vue
<template> <v-layout row wrap justify-center> <v-flex xs5 mt-5> <v-card> <v-card-title> <v-flex xs12 class="text-center"> 円グラフ </v-flex> </v-card-title> <v-divider></v-divider> <v-card-text> <!-- ここに円グラフを表示させるためのタグを書く --> </v-card-text> <v-divider></v-divider> <v-card-actions> <v-flex class="text-center"> <v-btn dark depressed @click="returnPage" color="info">戻る</v-btn> </v-flex> </v-card-actions> </v-card> </v-flex> </v-layout> </template> <script> export default { methods: { returnPage() { this.$router.push({name:'Form'}); //ルーティング } } } </script>
router/index.js
import Vue from 'vue' import VueRouter from 'vue-router' import Form from '../views/Form.vue' import Chart from '../views/Chart.vue' Vue.use(VueRouter) const routes = [ { path: '/', name: 'Form', component: Form }, { path: '/chart', name: 'Chart', component: Chart } ] const router = new VueRouter({ mode: 'history', base: process.env.BASE_URL, routes }) export default router
store/index.js
import Vue from 'vue' import Vuex from 'vuex' Vue.use(Vuex) export default new Vuex.Store({ state: { expenditures:[], //支出データを保存する配列 }, mutations: { //支出データを state の expenditures に追加 addExpenditure(state, expenditure) { state.expenditures.push(expenditure) } }, actions: { //支出データを追加する mutations の呼び出し addExpenditure({commit}, expenditure) { commit('addExpenditure', expenditure) } }, getters: { } })ApexCharts のインストール
$ npm install vue-apexcharts円グラフの表示
まずは以下の記事を参考に円グラフを表示させてみます。
円グラフのコードサンプルChart.vue<apexchart type="pie" height="400" :options="chart.options" :series="chart.series"></apexchart><script> import VueApexCharts from 'vue-apexcharts' export default { components: { apexchart: VueApexCharts, }, data: () => ({ chart: { options: { labels: ['食費','交通費','住居費','その他'], title: { text: 'ジャンル別 支出割合', align: 'center', }, }, series: [4, 2, 5, 3], } }),
series
は、データのオブジェクトの配列を受け付けるオプションです。
labels
は、series
の配列に対応した配列を受け付けるオプション。今回の場合であれば
食費、交通費、住居費、その他の割合が 4:2:5:3 の円グラフが表示されます。算出されたデータの表示
本題はここから。
先程は適当な数値を入れて円グラフを表示させましたが
自前で用意したデータの集計を表示させたい。その場合は、加工したデータを配列に詰めちゃえばいいわけです。
(結局series
は配列を受け付けるので。)今回は state 内のデータを加工するので getters 内で算出処理をする。
store/index.jsgetters: { chartSeries: (state) => { let foodSum = 0; //食費支出額の合計 let transportationSum = 0; //交通費支出額の合計 let housingSum = 0; //住居費支出額の合計 let othersSum = 0; //その他の支出額の合計 for (let ex of state.expenditures) { if (ex.category === '食費') { foodSum += ex.price; } else if (ex.category === '交通費') { transportationSum += ex.price; } else if (ex.category === '住居費') { housingSum += ex.price; } else if (ex.category === 'その他') { othersSum += ex.price; } } return [foodSum, transportationSum, housingSum, othersSum]; //それぞれの合計が詰まった配列 } }後は、
series: [4, 2, 5, 3]
を消して以下のように書き換えれば、ほい完成。Chart.vue<apexchart type="pie" height="400" :options="chart.options" :series="$store.getters.chartSeries"></apexchart>参考記事
- 投稿日:2020-11-23T19:44:37+09:00
【Vue.js】簡単にCRUD機能を実装してみた
CRUD機能とは
- Create(生成)
- Read(読み取り)
- Update(更新)
- Delete(削除)
以上4つの機能をまとめたものを、頭文字を取って「CRUD」と読んでいます。
今回はVue.jsでCRUD機能を兼ね備えたToDoアプリを作成していきます。
Create(生成), Read(読み取り)
まずはCreate, Read機能を実装します。
ToDoアプリで言うところの、タスク追加,一覧表示機能ですね。Vue.js<template> <div> <h1>Todoアプリ</h1> <!-- タスク入力欄 --> <input type="text" v-model="task" placeholder="タスクを入力" > <button @click="addTask">追加</button> <!-- タスクを表示させるためのテーブル --> <table> <thead> <tr> <th>タスク</th> </tr> </thead> <tbody> <tr v-for="todo in todos" :key="todo.id" > <td>{{ todo.comment }}</td> </tr> </tbody> </table> </div> </template> <script> export default { data() { return { // タスクを配列に格納する todos: [], task: '' } }, methods: { // タスク追加メソッド addTask() { if (this.task === '') { alert('タスクを入力してください'); return } // inputに入力されたタスクを配列に追加 this.todos.push({ id: this.todos.length, comment: this.task }); // タスク入力後、inputを空にする this.task = ''; } } } </script>上のコードでは以下を実行しています。
1、inputにタスクを入力。
2、追加ボタン押下時に配列todosにタスクを追加。
3、v-forをテーブルタグに付与し、順にタスクを表示させる。実際にタスクを入力して追加してみましょう。
ちゃんと機能していることが分かります。Update(更新)
次はUpdate機能を実装します。
ToDoアプリで言うところの、タスク編集機能ですね。Vue.js<template> <div> <h1>Todoアプリ</h1> <!-- タスク入力欄 --> <input type="text" v-model="task" placeholder="タスクを入力" > <button @click="addTask">追加</button> <!-- タスクを表示させるためのテーブル --> <table> <thead> <tr> <th>タスク</th> </tr> </thead> <tbody> <tr v-for="todo in todos" :key="todo.id" > <td>{{ todo.comment }}</td> <!------------------------ここから追加------------------------> <td><button @click="showEditTask">編集</button></td> <td><input type="text" v-model="editComment" v-show="showEdit"></td> <td><button @click="editTask(todo)" v-show="showEdit">完了</button></td> <!------------------------ここまで追加------------------------> </tr> </tbody> </table> </div> </template> <script> export default { data() { return { // タスクを配列に格納する todos: [], showEdit: false, task: '', // 追加 editComment: '' } }, methods: { // タスク追加メソッド addTask() { if (this.task === '') { alert('タスクを入力してください'); return } // inputに入力されたタスクを配列に追加 this.todos.push({ id: this.todos.length, comment: this.task }); // タスク入力後、inputを空にする this.task = ''; }, // ---------------ここから追加--------------- // タスク編集欄の表示/非表示 showEditTask() { // タスク編集欄が非表示なら表示させる if (this.showEdit === false) { this.showEdit = true; // タスク編集欄が表示中なら非表示にする } else if (this.showEdit === true) { this.showEdit = false; } }, // タスク編集メソッド editTask(todo) { if (this.editComment === '') { alert('タスクを入力してください'); return } // どのテーブルを編集するか絞り込む const targetIndex = this.todos.indexOf(todo); // spliceでタスクを置換する this.todos.splice(targetIndex, 1, { id: targetIndex, comment: this.editComment }) // タスク入力後、inputを空にする this.editComment = ''; } // ---------------ここまで追加--------------- } } </script>上のコードでは以下を実行しています。
1、編集ボタン押下時にv-showでタスク編集用のinputと完了ボタンを出現させる
2、完了ボタン押下時にinputに入力された値に応じて、todosの中身を置換(編集)する実際にタスクを編集してみましょう。
ちゃんと機能していることが分かります。Delete(削除)
最後にDelete機能を実装します。
ToDoアプリで言うところの、タスク削除機能ですね。Vue.js<template> <div> <h1>Todoアプリ</h1> <!-- タスク入力欄 --> <input type="text" v-model="task" placeholder="タスクを入力" > <button @click="addTask">追加</button> <!-- タスクを表示させるためのテーブル --> <table> <thead> <tr> <th>タスク</th> </tr> </thead> <tbody> <tr v-for="todo in todos" :key="todo.id" > <td>{{ todo.comment }}</td> <!------------------------ここから追加------------------------> <td><button @click="deleteTask(todo)">削除</button></td> <!------------------------ここまで追加------------------------> <td><button @click="showEditTask">編集</button></td> <td><input type="text" v-model="editComment" v-show="showEdit"></td> <td><button @click="editTask(todo)" v-show="showEdit">完了</button></td> </tr> </tbody> </table> </div> </template> <script> export default { data() { return { // タスクを配列に格納する todos: [], showEdit: false, task: '', // 追加 editComment: '' } }, methods: { // タスク追加メソッド addTask() { if (this.task === '') { alert('タスクを入力してください'); return } // inputに入力されたタスクを配列に追加 this.todos.push({ id: this.todos.length, comment: this.task }); // タスク入力後、inputを空にする this.task = ''; }, // タスク編集欄の表示/非表示 showEditTask() { // タスク編集欄が非表示なら表示させる if (this.showEdit === false) { this.showEdit = true; // タスク編集欄が表示中なら非表示にする } else if (this.showEdit === true) { this.showEdit = false; } }, // タスク編集メソッド editTask(todo) { if (this.editComment === '') { alert('タスクを入力してください'); return } // どのテーブルを編集するか絞り込む const targetIndex = this.todos.indexOf(todo); console.log(targetIndex); // spliceでタスクを置換する this.todos.splice(targetIndex, 1, { id: targetIndex, comment: this.editComment }) // タスク入力後、inputを空にする this.editComment = ''; }, // ---------------ここから追加--------------- // タスク削除メソッド deleteTask(todo) { // どのテーブルを削除するか絞り込む const targetIndex = this.todos.indexOf(todo); // spliceでタスクを削除する this.todos.splice(targetIndex, 1); // 削除後idを振り直し、誤作動を無くす for (let i = targetIndex; i < this.todos.length; i++) { this.todos[i].id = i; } } // ---------------ここまで追加--------------- } } </script>上のコードでは以下を実行しています。
1、削除ボタン押下時、spliceでtodos配列からタスクを削除
2、削除はタスクと紐づけているidを辿って行っているので、idを振り直し誤作動を無くす実際にタスクを削除してみましょう。
ちゃんと機能していることが分かります。まとめ
今回は簡単にCRUD機能をToDoアプリで実装しました。
リファクタすればもっと綺麗なコードになりそうですが、ご愛嬌。
- 投稿日:2020-11-23T15:38:34+09:00
Lazy Loadを使ってみる【現状NG】
現状
遅延表示することができていません
概要
下記を参考にLazyを使ってみました。Lazy Load何?とか 、なぜ使うの? とかも記載されてるます
Lazy Loadで画像を遅延ロードする方法手順
- lazyloadのjsを読み込む
<script src="https://cdn.jsdelivr.net/npm/lazyload@2.0.0-rc.2/lazyload.min.js"></script>
- imgタグをの画像指定をsrcからdata-srcに変更(※下記はVueで記述)
- imgタグのclassにlazyloadを追加
<!-- <img class="content_img" :src="videoInfo.img_url"> --> <img class="content_img lazyload" :data-src="videoInfo.img_url">
- bodyの最後でlazyload()を実行
- 投稿日:2020-11-23T13:10:28+09:00
WebComponent でラップした Vue コンポーネントを Cypress でテストする
はじめに
レガシーアプリケーションのWebフロントエンド改善に、自己完結型の WebComponent は有力な手段です。
なぜなら、レガシーなWebフロントエンドは以下の特徴を有していることが多いからです。
- CSSのルールに秩序がない
- jQueryなどでかなり広い範囲の DOM を変更する
- グローバルな状態に依存する
Shadow DOM によって、既存の CSS ルールの影響を受けず、JavaScript によるエレメントの改変からUIを守ることができます。
加えて WebComponent の仕様によって、UIパーツにソフトウェア的なインターフェースを明確に定義することができます。
こうすることで、単一のソフトウェアユニットとしてのスコープをUIパーツに定義することができ、ユニットテストや、リファクタリング、UIのデザイン改善を行いやすくなります。一方で、既存のアプリケーションから隔離されているため、できるだけWebComponent自身で完結していることが望ましいです。
そのため、WebComponet はAPIの通信や自身の状態を更新するなど色々なことを行います。
これらの様々なふるまいを自動でテストできる状態を作ることは、次世代のレガシーにしないためにも重要です。本文書では、Vue.js をベースに構築した WebComponent を Cypress を使ってテストをするまでの手順を説明します。
コードだけを見れば十分という方は、以下のリポジトリを参照してください。
https://github.com/sterashima78/cypress-vue-webcomponentプロジェクトの作成
Vue CLI で作成します。
vue create <project-name>基本的には各自の自由で問題ないと思いますが、 E2E は選択しないでください。
Vue CLI Plugin の E2E テストはSPAを前提としているため、うまくいかないです。また、Vue.js 自体のバージョンは 2系にしてください。3系は、vue-web-component-wrapper がサポートされていません。
私は、普段から Typescript + Composition API で開発を行っているので、Typescript のサポートを追加し、Class コンポーネントは選択しません。
追加の依存パッケージを追加します。
npm i -D cpx cypress http-server start-server-and-test npm-run-all npm i axios @vue/composition-api
テスト対象の作成
テスト対象の要件
コンポーネントは、利用者との間に以下のインターフェースを持ちます。
- Props
- 利用者が任意の値を渡す
- Event
- コンポーネントから利用者へメッセージを送る
- Slot
- コンポーネントに任意のコンテンツを挿入する
加えて以下をトリガーに自身の状態を更新します。
- ライフサイクルイベント
- ユーザからUIへのアクション
- ボタンクリック
- フォーム入力
- など..
状態の更新は多くの場合はそのまま対応するUIの更新がトリガーされます
また、状態の更新は、何かしらのロジックに基づき直接的に行われることもありますし、Web API などをコールして外部から取得した状態に更新することがります。これらの機能を持たせたコンポーネントをデモとして実装します。
テスト対象
本筋ではないのでソースを示すだけにします。
以下が、メインのロジックです。
useCounter
を見ればどんな状態と振る舞いがあるか大体わかります。src/components/counter.composition.tsimport { ref, computed, Ref, SetupContext } from "@vue/composition-api"; import axios from "axios"; export type Status = "ok" | "error:fetch" | "error:save" | "loading"; export type Message = Omit<Record<Status, string>, "ok">; export const msg: Message = { loading: "読み込み中", "error:fetch": "読み込みに失敗", "error:save": "保存に失敗" }; type FetchCount = () => Promise<number>; type SaveCount = (count: number) => Promise<number>; export const fetchCount: FetchCount = () => axios .get<{ count: string }>("/api/count") .then(({ data }) => data.count) .then(c => parseInt(c, 10)); export const saveCount: SaveCount = (count: number) => axios .post<{ count: string }>("/api/count", { count }) .then(({ data }) => data.count) .then(c => parseInt(c, 10)); export const init = ( count: Ref<number>, state: Ref<Status>, fetch: FetchCount, emit: SetupContext["emit"] ) => () => { state.value = "loading"; return fetch() .then(c => { count.value = c; state.value = "ok"; }) .catch(() => { state.value = "error:fetch"; emit("loaderr"); }); }; export const save = ( count: Ref<number>, state: Ref<Status>, save: SaveCount ) => () => save(count.value) .then(c => { count.value = c; state.value = "ok"; }) .catch(() => { state.value = "error:save"; }); export const toMessage = ( count: Ref<number>, state: Ref<Status>, messages: Message ) => () => { switch (state.value) { case "ok": return count.value; default: return messages[state.value]; } }; export const useCounter = ( onMounted: (fn: () => void) => void, emit: SetupContext["emit"] ) => { const count = ref(0); const state = ref<Status>("ok"); // マウント時に Web API からカウンタの値を取得する onMounted(init(count, state, fetchCount, emit)); return { // カウンタの数 count, // 通信などの状態を示す status, // 表示テキスト text: computed(toMessage(count, state, msg)), // カウンタを増やす increment: () => count.value++, // カウンタを減らす decrement: () => count.value--, // カウンタの値をAPIで送信する save: save(count, state, saveCount) }; };コンポーネントが以下。
src/components/Counter.vue<template> <div> <h1>{{ msg }}</h1> <span v-text="text" /> <button data-test="increment" @click="increment">increment</button> <button data-test="decrement" @click="decrement">decrement</button> <button data-test="save" @click="save">save</button> </div> </template> <script lang="ts"> import Vue from "vue"; import CompositionApi, { defineComponent, PropType, onMounted } from "@vue/composition-api"; import { useCounter } from "./counter.composition"; Vue.use(CompositionApi); export default defineComponent({ props: { msg: { type: String as PropType<string>, default: "default msg" } }, setup(_, { emit }) { return useCounter(onMounted, emit); } }); </script>以下のような機能を持ちます。
- props で表示テキストを変更
- mounted ライフサイクルイベントで通信
- 成功時に状態変更
- 失敗時に状態変更・エラーイベントを送信
- ボタンクリック時に状態更新
- ボタンクリック時に API 通信
- 成功時・失敗時に状態更新
ということで、slot 以外は大体の要素を含んでいるはずです。
WebComponentには Vue の slot-scope などはないので、表示に関する役割が大きいです。
そのため、e2e テストで試験をする利点が小さいと思っているのでここではスコープ外にしています。
何かしらの方法で試験をしたい場合は以下にあるような、スナップショットテストをお勧めします。
https://qiita.com/sterashima78/items/8db32368289e4859480bテストのための設定
Cypress
Cypress の設定をしていきます。
まずは、
npx cypress open
を実行してみてください。
cypress ディレクトリと、いくつかのファイルが生成されたと思います。cypress 以下に tsconfig.json を追加しておきます。
tsconfig.json{ "extends": "../tsconfig.json", "compilerOptions": { "noEmit": false, "sourceMap": false, "types": ["cypress"] }, "include": [ "./**/*.ts" ] }npm scripts
次に npm scripts の設定をします。
以下のタスクを設定して、順番に実行するとこでテストします。
- vue-cli-service で webcomponents ビルド
- テスト用のエントリファイル (HTML) 配置
- テスト用のローカルサーバ起動
- Cypress 起動 / テスト実行
package.json{ "scripts": { "build:cypress:test": "npm run build -- --target wc --name v --dest ./cypress-tests --inline-vue \"./src/components/*.vue\"", "copy:cypress:template": "cpx cypress/index.html cypress-tests/", "serve:cypress": "http-server ./cypress-tests/", "preserve:cypress": "run-s build:cypress:test copy:cypress:template", "cypress:run": "start-server-and-test 'npm run serve:cypress' http://localhost:8080 'cypress run --headless --browser chrome'", "cypress:open": "start-server-and-test 'npm run serve:cypress' http://localhost:8080 'cypress open'" }, }これで、
npm run cypress:open
をすると、順次タスクが実行されます。
cypress-tests ディレクトリにビルド済み js と HTML が配置されていて、 8080 で serve されていればOKです。
Cypress が終了すると http-server も自動で停止します。HTML ファイルは、以下のようにビルド済みJSをロードするだけです。
cypress/index.html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="/v.js"></script> </head> <body> </body> </html>テストを記述する
最後にテストを記述していきます。
まず、Cypress でアクセスする予定の HTML は空っぽなので、ここにテスト対象のコンポーネントをマウントさせたりする必要があります。
これを実現するためのカスタムコマンドを実装します。そのあと、それぞれの振る舞いに対応する試験を記述していきます。
テスト対象をマウントする
以下のようにカスタムコマンドを実装します。
cypress/support/commands.tsCypress.Commands.add("setup", (name: string, template?: string) => cy .window({ log: false }) .then(window => { window.document.body.innerHTML = template || `<${name}></${name}>`; }) .then(() => cy.wait(500)) .then(() => cy.get(name).shadow()) );型定義も欲しいので、以下も作成します。
Cypress で提供されている型定義を拡張します。cypress/@types/commands.d.ts/// <reference types="cypress" /> declare namespace Cypress { // eslint-disable-next-line @typescript-eslint/class-name-casing interface cy { setup(name: string, template?: string): Chainable<Element>; } }以下のように使います。
cy.setup("v-counter").find("span").should("contain.text", "0")
cy.setup("v-counter")
をコールすると、ドキュメントが以下のようになります。<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="/v.js"></script> </head> <body> <v-counter></v-counter> </body> </html>加えて、
<v-counter>
の shadow root が戻り値として得られます。第二引数に任意のHTML文字列を渡すとそのHTMLがbody 直下に挿入されます。
これで、試験に必要な環境を準備して、shadow root 内部の要素にアクセスできるようになったので、試験を書いていけます。
試験の記述
テストコード全体は、前述のリポジトリを見てもらったほうが早いので、テストの種類ごとに抜粋して説明します。
状態の検証
UIの状態は、その表示に現れているので、UIのテキストや色などを検証します。
例えば、
count
は何もしていない状態で 0 です。
また、通信など行っていないときは、span
要素にcount
が表示されるので、以下で検証ができます。cy.setup("v-counter").find("span").should("contain.text", "0")このように○○の時は、どのような表示になっているか (どのような状態になっているか)を検証していきます。
props の検証
props が変更されたときの振る舞いを検証します。
ここでは、msg
はそのまま表示テキストであるので、前述を同じ方法で検証します。describe("props", ()=> { it("props が見出しに表示される", ()=> { const props = "foo bar" cy.visit("http://localhost:8080") // 後で参照するために要素に alias 設定する cy.setup("v-counter").find("h1").as("title"); // 設定前はデフォルト cy.get("@title").should("contain.text", "default"); // props を設定する ($el は jQuery オブジェクト) cy.get("v-counter").then($el => $el.attr("msg", message)); // 表記が変わったことを確認 cy.get("@title").should("contain.text", props ); }) })API のモック
このコンポーネントは 外部の API に依存しています。
テスト用のAPIサーバがあればそこに向けておけばいいと考える人もいるかもしれませんが、自動テストという性質を考えると、APIサーバの状態に依存させると、テストが不安定になるなどのデメリットがあるため、モックをしておくのが適当と考えています。実際のAPIをコールさせたい場合は、アプリケーション全体レベルでのテストをデザインして別途行うのが、良いと思います。
Cypress でAPIをモックするには以下を行います。
cy.server()
をコールcy.route(options)
でモックするAPIを設定する例えば、初期状態の取得について試験を記述しようとすると、以下を検証するかと思います。
- 対象ページにアクセスする (mounted ライフサイクルで通信開始する)
- テキストが通信中テキストになる
- 通信が完了する
- テキストが API から戻ってきた値になっている
以下のように記述します。
import { msg } from "../../../src/components/counter.composition"; describe("Counter", () => { const baseUrl = "http://localhost:8080/"; beforeEach(() => { // モック設定 cy.server(); }); describe("初期化通信成功", () => { it("initialize", () => { const count = 10000; // GET /api/count の仕様を設定 cy.route({ method: "GET", url: "/api/count", response: { count }, delay: 2000 }) // 後で参照するためにエイリアス設定 .as("init") cy.visit(baseUrl); cy.setup("v-counter").as("root"); // ロード中 cy.get("@span").should("contain.text", msg["loading"]); // 通信待ち cy.wait("@init"); // 初期値 cy.get("@span").should("contain.text", count); }); }); });エラーの時は、route のオプションで
status
を指定して再現します。cy.route({ method: "GET", url: "/api/count", status: 500, response: "", delay: 2000 }).as("init");コンポーネントのイベント
コンポーネントの発行するイベントを検証します。
このコンポーネントでは、初期化通信に失敗したときにイベントを発行する仕様だったので、それを検証します。
describe("Counter", () => { const baseUrl = "http://localhost:8080/"; beforeEach(() => { // モック設定 cy.server(); }); describe("初期化通信失敗", () => { it("初期化通信失敗時はエラーメッセージが表示", () => { // GET /api/count で通信失敗 cy.route({ method: "GET", url: "/api/count", status: 500, response: "", delay: 2000 }).as("init"); cy.visit(baseUrl); cy.setup("v-counter").as("root"); cy.get("@root") .find("span") .as("span"); // 後で検証するために発行されたイベント保持する const events: number[] = []; // イベントが発行されたら保管ここでは、イベントが発行するたびに 0 を入れておく (ペイロードがあるイベントならそれを入れればいい) cy.get("v-counter").then($el => $el.on("loaderr", () => events.push(0))); cy.get("@span").should("contain.text", msg["loading"]); cy.wait("@init"); cy.get("@span").should("contain.text", msg["error:fetch"]); // イベントの検証 cy.wrap(events).should("eql", [0]); }); }); });UIに対するイベント
最後に UI パーツに対するイベント駆動で状態が変更されるような機能の検証です。
これは直観的でわかりやすいですが、一応例を示しておきます。インクリメントボタンをクリックするとカウンタがインクリメントすることを検証します。
describe("Counter", () => { const baseUrl = "http://localhost:8080/"; beforeEach(() => { // モック設定 cy.server(); }); it("increment", () => { const count = 10000; cy.route({ method: "GET", url: "/api/count", response: { count }, delay: 2000 }) cy.visit(baseUrl); cy.setup("v-counter").as("root"); cy.get("@root") .find("span") .as("span"); // increment ボタンにエイリアス設定 cy.get("@root") .find("[data-test=increment]") .as("button"); // クリック前の表示 cy.get("@span").should("contain.text", count); // クリック cy.get("@button").click(); // 表示の更新 cy.get("@span").should("contain.text", count + 1); // クリック cy.get("@button").click(); // 表示の更新 cy.get("@span").should("contain.text", count + 2); }); });要素の参照に使うセレクタ
今回の例では
h1
,span
など一つしか要素が存在しないタグはタグ名で取得していましたが、data-test
などのテスト用のカスタム属性で値を参照するほうがいいです。マークアップの構造などにはテスト側ができるだけ興味を持たないように設計できるほうが、テストが壊れにくいです。
ビルド済みコードにテストに関係する情報が含まれてしまうのを懸念する方もいると思いますが、以下などを使うことでテスト時以外は取り除くことができます。
https://github.com/LinusBorg/vue-cli-plugin-test-attrs
まとめ
- レガシープロジェクトを改良するのに WebComponent は有効
- WebComponet の品質維持のために Cypress で自動テストを書こう
- テストの内容は、WebComponet が持つ主なふるまいを網羅できるようにしよう (Cypress をちゃんと使えばできる)
- 投稿日:2020-11-23T12:44:33+09:00
Nuxt.jsとkonvaで台形補正
以下の記事で実現されているものをVue.jsでkonvaを使って実装してみたので、その内容をまとめます。
元記事様は実装については最低限しか触れられていなかったので、この記事では、ここのコードをまるっと使えば動くものを提供したいなと思います。
その他、canvasについてはズブのド素人だったので、以下の一連の記事を参考にさせていただきました。ありがとうございます!台形補正
最初に出来上がったものをお見せします。
左図の赤枠で囲われた部分を補正した結果を右図に出力しています。
参考元と同じく、リアルタイムで補正が行われるようにしており、なかなかよいパフォーマンスです。環境
- Nuxt.js 2.14.6
- Konva 7.1.8
- glfx 0.0.4
- canvasでの画像処理用ライブラリ
glfxは参考にさせていただいた記事にもありましたが、5年ほど更新がありません。そのため、TypeScriptには非対応です。TypeScriptで利用したいという方は、適宜、元記事様を参考にwrapperを定義してあげてください。
準備
konvaとglfxをインストールします。
npm install konva npm install glfxtemplate, style
早速実装内容を紹介します。まずは
<template>
と<style>
ですが、ここは最低限。glfx.vue<template> <div> <div> <div ref="container" class="canvas_inline"> <canvas id="baseImg" width="600" height="600" ref="canvas" /> </div> <div class="canvas_inline canvas_margin"> <canvas ref="result" width="400" height="566" /> </div> </div> <div> <span>{{ nowCorner }}</span> </div> </div> </template> <style scoped> .canvas_inline { display: inline-block; vertical-align: top; } .canvas_margin { margin-left: 10px; } </style>script
処理の流れはざっくりと以下のような感じです。
起動時
- canvasに画像を表示
draggable
な4頂点を定義し、dragmove
処理を追加- 4頂点と4辺を描画
- 頂点の座標情報を取得し、補正を実施
- 補正画像を出力先
canvas
に描画頂点操作時(
dragmove
)
- 座標の更新
- 座標情報を取得し、補正を実施
- 補正画像を更新
宣言
class API
を使います。glfx.vue<script> import { Component, Vue, Watch } from 'nuxt-property-decorator'; import Konva from 'konva'; import * as fx from 'glfx'; @Component export default class KonvaTop extends Vue { ... } </script>mounted
まずは、
mounted
にてcanvas
の初期設定を行い、補正に利用する画像を取得します。// data width = 600; height = 600; stage = null; canvas = null; drawingLayer = null; drawingScope = null; imageObj = null; backgroundLayer = null; backgroundImageScope = null; // 座標情報格納配列 coords = [ [202, 123], [544, 103], [45, 432], [520, 498] ]; // 4辺に関係する関数 line = null; corners = []; // 現在の座標を表示する nowCorner = ''; // perspective perspectiveNubs = []; baseCanvas = null; image = null; texture = null; resCanvas = null; mounted() { var container = this.$refs.container; this.stage = new Konva.Stage({ container, width: this.width, height: this.height }); this.drawingLayer = new Konva.Layer(); this.stage.add(this.drawingLayer); this.canvas = this.$refs.canvas; this.drawingScope = new Konva.Image({ image: this.canvas, stroke: 'black' }); this.drawingLayer.add(this.drawingScope); this.stage.draw(); // 画像読み込み this.imageObj = new Image(); this.imageObj.addEventListener('load', this.imageOnload); this.imageObj.src = require('../../static/IMAGE.JPG'); }画像読み込み完了
画像読み込みが完了すると
imageOnload
関数が呼び出されます。
4辺の描画と補正画像の出力は、最初に定義したオブジェクトを更新していくため、初期起動時とその後のdragmove
で処理が変わりますimageOnload() { // 背景レイヤ this.backgroundLayer = new Konva.Layer() // 取得された画像データをImageオブジェクトに登録 this.backgroundImageScope = new Konva.Image({ image: this.imageObj, width: this.canvas.width, height: this.canvas.height }) // 背景レイヤに背景イメージを追加 this.backgroundLayer.add(this.backgroundImageScope) this.stage.add(this.backgroundLayer) // 背景イメージを最背面に移動。 this.backgroundLayer.moveToBottom(); // 頂点の描画 this.drawCircle(); // 4辺の初期描画 this.initDrawLine(); // 補正画像の初期表示 this.initPerspectiveImage(); }頂点の処理
初期起動時に
draggable
な4頂点のオブジェクトを生成し、dragmove
のイベントを登録します。
こうすることで、頂点をドラッグで動かしてイベントを発火させることができます。drawCircle() { // 4つの頂点を1つずつ登録する for (let i = 0; i < 4; i++){ this.corners[i] = new Konva.Circle({ radius: 10, x: this.coords[i][0], y: this.coords[i][1], stroke: 'red', strokeWidth: 2, // こいつをtrueにすることでcanvas上で動かすことができる draggable: true }); // 4つの頂点ごとにイベントを分ける。 // もっとよい書き方があったら教えて下さい! switch(i) { case 0: this.corners[i].on('dragmove', this.moveCircle1); break; case 1: this.corners[i].on('dragmove', this.moveCircle2); break; case 2: this.corners[i].on('dragmove', this.moveCircle3); break; case 3: this.corners[i].on('dragmove', this.moveCircle4); break; default: break; } // 描画レイヤーに追加 this.drawingLayer.add(this.corners[i]); } // canvasを更新 this.stage.add(this.drawingLayer); // 座標情報を表示させる処理 this.getNowCorner(); }実際に頂点を動かすと、以下の処理が発火します。
座標情報を更新し、更新された座標情報をもとに、4辺を再描画し、補正画像を更新します。// それぞれどの頂点が動かされたかを識別するだけ moveCircle1(node) { this.moveCircleBase(node, 0); }; moveCircle2(node) { this.moveCircleBase(node, 1); } moveCircle3(node) { this.moveCircleBase(node, 2); } moveCircle4(node) { this.moveCircleBase(node, 3); } moveCircleBase(node, i) { // 座標情報を更新 this.coords[i][0] = node.target.attrs.x; this.coords[i][1] = node.target.attrs.y; // 座標情報の表示を更新 this.getNowCorner(); // 4辺を再描画 this.drawLine(); // 補正画像を更新 this.updatePerspectiveImage(); }辺の処理
4辺は、補正エリアを定義する頂点を結んでいます。頂点をドラッグするときに同時に動かしたかったので、頂点のドラッグイベント時に表示を更新し、頂点の動きに追従できるようにしました。
もしもっとスマートなやり方があるようでしたら、ぜひ教えて下さい!
// 初期化処理 initDrawLine() { this.line = new Konva.Line({ // 左上→右上→右下→左下→左上の順番に線をつなぐ points: [ this.coords[0][0], this.coords[0][1], this.coords[1][0], this.coords[1][1], this.coords[3][0], this.coords[3][1], this.coords[2][0], this.coords[2][1], this.coords[0][0], this.coords[0][1], ], stroke: 'red' }); this.drawingLayer.add(this.line); this.stage.add(this.drawingLayer); } drawLine() { // すでに定義したLineオブジェクトの座標情報を更新 this.line.attrs.points = [ this.coords[0][0], this.coords[0][1], this.coords[1][0], this.coords[1][1], this.coords[3][0], this.coords[3][1], this.coords[2][0], this.coords[2][1], this.coords[0][0], this.coords[0][1], ]; this.drawingLayer.add(this.line); this.stage.add(this.drawingLayer); }台形補正
ここが今回のキモです。
glfxライブラリは、Image
要素からtexture
を取得し、そのtexture
情報に画像処理をかけることができます。
台形補正を行うにはperspective(before, after)
というAPIを利用します。
before
は補正前の4頂点の情報を[ax, ay, bx, by, cx, cy, dx, dy]
と8つの要素の行列として格納し、
after
には補正後の4頂点の情報を同じ形式でセットします。
今回は、after
として400:566
というA版、B版の割合に調整した画像を出力したかったので、const after = [ 0, 0, 400, 0, 0, 566, 400, 566];こんな感じで
after
をセットしています。また、別canvasに補正結果を出力するため、補正後の
canvas
オブジェクトからgetPixelArray()
でUint8Array
を取り出しています。
この取り出した値でImageData
を作り、出力先canvas
要素に埋め込んでいます。// canvas,texture,出力先canvasを定義しておく initPerspectiveImage() { try { this.baseCanvas = fx.canvas(); } catch (e) { alert(e); return; } this.image = this.backgroundLayer.canvas._canvas; this.texture = this.baseCanvas.texture(this.image); this.resCanvas = this.$refs.result.getContext('2d'); // 最初の補正情報 this.updatePerspectiveImage(); } updatePerspectiveImage() { // beforeの座標情報を設定 this.setPerspectiveNubs(); const before = this.perspectiveNubs; const after = [ 0, 0, 400, 0, 0, 566, 400, 566]; this.baseCanvas .draw(this.texture) .perspective(before, after) .update() // ImageDataを取得して、別canvasに描画 const data = this.baseCanvas.getPixelArray(); const img = new ImageData(new Uint8ClampedArray(data), this.width*2, this.height*2); this.resCanvas.putImageData(img, 0, 0); } setPerspectiveNubs() { this.perspectiveNubs = [ this.coords[0][0] * 2, this.coords[0][1] * 2, this.coords[1][0] * 2, this.coords[1][1] * 2, this.coords[2][0] * 2, this.coords[2][1] * 2, this.coords[3][0] * 2, this.coords[3][1] * 2, ]; }最後に
- リポジトリはこちら
- 四隅を検出するデータセットがあれば、画像読み込み時に頂点推定とかできそうですよね...
- TypeScriptでリファクタリングもしたいなあ
- 投稿日:2020-11-23T12:43:47+09:00
Vue(Nuxt)+Rails APIで、ネストした配列で画像とJSONのパラメーターを送る方法
なにこれ
Vue(Nuxt)で、子テーブルへ画像データとJSONを配列にしてパラメーターで送りたい!って時にかなりつまづいたので、備忘録として書き残しておきます。
この記事で得られること
1.Vue(Nuxt)で画像+JSONの形式でPOSTする方法
2.ネストさせた配列で、画像とJSON形式のパラメーターを送る方法大事なポイント
1.FormData型の変数を宣言して、その変数にappendしていく。
test.jsconst req = new FormData() req.append(`name`, this.product.name)2.ネストした配列で送りたい時はこうする。配列を明示してその中にオブジェクトを生成&appendする。
test.jsreq.append(`product_sub_attributes[][image]`, subs[i].image)3.axiosでリクエストを投げる時、
'Content-Type': 'multipart/form-data'
にすること。test.jsconst response = await axios.post('/products', req, { headers: { 'Content-Type': 'multipart/form-data' } })備考
- バックエンドはRails APIモードを仕様しています
- 画像アップ方法は、フロントはvue-cropeer、バックエンドはcarrierwaveを使用していますが、まだ理解しきれてない箇所があるのでそちらも後日記事にします。 なので、vue-cropperとcarrierwaveの説明は割愛します。今はJSON配列で送れるんだなーと知っていただければ幸いです。
- バックエンドはみんな大好き
gem 'active_model_serializers’
を使用しています。- 一部vuetify、bootstrap、axiosを使っています。
注意事項
今回はタイトルの方法を紹介するのがメインになるので、かなり端折っています。
全部解説すると膨大な量になってしまうので。
雰囲気を掴んでいただければ、と思っているのでご了承ください。
既存のコードをQiita用に抽出して載せているので、一部間違いがあるかも知れません。前提条件
テーブル構造はこんな感じです。
productsテーブル
とproducts_subs
テーブルがあって、1:Nの関係です。
両方とも画像投稿用のimageカラムを持っています。
登場する全てのカラムはstring型です。product.rbclass Product < ApplicationRecord has_many :products_subs accepts_nested_attributes_for :products_subs endproduct_sub.rbclass ProductSub < ApplicationRecord mount_uploader :image, ProductSubUploader belongs_to :product validates :name, presence: true validates :image, presence: true endやりたいこと
productsテーブルへcreateしたい時に、子テーブルのproducts_subsへ同時にデータを保存させたい。
そのためにはネストして送る必要がある。早速解説します。
画像アップのおおまかな流れ
プレビュー表示用のimgタグと画像アップ用のinputタグを用意して、
imgタグをクリックすると$refsでinputを参照してクリックしたことにします。配列なので$refs.subImageに[i]をつけて、番号を参照しています。
inputタグはクリックされるとsetSubImageメソッドを呼び出します。setSubImageで画像を読み込んだ後にvue-cropperに投げて、
らcropSubImageメソッドでsubImageCropperを参照してプレビューを表示する変数に突っ込んだり、画像格納用のオブジェクトに代入したりしてます。
余談だけど、このvue-cropperさんの動きが理解できてなくて、たまに$refsで参照できないバグが生まれます。笑test.vue<template v-for="(sub, i) in product.productsSubAttributes"> <div> <v-textarea v-model="sub.name" > </v-textarea> <img :src="sub.imageSrc ? sub.ImageSrc : '' " @click.prevent="$refs.subImage[i].click()" > <input ref="subImage" class="d-none" type="file" name="image" accept="image/*" @change="setSubImage($event)" /> <v-card> <vue-cropper ref=“subImageCropper" :src="imgSrc" /> <button @click="cropSubImage(sub), i)"> 保存 <button> </v-card> </div> </template> <script> export default { methods: { setSubImage(e) { const reader = new FileReader() reader.onload = (e) => { this.imgSrc = e.target.result } reader.readAsDataURL(e.target.files[0]) }, async cropSubImage(sub, i) { // imageSrcがプレビュー表示用のプロパティです。 sub.imageSrc = this.$refs.subImageCropper[0].getCroppedCanvas().toDataURL() this.$refs.subImageCropper[0].getCroppedCanvas().toBlob((blob) => { sub.image = blob }) } } } } </script>本題の画像とJSONオブジェクトをパラメーターで送る方法
今回はaxiosを使います。
その際に、configを設定してmultipart/form-data
という形式に変換します。
【axios】HTTPリクエストメソッド別の引数一覧表(エイリアスを使用した場合)
この記事がよくまとまってたのでリンク貼っておきます。multipart/form-dataとは、画像ファイル(Blob型)を送信できるようにするHTTPリクエストメソッドです。
application/jsonだと画像データが送れないです。パラメーターで送るための方法
細かく解説するためにコメント式にしました。
test.jsasync onClickCreate() { // FormData型の変数reqを定義して、そちらにappendしていきます。 const req = new FormData() // 今回は使用しませんが、こんな感じでバックエンドのカラム名に合わせて // オブジェクトを生成してappendすることで、パラメーターを送れます。 req.append('name', this.product.name) // 定数subにアップした画像や文字列のデータを代入して、for文で回します。 // for文を使っている理由は複数ありますが、今回は省略。状況によってはforEachでも代用可能です。 const subs = this.product.productSubAttributes for(let i = 0; i < subs.length; i++){ // 送りたいパラメーターに[]をつけることで、0から順番に配列で送ることが出来ます。 // ただ、配列にインデックスを指定する方法が分からないです。 req.append(`product_sub_attributes[][name]`, subs[i].name) // subs[i].imageがBlob型である=先程vue-cropeerで整形したデータなので、直接カラムに代入します。 if (subs[i].image instanceof Blob) { req.append(`product_sub_attributes[][image]`, subs[i].image) // 画像がない場合、番号を指定できないので、空文字を代入しないと順番が狂います。 // どういうことかと言うと、配列内のオブジェクトが2個以上あった場合、0番目から探して空いているカラムに代入されます。 // ちなみにここでかなりハマりました。 } else { req.append(`product_sub_attributes[][image]`, '') } } try { // 先程代入していった変数reqをパラメーターで送ります。 // 余談ですが、Railsの場合全てキー名をスネークケースにする必要があります。 const response = await axios.post('/products', req, { headers: { 'Content-Type': 'multipart/form-data' } }) } catch (error) { console.error(error.response) } }ちなみにバックエンドはこんな感じ
products_controller.rbdef product_params params.permit( :id, :name, { product_sub_attributes: [ :id, :name, :image, ] } ) endおわり
こんな感じでVue(Nuxt)からパラメーターで画像を配列+JSONで送ることが出来ます。
自分が実装しようとした時にこの方法にたどり着くまで苦労したので、
個人的な感想
やり方(How)を覚えるのは良いことなんですけど、
裏側のなぜこの方法なのか(How)の方を理解していきたいです。
- 投稿日:2020-11-23T10:44:02+09:00
AWS, Docker, CircleCI, Laravelでポートフォリオを作成してみた【参考リンク付き】
初めに
今回はDocker, CircleCI, AWS等、人気の高まっているインフラ技術を一から学んで、Webアプリを作成してみました。
バックエンドはLaravel、フロントエンドにVue.js等といった構成です。この記事では、アプリ開発にあたって苦労した点や、
各機能実装の際に参考にした記事や教材についてもご紹介していければと思います。アプリの概要
朝活をテーマをしたSNSアプリです。
- 朝活仲間を作り、「コツコツ」継続できる
- 朝活習慣の「コツ」を共有して、朝活の挫折を防ぐ
ことをコンセプトに、「朝活」を文字って「AsaKotsu」というサービスを開発しました。
URLはこちら↓です。よければ、ご自由に動かしてみてください^^アプリのURL:https://pf.asakotsu.com/
(※まだスマホ対応が完了していないので、PCでの閲覧推奨です^^;)GitHubのURL:https://github.com/ngsw877/asakotsu
使用画面のイメージ
このアプリの特徴
基本的にはtwitterのような投稿、コメント、いいね、フォロー機能のあるSNSですが、
その他に以下のような特徴のあるアプリです。
- アプリから、朝活Zoomミーティングを作成、編集、削除できる(ZoomAPI連携)
- 目標起床時間を設定して、早起き達成日数を記録することができる
- 早起き達成日数のランキング機能(1ヶ月ごとに集計)
- 投稿にタグ付けし(カテゴリ)、「朝コツ」タグ等で朝活のコツを共有することができる
使用技術
フロントエンド
- Vue.js 2.6.11
- jQuery 3.4.1
- HTML / CSS / Sass / MDBootstrap
バックエンド
- PHP 7.4.9
- Laravel 6.18.36
- PHPUnit 8.5.8
- ZoomAPI (guzzlehttp/guzzle 7.0.1)
インフラ
- CircleCi
- Docker 19.03.12 / docker-compose 1.26.2
- nginx 1.18
- mysql 5.7.31 / PHPMyAdmin
- AWS ( EC2, ALB, ACM, S3, RDS, CodeDeploy, SNS, Chatbot, CloudFormation, Route53, VPC, EIP, IAM )
サーバーサイドのロジックはPHP/Laravelでプログラミングし、
フロントエンドの細かいデザインはSassで整え、動きを付けたい時はVue.jsやjQueryで実装しました。
開発環境にDocker/docker-composeを使用し、
CI/CDパイプラインに関しては、CircleCIで自動テスト・ビルドを行い、
AWSのCodeDeployで自動デプロイを実現するようにしています。インフラ構成図
開発環境、本番環境について
開発環境に
Docker / docker-compose
を使用しており、以下の4つの用途のコンテナを使用しています。
- Webサーバーのコンテナ: Nginx
- アプリケーションのコンテナ: PHP / Laravel / Vue.js
- DBのコンテナ: MySQL
- DB管理用のコンテナ: PHPMyAdmin
参考リンク:
本番環境のAWS上ではECSでデプロイしたかったのですが、
難易度が高く断念・・
ひとまずEC2でのデプロイ経験にも慣れるため、今回はEC2上で環境構築していく形で進めていきました。SSL証明書の発行
SSL証明書を発行してHTTPS化も実現したかったため、
ACM(AWS Certificate Manager)
を使用しています。ACMを使用するためには、EC2に加えて、
ALB(ELB)
やCloudFront
も必要になってくるため、今回はALBを導入することにしました。
なお、ALBを使用しているものの、節約のため現状では負荷分散やスケールアウトする程のアクセスが見込まれないため、EC2インスタンスは1つのみ用意しています。
なお、アドレスバーに鍵マークがついても、Laravel側のプロキシ設定をしないとcssやjsファイルが読み込まれなかったり、ルーティングがhttps化されなくなるので要注意な印象。。参考リンク:
- AWS:無料でSSL証明書を取得する方法
- 信用するプロキシの設定S3バケットへのアップロード
S3は、以下の2つの用途別に用意しています。
CircleCIでビルドしたソースを格納
EC2上のアプリでアップロードした画像データを格納
2に関しては、S3のバケットポリシーの設定や、Laravel側でS3用パッケージのインストールが必要だったりと意外にやるべきことがありました。
参考リンク:
Slackへの通知設定
CodeDeploy
とSNS
、Chatbot
を連携して、自動デプロイの開始と終了のタイミングでSlackアカウントに通知が飛んでくるようにしています。なかなか便利。機能一覧
ユーザー登録関連
- 新規登録、プロフィール編集機能
- ログイン、ログアウト機能
- かんたんログイン機能(ゲストユーザーログイン)
ZoomAPI連携
- 朝活Zoomミーティング機能(CRUD)
- ミーティングの新規作成、一覧表示、編集、削除機能
早起き達成の判定機能
- ユーザー毎に目標起床時刻を設定可能(4:00〜10:00まで)
- 目標起床時間より前に投稿をすることができれば、早起き達成記録が1日分増えます。
- ※深夜過ぎ等に投稿した場合も早起き成功とならぬよう、
目標起床時間より3時間前に投稿しても無効になるよう対処しています。
(例)目標起床時間を07:00に設定した場合、04:00~07:00に投稿できたら早起き達成ユーザーの早起き達成日数のランキング機能(1ヶ月毎)
無限スクロール機能 (jQuery / inview.js / ajax)
ユーザー投稿関連(CRUD)
コメント機能
タグ機能 (Vue.js / Vue Tags Input)
いいね機能 (Vue.js / ajax)
フォロー機能
- フォロー中/フォロワー一覧(ページネーション)
フラッシュメッセージ表示機能 (jQuery/ Toastr)
- 投稿、編集、削除、ログイン、ログアウト時にフラッシュメッセージを表示
画像アップロード機能 (AWS S3バケット)
PHPUnitテスト
DB設計
ER図
各テーブルについて
テーブル名 説明 users 登録ユーザー情報 follows フォロー中/フォロワーのユーザー情報 achievement_days ユーザーが早起き達成した日付を、履歴として管理 meetings ユーザーが作成したZoomミーティング情報 articles ユーザー投稿の情報 tags ユーザー投稿のタグ情報 article_tags articleとtagsの中間テーブル likes 投稿への、いいねの情報 comments ユーザー投稿への、コメントの情報 早起き達成機能 関連のポイント
usersテーブルの
wake_up_time
はユーザーの目標起床時間を意味しています。
この時間よりも早い時間にユーザーが投稿をできれば、その日の早起きが達成となります。
なお、
「目標起床時間が07:00で、深夜1:00に投稿した」
というように、早過ぎる時間にユーザーが投稿した
場合にも早起き達成とならないように設定しています。
その仕組みとして、usersテーブルのrange_of_success
の値が利用されています。
これは、
「目標起床時間より何時間前までに投稿すれば早起き達成となるのか、その範囲を表す整数値」
です。
デフォルトは3
で、例えば目標起床時間を07:00に設定している場合は、その3時間前の
04:00 〜 07:00 の間に投稿できれば早起き達成となります。こうして早起き達成をすることができたら、achievement_daysテーブルの
date
に達成日の日付が履歴として記録されていきます。
例) 2020-11-22
この日付データを利用して、以下の機能を実現しています。① 1ヶ月毎の早起き達成日数を算出
② ①の日数を利用したランキング機能当初は、早起き継続日数のランキングにしようかとも考えていましたが、
ユーザーのモチベーション維持等の観点から1ヶ月毎の早起き達成日数を採用することにしました。※朝活アプリなので、もともとは目標起床時間04:00~10:00の間しか設定できない仕様ですが、
現在は「早起き達成判定」機能を好きな時間にお試しいただけるよう、時間設定を自由にできるようにしています。苦労したこと
開発からデプロイまで、どの工程でももれなくエラーで苦戦しましたがw、
ここでは特に印象に残っている点をまとめます。CircleCIで苦労したこと
- CircleCIの設定ファイルである、config.ymlの設定
- 自動ビルド、自動テストの流れの理解
config.ymlの設定においては、だいぶエラーに悩まされました。。
特に、コマンドやパスを指定する時は、パスのルートはどこが起点になっているのかを理解することが重要な印象。
テスト失敗時の対策としては、ビルドされたコンテナにSSHログインしてエラーログを確認し、原因を解消していくようにしていました。参考リンク: SSH を使用したデバッグ
AWSデプロイで苦労したこと
- ACMでのSSL証明書発行
- Laravelで画像をS3にアップロードする設定
- CodeDeployでの、自動デプロイ設定(特にappspec.yml)
- EC2インスタンスのセットアップ
上述した、
SSL証明書の発行
S3バケットへのアップロード
周りでエラーにハマりがちでした。
また、今回はECSでなくEC2でデプロイすることとしましたが、EC2にSSHログインしてから
インストールしたり設定するファイルが多く、その辺りの作業も大変でした。
この工程を考えると、ますますECSを扱えるようになりたく思いましたね^^;フロントエンドで苦労したこと
- UI/UXの調整(Sass)
- Ajax全般
バックエンドでの苦労
- DB設計
- DBリレーション関連の処理
- PHPUnitでのテスト全般
リレーション周りについては当初かなり苦戦しました。
どのテーブルとどのテーブルを関連付けるのか、また関連付けた情報をどうやって取得すれば良いのか?
また、
- $article->user()
- $article->user
例えばこの2つの違いについても重要なポイントと感じました。
PHPUnitのテストコードについては、体系的に学べる情報がなかなか見つからなかったので、情報収集に苦労しました。
ZoomAPI連携で苦労したこと
- Guzzleの理解
- ZoomAPIの理解
アプリ上からZoomミーティングを作成したり編集できる機能をつけることにしましたが、
これまで外部APIを利用したことがなかったこともあり、文法的なものや、API通信の仕組みについて理解するまでが難しく感じました。実装にあたり、まずLaravelでZoomAPIと通信を行うために、PHPのHTTPクライアントである
Guzzleをインストールしました。参考リンク:
次に、Zoomアプリマーケットプレイスでアプリを登録し、公式ドキュメントを読んでみるも、英語な上初めはどこのページの何を見れば良いのかわからず苦戦しました。。^^;
Laravelで、ZoomAPIと通信を行う処理のサンプルコードを紹介している
海外の記事を参考にしたりしているうちに、次第に公式ドキュメントから必要な情報を探せるようにもなってきました。参考リンク:
ただ、今回Laravel6系でアプリを開発していたため、通常Laravel7系で使用できるGuzzleラッパーが使えず、ややコードを書き換えないといけない点にも苦労しました。
参考にした学習教材等
基本的には、UdemyとTechpitで学習してきました。
この2つはとてもわかりやすいです。
個人的には、Udemyで基礎を学んでから、応用編としてTechpitで手を動かしながら学ぶのが良いと感じました。Docker / docker-compose
PHPUnit / CircleCI / AWS
AWS
- 【Udemy】AWS:ゼロから実践するAmazon Web Services。手を動かしながらインフラの基礎を習得
- 【Udemy】これだけでOK! AWS 認定ソリューションアーキテクト – アソシエイト試験突破講座(SAA-C02試験対応版)
- 【書籍】Amazon Web Services 基礎からのネットワーク&サーバー構築 改訂3版
Laravel
Laravel / Vue.js
Sass
今後の課題
- レスポンシブWebデザイン(スマホ対応)
- デザイン面の改善
- 無限スクロールの不具合修正(読み込まれた投稿のいいねボタンが消える)
- ALBにAuto Scalingを追加し、EC2を冗長化
- ECS(EKS)でのデプロイ
- RDSの冗長化
- インフラのコード化
- 検索機能の追加
- テストコードの充実
- 投稿時に別画面へ遷移するのではなく、入力フォームをモーダルで表示させるようにする
- 開始前のZoomミーティング、終了ミーティングのソート機能
まだ課題も多いですが、一つずつ改善してよりブラッシュアップしていきたく思います。
だいぶ長い記事になってしまいましたが、ここまで読んでくださりありがとうございました!^^
- 投稿日:2020-11-23T02:48:25+09:00
【JavaScript】clientWidthプロパティでは小数値は取得できない
JSで横幅や高さを取得する処理を実装することはよくあると思います。
その際に、よく使われるのは# 横幅取得 element.clientWidth # 高さ取得 element.clientHeight上記だと思います。
ただし、clientWidth
やclientHeight
プロパティは整数値に丸め込まれてしまいます。僕はこれを知らずに
clientWidth
やclientHeight
を使ってどはまりました。
横幅や高さを取得した上で計算する際に、小数値が丸め込まれてしまうと計算される値がずれてしまいます。小数値まで含まれた値が欲しい
タイトルにもあるように、getBoundingClientRect()メソッドを使います。
# 横幅取得 element.getBoundingClientRect().width # 高さ取得 element.getBoundingClientRect().heightちなみに、
getBoundingClientRect()
は要素の横幅・高さだけでなく、ビューポートに対する位置も取得できます。まとめ
最近は、バックエンドだけでなく、フロントエンドの開発も行うようになりました。
そんなフロントエンド初心者の僕は、基本的に要素の寸法と、そのビューポートに対する位置を取得したいときは、
getBoundingClientRect()
を使えば良さそうだと思いました。
clientWidth
やclientHeight
を使うメリットなどがあれば教えてください。
- 投稿日:2020-11-23T00:42:48+09:00
Vue3でYouTubeの動画を操作するChrome拡張機能を開発する
作ったもの
『YouTube Chapter Editor』というYouTubeのチャプター(目次)が効率良く作成できるChrome拡張機能を作りました。
動画編集ページのプレイヤーに専用ツールが埋め込まれ、チャプター情報を入力していくことで貼り付け用のテキストが自動生成されるツールです。
YouTubeでチャプター(目次)付きの動画を投稿したことがある方はご存知かと思いますが、このチャプター情報の入力ってすごく面倒ですよね。
詳しい機能や使い方についてはこちらのnoteで書いていますのでよろしければご覧ください。
『YouTubeのチャプター(目次)を簡単に入力できるChrome拡張機能を作りました』技術について
以下のような技術を利用して開発しました。
- Vue 3 / Vuex
- Webpack
- Stylus
- ESlint / Prettier
- Font Awesome
本記事ではVue.jsのChrome拡張開発での利用、拡張機能からYouTubeの動画の操作、その他Chrome拡張機能開発のTipsについて紹介します。
実装
上記について実装の一部を抜粋しながら紹介します。
わかりやすくするために説明に直接関係ない処理を省略したり、関数名や変数名などはシンプルなものにしています。ページ内で埋め込む要素に対してVue.jsを利用する
ツールを埋め込むためにまず空divを挿入して、video要素をpropsで渡して
createApp()
します。
後ほど説明しますが、すでに埋め込まれている動画の操作をするのでYouTube Player APIではなく、video要素を直接操作することになります。まず、ルートに設置するmain.jsです。
main.jsimport { createApp } from 'vue' import App from './App.vue' import store from './store' // 動画プレイヤー領域の要素を取得 const playerContainer = document.getElementsByTagName('ytcp-html5-video-player')[0] // ツールを埋め込むための空要素を挿入 playerContainer.insertAdjacentHTML('afterend', `<div id="app"><App :video="video" /></div>`) // 動画要素を取得 const video = document.getElementsByTagName('video')[0] const app = createApp({ el: '#app', data() { return { video } }, }) app.use(store) app.component('App', App) app.mount('#app')ちなみに
App.vue
はこんな感じ。App.vue<template> <div> <Controller :video="video" /> <div class="chapter-contents"> <ChapterList :video="video" /> <Preview /> <Errors /> </div> <Logo /> </div> </template> <script> import { defineComponent } from 'vue' import Controller from './components/Controller' import ChapterList from './components/ChapterList' import Preview from './components/Preview' import Errors from './components/Errors' import Logo from './components/TheLogo' export default defineComponent({ components: { Controller, Preview, ChapterList, Errors, Logo, }, props: { video: { type: HTMLVideoElement, required: true, }, }, }) </script>Vue.jsから動画要素を操作する
動画の操作部分の抜粋です。
video要素が持つイベント(playing
,pause
,ended
,timeupdate
)を取得して、Vueのstate
を更新します。./components/Controller.vue<template> <!-- 省略 --> </template> <script> import { defineComponent, computed, reactive } from 'vue' import { useStore } from 'vuex' const SEEK_TYPE_BACK = 1 const SEEK_TYPE_FORWARD = 2 export default defineComponent({ props: { video: { type: HTMLVideoElement, required: true, }, }, setup(props) { const store = useStore() const state = reactive({ isPlaying: false, currentTimeSeconds: 0, video: props.video, }) /** * プレイヤーイベントの登録 */ state.video.addEventListener('playing', () => (state.isPlaying = true)) state.video.addEventListener('pause', () => (state.isPlaying = false)) state.video.addEventListener('ended', () => (state.isPlaying = false)) // 経過時間の監視 state.video.addEventListener('timeupdate', () => { const currentTimeSeconds = Math.floor(state.video.currentTime) // 秒数が更新されたらstateの現在時間を更新 if (state.currentTimeSeconds !== currentTimeSeconds) { state.currentTimeSeconds = currentTimeSeconds } }, false) // templateから実行する関数 const play = () => state.video.play() const pause = () => state.video.pause() /** * templateから実行するseekの関数 * @param {Number} seconds * @param {Number} type */ const seek = (seconds, type) => { switch (type) { case SEEK_TYPE_BACK: state.video.currentTime = state.video.currentTime - seconds break case SEEK_TYPE_FORWARD: state.video.currentTime = state.video.currentTime + seconds break } } return { state, play, pause, seek, } }, }) </script>状態管理について
各コンポーネント間をまたいでチャプター情報やエラー情報を扱っているので、Vuexを利用しています。
チャプターの追加/変更/削除、エラーの追加/初期化のactionを持っている感じです。Chrome拡張機能を多言語対応する
chrome.i18nを利用します。
公開ディレクトリの直下に_locales/
を作成し、言語ごとにmessages.json
を用意します。
_locales/ja/messages.json{ "ext_name": { "message": "YouTube Chapter Editor" }, "ext_description": { "message": "YouTubeのチャプター編集用のツールです。動画の詳細ページのプレイヤーに編集ツールが埋め込まれます。" } }manifest.jsonに
default_locale
を指定します。"default_locale": "ja"manifest.jsonからは
__MSG_ext_name__
のように指定することで取得できます。manifest.json{ "name": "__MSG_ext_name__", "description": "__MSG_ext_description__", "default_locale": "ja" }JavaScriptからはこのように取得します。
chrome.i18n.getMessage('ext_name'))JavaScriptからローカルの画像を読み込む
extension_id
を指定する必要があります。
多言語対応でchrome.i18nを利用している場合は、以下のようにchrome.i18n.getMessage('@@extension_id')
でIDが取得できます。./components/Logo.vue<template> <div class="logo"> <span class="logo__image" :style="logoStyle"></span> <span class="logo__text">YouTube Chapter Editor</span> </div> </template> <script> import { defineComponent } from 'vue' export default defineComponent({ setup() { const logoStyle = { backgroundImage: `url('chrome-extension://${chrome.i18n.getMessage('@@extension_id')}/icons/48.png')`, } return { logoStyle, } }, }) </script>上記以外だと、CSS側で
chrome-extension://__MSG_@@extension_id__
と指定する方法もあります。拡張機能のアイコンをクリックしてContents Script内の関数を実行する
今回の拡張機能は、対象ページに直接アクセスした場合やリロード時に自動でツールが起動するのですが、YouTubeはSPAなので他のページから遷移してきた場合はアイコンをクリックしてツールを起動する必要があります。
manifest.jsonで
background
として指定しているファイルに以下のような処理を書きます。
content_scripts/bundle.js
はVueの実装などが入ったこの拡張機能のメインのスクリプトです。
アイコンがクリックされたらisIconClicked
のフラグを立てます。background.jschrome.pageAction.onClicked.addListener(tab => { chrome.tabs.executeScript( tab.id, { code: 'isIconClicked = true;', }, () => { chrome.tabs.executeScript(null, { file: 'content_scripts/bundle.js' }) }, ) })Contents Scriptのmain.jsで
isIconClicked
を判定して実行します。main.jsif (typeof isIconClicked !== 'undefined') { if (isIconClicked) { // アイコンがクリックされたら実行する処理 } }他に良い方法があればコメントいただけると嬉しいです。
開発時のホットリロード(自動再読み込み)
hot-reload.jsを利用します。
hot-reload.jsを任意の場所に設置し、manifest.jsonで以下のように設定します。"background": { "scripts": [ "hot-reload.js" ] }ファイルの変更を検知して、開発中にアクティブなタブが自動再読み込みされるようになります。
最後に
ツール自体は比較的シンプルなものなので特に実装で困ることはなかったのですが、VueでChrome拡張開発する方法や、Contents Scriptの動作についてはつまづくことも多かったです。今回は私が特に困った部分を盛り込んだ記事となりましたが、お役に立てれば幸いです。
あと、何よりこの拡張機能はYouTubeで動画を投稿されている方にはぜひ使っていただきたいです。私自身がもうこのツールなしではチャプター作成できないくらい依存しています。。
『YouTube Chapter Editor』普段やっているPodcastのYouTube版で今回のツールの紹介をしてみました。技術の話をする番組ではないのですが、ご興味あればこちらもよろしくお願いします。(Spotify / Apple Podcast / Google Podcasts)