この記事について
個人的にWebブラウザで動作するアプリを作成しましたので、その開発の記録を書きたいと思います。
この記事の結論として3つ記載している内容をはじめに読みたい方は今回の開発を通して大切だと思ったことをご覧ください。
対象読者
- これから個人開発で何かアプリを作りたい方
- 個人開発における開発の進め方や技術選定などの方法について情報収集したい方
- 0からソフトウェア開発を行う方
今回作成したブラウザアプリ
- 名前:F1C(Fukui's No.1 Company = 福井のNo.1企業共有アプリ)
- 概要:福井に拠点のあるNo.1企業を共有し、また他ユーザの共有内容を閲覧できるサービス
- アプリURL:https://f1c.jp.net/home
- ソースURL:https://github.com/shin4488/no1-company-share
- 開発期間:約1.5か月
このアプリを作成した背景
- 私自身、福井での転職を考えており企業を探しておりましたが、「そもそも福井にはどのような企業があり、それぞれの企業がどのような特徴を持っているのか」といった情報を得るのが大変だと感じたことがこのアプリを作成したきっかけです
- 「自分は把握できていないが、優良な企業」はたくさんあると思いますので、より多くの人が(就職以外の場面でも)このアプリを使用して「こんな素晴らしい企業があるのか!」など福井の企業に対して興味を持つきっかけとなれば、という思いでこのアプリを作成しました
- 「良い企業」の定義を客観的に分かるようにするため「何らかの分野でNo.1である企業」を共有してもらう形としました
- ユーザ自身が情報を得るだけでなく、共有された企業に対する注目度が上がると企業にとってもプラスになり、さらにより多くの福井の企業が注目されると福井への注目度も上がって地域活性化につながると考え、個人・企業・地域がWin-Winの関係を作れるようなアプリを目指して作成しました
開発の流れ
以下の順番でアプリの作成を進めていきました。
この記事では、以下の順番でそれぞれ何を考えて開発を進めたのかを記載していきます。
(アイディア出しの「アプリ概要まとめ」は、上記「今回作成したブラウザアプリ」の「概要」・「このアプリを作成した背景」の通りです。)
- アイディア出し
- アプリ概要まとめ
- 機能一覧
- 設計
- 画面イメージ
- DB論理設計
- 技術選定
- WebAPIデータ構造
- サーバサイドアーキテクチャ
- プログラミング
- サーバサイドの共通部分
- フロントエンドの共通部分
- フロントエンドの各機能
- サーバサイドの各機能
- テスト
- リリース準備
- 本番環境のサーバ設定(インストールなど)
- ドメイン取得・ドメインを名前解決できるように設定
- SSL証明書の発行
- データベース(PostgreSQL)のクラスタ(≒インスタンス)作成
機能一覧
- ログイン(Googleアカウント)
- 新規投稿作成
- 企業検索
- No.1内容の追加・削除
- 企業ホームページURL、画像取得
- 既存投稿編集
- 投稿(論理)削除
- 投稿閲覧
- 全投稿
- お気に入りのみ
- 自分の投稿のみ
- お気に入り追加/お気に入り削除
- 通報
SNSとしての最低限の機能を付けて「早めに初回リリースできるように」意識しました。
ログインに関しては、このアプリ専用のアカウントを作成するのではなく、手軽さを考えてGoogleアカウントでログインできるようにしました。
また、誤った情報を含む投稿やいたずらの投稿があった際には、他ユーザの投稿に対しても非表示にできるように通報の機能もつけました。
設計
画面イメージ
設計段階では、リリース機能のイメージがつきやすいように大枠のみを決めて図に落とし込みました。(結構ざっくりと作成してます...。)
色や細かい文言などは実装して画面を見ていく中で適宜変更していこうと考えて大枠の画面イメージを作成しました。
今回はFigmaを使用して、画面イメージを作成しました。
未ログイン時の投稿閲覧画面
このアプリでは投稿をお気に入り登録することができる、ということが分かるように(実際にお気に入り追加できるのはログイン後ですが)未ログイン時でもお気に入りアイコン(♡)を表示するようにしました。
ログイン時の投稿閲覧画面
「お気に入り」「マイポスト」(自分の投稿のみ閲覧する画面)に関しては、画面自体は「ホーム」(全投稿閲覧する画面)と同じ構成を想定して作成しました。
投稿作成・編集画面
最低限、何のデータが必要なのかを把握できるように入力ボックスのみを書いてみました。
企業名の入力に関しては、法人検索のWebAPIを使用してデータ入力したかったため(企業を選択する形)、単なるテキストの入力ボックスではなく、検索用の選択式の入力ボックスとしました。
DB論理設計
エクセルを使用して、具体的にどのようなデータが入るのかといった想定をして、論理設計を行いました。(DBはRDBを使用)
画面イメージ側は実装段階での変更が容易なのですが、DB側は実装段階での変更は影響範囲が大きくなる傾向があるため、できるだけ後工程での変更がないような設計を意識しました。
1企業に対して複数のNo.1内容をつけられるようにしております。(投稿:投稿詳細=1:n)
また将来的な機能として、一度削除した投稿の復元や、通報された投稿の修正ができるように、投稿テーブルに対して「削除済み」「通報済み」のカラムを作成しております。(論理削除として、物理削除とはしていない)
技術選定・選定基準/理由
0からのソフトウェア開発では、まず形あるものを素早く作りリリースしてユーザに触ってもらうことを意識していたため、フロントエンド・サーバサイドともに「技術調査時の情報量が多いこと」「学習コストが低いこと」はどの技術選定においても重視しております。
フロントエンド
内容 | 使用したもの | 採用理由 |
---|---|---|
フレームワーク | Vue.js | 学習コストが低いため Nuxt.jsを使用したかったため |
Server Side Rendering | Nuxt.js | (サーバサイドレンダリングを使用したい理由は以下に記載) 同じくサーバサイドレンダリングが可能なNext.jsと比べて、開発環境構築までのハードルが低く(必要なモジュールなどはプロジェクト作成時に入った状態で開発できる)より素早い開発が可能と考えたため |
UIフレームワーク | Vuetify.js | 学習コストが低く、UI構築が容易であるため |
型付け | TypeScript | 型安全によるプログラムの保守性を高めたいため |
ログイン | Firebase Authentication | 手軽なログイン機能(Googleアカウントによるログイン)を実現したいため |
サーバサイドレンダリングを使用したかった理由…PCスペックの高くないユーザも想定しており、ページ初期表示時(Vue.jsで言うcreatedやmounted)でのブラウザからのサーバ処理呼び出しを避けて、出来るだけブラウザの負荷を下げて「使用端末によってはアプリのパフォーマンスが著しく下がる」というようなことを避けたかったため
サーバサイド
内容 | 使用したもの | 採用理由 |
---|---|---|
実行環境 | Node.js | Nuxt.jsでExpress.js(Node.jsのWebフレームワーク)の拡張が可能であるため フロントとサーバのプログラミング言語をそろえることで、より低コストで迅速に開発できると考えたため |
Webフレームワーク | Express.js | Nuxt.jsのリファレンスにて、「Express.jsの拡張が可能」といった旨の記述があり、フロントエンド・サーバサイドの両方を一度にビルドが可能となり、開発効率が上がると考えたため |
型付け | TypeScript | 型安全によるプログラムの保守性を高めたいため |
認証 | Firebase Admin | フロントエンドでFirebase Authenticationを使用しており、Firebaseのサーバサイドでの認証を行うため |
ORM | Sequelize | 「Node.jsのオープンソースライブラリの選定基準」を参照 |
DI | Inversify | 「Node.jsのオープンソースライブラリの選定基準」を参照 |
ロギング | Log4js | 「Node.jsのオープンソースライブラリの選定基準」を参照 |
入力値バリデーション | express-validator | 「Node.jsのオープンソースライブラリの選定基準」を参照 |
企業検索 | gBizINFO | 非上場企業を含めて企業検索可能なサービスであるため (他に同様の機能を持った無料サービスを見つけられなかった...) |
Node.jsのオープンソースライブラリの選定基準
- 直近のバージョンアップが1年以内に行われていること
→不具合発生時などのサポート・改修が迅速に行われる - Githubリポジトリにおけるスター数が1000を超えていること
→より多くの開発者がそのライブラリを使用して、本アプリの開発への参加が他の開発者にとって容易で、また技術調査時の情報量が豊富である - 複数ライブラリが上記の基準を満たす際は、以下をの観点で選定
- 技術調査時の情報量が多い
- OSSの活動がより活発である
- 実際にライブラリを自分でも使用してみて使いやすい(記述量少なく実装可能、実行時の処理パフォーマンスが良い、スケールしやすいなど)
インフラ
内容 | 使用したもの | 採用理由 |
---|---|---|
サーバ(本番環境) | さくらVPS | レンタルサーバでは他ユーザの影響を受けがち AWSなどパブリッククラウドでは従量課金制のため知らぬ間に高額な使用料がかかってしまいがち...であるため(消去法...) |
OS | Ubuntu | |
Webサーバ | Nginx | |
データベース | PostgreSQL | 個人的に使用したことがなく、使ってみたかったため... |
開発環境の作成 | Docker, Docker Compose | OSレベルでの仮想化は不要(アプリケーションレベルの仮想化のみでOK)だと考えたため 使用したことのある開発者が多そう(このプロジェクトへの参加障壁が低くなる)であるため |
WebAPIデータ構造
共通レスポンスボディ
以下の理由により、共通レスポンスボディを作成しました。
- レスポンスの形を共通化しておくことでAPI呼び出し側の共通化を可能とする(レスポンスが配列やオブジェクトなどAPIによって変わると共通化が困難になってしまう)
- エラーメッセージが存在する際は、エラーメッセージでひとまとめにする(API呼び出し側のエラーハンドリングの共通化も可能とする)
レスポンスエラーメッセージ・レスポンスデータの2つに大きく分けてレスポンスを作成しました。
{
// 複数メッセージを返却可能とする
"messages": [
{
"message": ""
}
],
"data": "この中身のオブジェクトは各API側で定義する"
}
各機能のリクエスト
分かりやすさ重視のため、RESTful APIの考えを採用しており、Httpメソッドは操作指向で定義し、URIはリソース指向で定義しました。
例:投稿に対する操作
内容 | Httpメソッド | URI | 備考 |
---|---|---|---|
投稿閲覧 | GET | /api/v1/shared-posts/<投稿ID> | 投稿ID未指定時は複数件取得 |
新規投稿作成 | POST | /api/v1/shared-posts/ | |
既存投稿更新 | PUT | /api/v1/shared-posts/<投稿ID> | 投稿ID未指定時は複数件更新 |
投稿削除 | DELETE | /api/v1/shared-posts/<投稿ID> | 投稿ID未指定時は複数件削除 |
通報 | POST | /api/v1/reported-shared-posts/ |
各機能の仕様はこちらを参照
サーバサイドアーキテクチャ
フロントエンドはNuxt.jsを使用していることもあり、フレームワーク決定時点である程度アーキテクチャが決まるのですが、サーバサイドは自分自身でアーキテクチャを決めていくことになるため、この章では採用したサーバサイドアーキテクチャ(主にクラス設計)について説明します。
以下の画像が大まかなアーキテクチャ(クラス設計)です。
エンドポイントの処理で扱うデータについては、原則、クラスごとに入力値用のインターフェース(Request
/Parameter
/ParameterDto
)・返却値用のインターフェース(Response
/Result
/ResultDto
)を定義しております。
- Middlewareの処理では以下を用意しました。
- リクエストログ出力
- 認証
- SimpleValidator(単項目入力値バリデーション...処理内容は機能ごとに異なる)
- SimpleValidatorのエラーハンドリング
- エンドポイントの処理のエラーハンドリング
- 各機能エンドポイントの処理では、原則、以下のようにプログラムの役割を分担しました。
- Controllerクラス(クライアントのリクエスト受信・Serviceクラスが受け取るパラメータデータへのデータ詰め替え)
- Serviceクラス(各業務処理呼び出し)
- ComplexValidatorクラス(複数項目間やデータベースアクセスを伴う入力値バリデーション)
- Logicクラス(業務処理実行)
- Daoクラス(データベースアクセス処理(SELECTやINSERT・UPDATE))
プログラミング
開発環境の準備(Docker)
今回、開発環境の作成にDockerを使用することとしたため、 docker-compose.yml
や Dockerfile
を作成します。
WebサーバとしてはNginxを使用するため、以下3つに分けてコンテナ管理を行います。
- web...Nginxサーバ用のコンテナ
- application...Nuxt.jsサーバ用のコンテナ
- database...PostgreSQLサーバ用のコンテナ
コンテナ間の通信は各コンテナでポートをexposeせずにアクセスできるため、exposeするポートはwebのみとしております。
version: "3.8"
services:
web:
build: ./web
ports:
- 10000:80
tty: true
volumes:
- ./web/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- application
application:
build: ./application
tty: true
volumes:
- ./application:/home/app/companyshare
- ./application/node_modules:/home/app/companyshare/node_modules
- ./application/.nuxt:/home/app/companyshare/.nuxt
depends_on:
- database
database:
build: ./database
env_file:
- ./database/.env
tty: true
volumes:
# ./database/dataにデータが存在しない場合に実行されるスクリプト(テーブル作成や初期データ作成などを行う)
- ./database/init:/docker-entrypoint-initdb.d
# データ永続化のため
- ./database/data:/var/lib/postgresql/data
ディレクトリ構成としては以下となっております。
project
├web
| ├nginx.conf
| └Dockerfile
├application
| └Dockerfile
├database
| └Dockerfile
└docker-compose.yml
webコンテナのnginx.confは以下のように記載しております。
user www-data;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
server_tokens off;
upstream nodeapp {
server application:3100;
}
server {
listen 80 default_server;
location / {
proxy_pass http://nodeapp;
}
}
}
upstream nodeapp
内の application
は docker-compose.yml
で記載したapplicationコンテナを示しております。
(applicationコンテナの3100ポートでNuxt.jsを起動している)
upstream nodeapp
と proxy_pass http://nodeapp;
によって、Nginxに来たリクエストをNuxt.jsが起動しているコンテナに対してリクエストを送信しております。
サーバサイドの共通部分
サーバサイドでいくつか共通処理などを作成しましたが、その中でも特に注意した処理や力を入れた点について記載します。
ディレクトリ分け
サーバサイドのディレクトリは大きく3つに分けました。
server
├common
│ ├共通機能1
│ └共通機能2
├commonBL
│ ├共通処理1
│ └共通処理2
└feature
├個別機能1
└個別機能2
-
common
...このアプリ以外でも使用できるような機能/処理を扱うディレクトリ(ロギングや認証、エラーハンドリング、DI関連の処理など) -
commonBL
...このアプリの共通的な処理を扱うディレクトリ(主にデータベースアクセス処理や共通的な業務処理を扱う) -
feature
...このアプリの個別機能を扱うディレクトリ
エイリアス設定
こちらやこちらのIssuesように、Nuxt.jsのserverMiddleware側ではエイリアスが効かないようなので、module-alias
というライブラリを使用してserverMiddleware側でもエイリアスを効かせられるようにしました。
内容は【Nuxt.js】serverMiddlewareでエイリアスを効かせてimportするの記事にまとめました。
ロギング(Log4js)
Log4js
を使用します。
yarn add log4js
以下3つのログをファイル分けして出力するようにしました。
- システムログ...Httpリクエストの内容や発行したSQLなどアプリのシステム上で扱うデータの出力
- アクセスログ...クライアントからサーバへのアクセスデータの出力
- エラーログ...サーバ処理内でのエラー発生時のログ
Log4jsのログ設定ファイルの記述
{
"appenders": {
"systemLog": {
"type": "dateFile",
"filename": "./logs/system.log",
"pattern": "yyyy-MM-dd",
"compress": true,
"maxLogSize": 3000000,
"backups": 5
},
"errorLog": {
"type": "dateFile",
"filename": "./logs/error.log",
"pattern": "yyyy-MM-dd",
"compress": true,
"maxLogSize": 3000000,
"backups": 5
},
"accessLog": {
"type": "dateFile",
"filename": "./logs/access.log",
"pattern": ".yyyy-MM-dd",
"compress": true,
"maxLogSize": 3000000,
"backups": 5
}
},
"categories": {
"default": {
"appenders": ["systemLog", "errorLog"],
"level": "all"
},
"system": {
"appenders": ["systemLog"],
"level": "info"
},
"error": {
"appenders": ["errorLog"],
"level": "error"
},
"access": {
"appenders": ["accessLog"],
"level": "info"
}
}
}
Sequelizeで発行したSQLをログ出力する記述
import * as path from 'path';
import { getLogger, configure } from 'log4js';
import { Sequelize } from 'sequelize';
const configPath = path.resolve(__dirname, 'config.json');
configure(configPath);
const systemLogger = getLogger('system');
new Sequelize(
process.env.POSTGRES_DATABASE,
process.env.POSTGRES_USER_NAME,
process.env.POSTGRES_PASSWORD,
{
host: process.env.POSTGRES_HOST_NAME,
port: Number(process.env.POSTGRES_PORT),
dialect: 'postgres',
// この記述によって、発行されたSQLをログ出力することが可能
logging: (logContent) => systemLogger.log('info', logContent),
},
);
エラーハンドリングMiddleware(Express.js)
Express.jsのMiddlewareの1つにエラーハンドリングMiddlewareが標準機能でついているため、その機能を使用してエラー発生時にエラー内容に応じてHttpレスポンスを返却するようにしました。
import express from 'express';
import { AppError } from '@s/common/error/appError';
import { appContainer } from '@s/common/dependencyInjection/inversify.config';
import { types } from '@s/common/dependencyInjection/types';
import { LogHandler } from '@s/common/logger/interface/LogHandler';
import { NotAuthorizedError } from '@s/common/error/notAuthorizedError';
import { RecordNotFoundError } from '@s/common/error/recordNotFoundError';
// 第1引数に「error: Error」を受け付けると、そのMiddlewareはエラーハンドリング用のMiddlewareとみなされる
export const catchError = (
error: Error,
_request: express.Request,
response: express.Response,
_next: express.NextFunction,
) => {
// エラーはエラーログファイルに出力(今回の実装ではログ出力クラスをDIコンテナから取得している)
const logger = appContainer.get<LogHandler>(types.LogHandler);
logger.error(error);
// 発生したエラーの種類に応じてステータスコードを切り替え
const appErrorInstance = error instanceof AppError;
const statusCode =
error instanceof NotAuthorizedError
? 401
: error instanceof RecordNotFoundError
? 404
appErrorInstance
? 400
: 500;
const expressResponse = response.status(statusCode);
// エラー用のAPIレスポンス作成・ステータスコードのセット
};
エラー定義
/**
* このアプリの基底となる例外クラス
* 新しい例外クラスを作成する際は、このクラスを継承してください
*/
export class AppError extends Error {
private messages: string[];
get errorMessages(): string[] {
return this.messages;
}
constructor(...messages: string[]) {
super();
this.messages = messages;
this.name = new.target.name;
// 下記の行はTypeScriptの出力ターゲットがES2015より古い場合(ES3, ES5)のみ必要
Object.setPrototypeOf(this, new.target.prototype);
}
}
/**
* リクエストパラメータが不正である時の例外
*/
export class BadParameterError extends AppError {
constructor(message?: string[]) {
const errorMessage = message || 'データが存在しません。';
super(errorMessage);
}
}
/**
* データベースにレコードが存在しない時の例外
*/
export class NotAuthorizedError extends AppError {
constructor() {
// エラーメッセージは固定
const errorMessage = 'ログインしてください。';
super(errorMessage);
}
}
/**
* データベースにレコードが存在しない時の例外
*/
export class RecordNotFoundError extends AppError {
constructor(message?: string) {
const errorMessage = message || 'データが存在しません。';
super(errorMessage);
}
}
サーバサイドのエントリポイントでエラーハンドリング処理をMiddlewareに登録
※例外発生キャッチ用のミドルウェアはルーティング後に追加する必要があります。(参照:「エラー処理を記述する」)
import express from 'express';
import { catchError } from '@s/common/middleware/appErrorHandler';
const app = express();
// ルーティング追加
app.use(catchError);
以下のように処理中にエラーを発生させると、エラーハンドリングMiddlewareの処理が実行されます。
app.get('/error-sample/', (_request: express.Request, _response: express.Response) => {
throw new RecordNotFoundError('データなし'); // ステータスコード404のレスポンスが返却される
});
システム日付取得用クラス
「今日の日付」などは実行日によって値が変わってしまいますが、テスト時は固定値を使用したいと思いますので、システム日付取得用クラスを作成してそのクラスをDIするようにしました。
(テスト時は別のシステム日付取得用クラスを用意して、固定値を返却する想定)
(DIはInversifyを使用している想定、Inversify部分の記述は省略箇所あり)
import { injectable } from 'inversify';
import { DateHandler } from './interface/dateHandler';
@injectable()
export class DateHandlerImpl implements DateHandler {
getCurrentDate(): Date {
return new Date();
}
}
export interface DateHandler {
getCurrentDate(): Date;
}
import { inject } from 'inversify';
class Sample {
private dateHandler: DateHandler;
constructor(
@inject('DateHandler') dateHandler: DateHandler
) {
this.dateHandler = dateHandler;
}
exexute() {
const currentDate = this.dateHandler.getCurrentDate();
console.log(currentDate); // new Date()の実行結果
}
}
フロントエンドの共通部分
Middleware内でのルーティング
Middleware内でログイン状態のチェックを行い、ログイン時はそのまま指定されたページを表示・未ログイン時はホーム/使い方のページのみを表示するように制御しました。
import { Context, Middleware } from '@nuxt/types';
import { StringUtil } from '@c/util/stringUtil';
const middlware: Middleware = async ({ redirect, route, $accessor }: Context) => {
const routePath = route.path;
const loginPath = '/login';
const logoutPath = '/logout';
// ルートディレクトリ・ログイン状態変更時はホーム画面に遷移
const homeRedirectPaths = ['/', loginPath, logoutPath];
const shouldRouteToHome = homeRedirectPaths.includes(route.path);
if (shouldRouteToHome) {
redirect('/home');
return;
}
// 未ログイン時は「ホーム」「使い方」以外の時はリダイレクト
const firebaseLoginUserId = $accessor.firebaseAuthorization.userIdComputed;
const notNeedRedirectPaths = ['/home', '/usage'];
const isNeedRedirect = !notNeedRedirectPaths.includes(route.path);
if (StringUtil.isEmpty(firebaseLoginUserId) && isNeedRedirect) {
redirect('/home');
}
};
export default middlware;
ログイン状態をstoreで管理しているため、Middlewareからstoreに型情報を維持したままアクセスできるように以下のようにtype登録しております。
この記載によってMiddlewareの Context
で $accessor
を使用可能となり、storeにアクセスできます。
import { accessorType } from '@f/store';
declare module '@nuxt/types' {
interface Context {
$accessor: typeof accessorType;
}
}
export default {
router: {
middleware: ['router'],
},
};
axiosのリクエスト送信・レスポンス受信の共通化
どのAPI通信に関しても、リクエストヘッダにfirebaseでログインしたユーザのトークンを付与を行い、またレスポンス内にエラーメッセージが存在する際はメッセージを画面上に表示したかったため、以下記事のようにpluginsを作成してaxiosのリクエスト送信・レスポンス受信の共通化を行いました。
【Nuxt.js】axiosのリクエスト送信/レスポンス受信の共通化を行う
storeを使用したsnackbarの起動
APIレスポンスエラー時などはpluginsのTypeScriptプログラムからエラーメッセージを表示することもあるため、Vueファイルからsnackbarを起動するだけでなく、TypeScriptからもsnackbarを起動できるようにstoreを使用してsnackbarのグローバル化を行いました。
具体的な実装は以下の記事に記載しました。
【Vue.js, Nuxt.js】storeを使用してTypeScript側からsnackbarを起動させる
storeを使用したprogressbarの起動
同様に特定の処理中にのみprogressbarを表示するような実装も行いました。
【Vue.js, Nuxt.js】storeを使用してTypeScriptの処理中にprogressbarを起動させる(progressbarのグローバル化)
firebaseでのログイン処理、SSRでのユーザ情報取得
firebaseでログイン後に画面リロードした際、middlewareやasyncData、fetchなどでログインユーザのユーザIDが取得できないという事象があり、はまってしまったため色々調査しました。
結論は 【Nuxt.js】SSRでFirebase Authを使用する の記事へ記載しました。
タイトルの動的生成
今回、「ホーム」「お気に入り」「マイ投稿」の3つのページを用意しましたので、それぞれのページごとにtitle属性の値を切り替えられるように実装しました。
具体的な実装は以下の記事に記載しました。
【Nuxt.js】titleなどHTMLのheadの内容を動的に設定してみる
フロントエンドの各機能
投稿表示用カード(投稿内容が長いとき問題)
各投稿の表示はVuetifyの v-card
で実装しております。
v-card
内では v-card-title
・v-card-text
・v-card-actions
を使用しておりますが、投稿内容によっては画像のように v-card-text
部分がほかの投稿よりもかなり縦長になることがあります。
そこで、v-card-text
に対して max-height
・overflow-y-auto
(Vuetify)をつけて一定以上の長さになればスクロールするように実装しました。
投稿表示用カードのレスポンシブ対応
投稿を表示するカードですが、「大型モニターの場合は4列」「PCの場合は3列」「スマホの場合は1列のみ」というように切り替えられえるように実装しました。
ただはじめ、Grid systemに記載されている xs
が指定可能なものと思い以下のように記述しましたが、うまく画面できませんでした。
(うまく動作しなかった書き方)
<v-row>
<!-- valuesは投稿を複数保持する配列 -->
<v-col
v-for="(item, index) in values"
:key="index"
xl="3"
md="4"
sm="6"
xs="12"
>
<!-- SharedPostCardは1投稿を表示するコンポーネント -->
<SharedPostCard />
</v-col>
</v-row>
xs
で v-col
指定するには cols
を使用するようで、以下のように記述するとうまく動きました。
「xl」「lg」「md」「sm」「それ以外(cols)」と指定するようです。
参考:Vuetify(v2.0.0 )でv-colのxsを設定する
(うまく動作した書き方)
<v-row>
<!-- xsは指定できないため、cols指定となる -->
<v-col
v-for="(item, index) in values"
:key="index"
xl="3"
md="4"
sm="6"
cols="12"
>
<SharedPostCard />
</v-col>
</v-row>
サーバサイドの各機能
また随時内容を記載したいと思います。
テスト
今回は画面上で動作させて確認するブラックボックステストの形をとりました...。
リリース準備
サーバ情報
$ cat /etc/os-release
NAME="Ubuntu"
VERSION="20.04.1 LTS (Focal Fossa)"
PRETTY_NAME="Ubuntu 20.04.1 LTS"
VERSION_ID="20.04"
$ certbot --version
certbot 0.40.0
$ nginx -v
nginx version: nginx/1.18.0 (Ubuntu)
$ node -v
v16.15.0
$ yarn -v
1.22.18
$ psql --version
psql (PostgreSQL) 12.11 (Ubuntu 12.11-0ubuntu0.20.04.1)
本番環境のサーバ設定(インストールなど)
今回使用する以下をインストールしました。
- Node.js(LTS)
- yarn
- PostgreSQL(Ubuntu 20.04にPostgreSQLをインストールする方法 [クイックスタート])
$ sudo curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
$ sudo apt install -y nodejs
$ sudo npm install -g yarn
$ sudo apt install postgresql postgresql-contrib
postgresユーザでデフォルトのデータベースの存在確認
$ sudo -i -u postgres
postgres=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+----------+----------+---------+---------+-----------------------
postgres | postgres | UTF8 | C.UTF-8 | C.UTF-8 |
template0 | postgres | UTF8 | C.UTF-8 | C.UTF-8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | C.UTF-8 | C.UTF-8 | =c/postgres +
| | | | | postgres=CTc/postgres
(3 rows)
ドメイン取得・ドメインを名前解決できるように設定
今回、ムームードメインでドメインを取得し、さくらVPSのネームサーバと紐づけを行いました。
設定内容は ムームードメインで取得したドメインをさくらVPSのサーバで使用する の記事に記載しました。
SSL証明書の発行
取得した f1c.jp.net
のドメインでSSL通信を可能とするためにLet's EncryptでSSL証明書を発行しました。
具体的な設定内容は以下の記事に記載しました。
【Ubuntu+Nginx】Let's EncryptでSSL証明書を発行してhttps通信を行う
データベース(PostgreSQL)のクラスタ(≒インスタンス)作成
今回使用しているサーバ上のPostgreSQLで複数サービスを使用できるように、PostgreSQLをクラスタ分けして管理しようと考えました。
ですが、クラスタ使用時にはまってしまいかなり時間を取られました...。
対処法を以下の記事にまとめております。
【Ubuntu+PostgreSQL】postgres以外のユーザでクラスタを作成して起動する
Nuxt.jsビルド生成物の起動
サーバ上でプログラムを永続的に起動させたかったため、foreverを使用しました。
参考:Nuxt.jsのアプリケーションをinitd-foreverを使ってデーモン化、自動起動する
(-c
は「コマンド実行」の意味)
$ sudo npm install -g forever
$ cd Nuxt.jsプロジェクトのディレクトリ
$ forever start -c "yarn start" ./
バージョンアップやメンテナンスなどでいったんNuxt.jsサーバを停止したい場合は以下のように停止できます。
$ forever stop -c "yarn start" ./
開発中に気を付けたこと、やってよかったこと
今後、ほかの方の個人開発にもこの記事が役立てられればと思いますので、今回の開発で行ったことやその中でよかったことを記載したいと思います。
開発環境
- フロントエンド・サーバサイドのホットリロードの導入
ソースコードの変更時にいちいちサーバ再起動は行いたくないため、開発効率を上げるためにも、フロントエンド・サーバサイドの両方をホットリロード対応させたことはかなり良かったです。
DockerやNginxを使用せずにNuxt.jsのみで開発を行う場合にはホットリロードが使用できると思いますが、DockerやNginxを使用した際にホットリロードが効かなくなることがありましたが、 この記事のようにnuxt.config.js
や環境変数に記述を追加したことろうまくホットリロードできました。
フロントエンド
- 1イベントハンドラ内でのサーバサイド呼び出しは原則1回のみ
以下理由のため(例外はありますが)基本的に1イベント1通信に抑えました。- サーバサイド呼び出しを複数回に分けると、Middleware処理などオーバーヘッドが発生してパフォーマンス低下につながる
- 処理失敗時に初めのサーバサイド処理のロールバックが困難
- 画面の動き・色はアプリ全体で統一させる
ユーザが迷わず画面操作できるように、同じものを表す画面・部品は同じ動き・色となるよう意識しました。 - フロントエンドに入力チェックがあるとユーザフレンドリーな画面になる
サーバ処理を伴わずにエラー通知ができるため、即時通知によってユーザがストレスなく画面操作できるかなと思います。
【注意】ブラウザ上でのJavaScriptソース書き換えや、パケットキャプチャツールによりブラウザ外からのサーバ処理実行が可能であるため、セキュリティの担保にはならない点に注意が必要です。(サーバサイドでも同様のチェック処理が必要です。)
サーバサイド
- ログファイルを意味ごと(アクセスログ・システムログ・エラーログなど)に分割する
システムログはリクエスト内容やSQLの出力として使用していたためすぐに大量のログが出力されますが、不具合調査時にはできるだけ問題の発見・切り分けを早く進めたかったため上記のように3つに分けました。1つにまとめてしまうとログ調査の時間もかかってしまうと思いますので、ある程度は分けるようにするのがおすすめです。 - Postmanなど非ブラウザからもリクエストが送信され、またブラウザ上ではJavaScriptソースが変更されることを想定する(フロントエンドで入力チェックしていてもサーバサイドでの入力チェックも必要)
本来の使い方としてはブラウザからアクセスされるアプリでも、プログラムをサーバ上にアップしている場合はパケットキャプチャツールなどでのアクセスも可能となります。またブラウザ上からでもHTMLやJavaScriptのソース書き換えが可能となり本来の使い方を超えたリクエストが飛ばされる場合があります。その場合データベースに登録されていないデータを使用して処理を実行しようとすることもありますので、マスタテーブルにレコードが存在することのチェックなどが必要になります。 - クライアントから送信されるリクエストデータは、
null
/undefined
になりえる
2つ目の内容と関連しますが、どのようなリクエスト値が送られてくるか分からない分、各リクエストデータはnull
やundefined
を許容するように実装しました。
(実際にはexpress-validator
によって必須項目に対してnull
やundefined
の場合はエラーとするバリデーションを入れました。)
(各リクエストデータがnull
やundefined
の場合に、500台としてレスポンスを返すのではなくパラメータが不正であることを示す400台としてレスポンスを返すなど。)
その他
- 共通化は意味ごとに行う
意味の異なる処理・画面は、要件が変わると別の処理・別の画面内容になりやすいため、たまたま同じ処理・同じ画面内容のものはできるだけ共通化しないことを意識しました。
参考:Twitter - クソコード動画「共通化の罠」 - 技術面でつまずいたときに行ったこと
- StackOverflowへの質問
- 「ライブラリ側の不具合?」と思ったら、使用しているライブラリのGitHubリポジトリ上でのIssuesを確認/起票
(たとえ不具合でない場合でも誰かが見て解決方法を教えてくれるかも)
今回の開発では、以下のようにIssuesに投稿してみました。
今後の機能追加・改善したい点
今回作成したアプリの初回リリースではできなかった点・次回以降のリリースで行いたいことを記載します。
機能面
追加したい機能はGitHubのIssuesにも記載しております。
- 企業の住所をもとに、投稿一覧や企業一覧を地図上に表示する(現在は、カード型で文字ベースで表示するのみ)
- 企業名による投稿検索(「同じ企業に対しては投稿不可であるが、投稿したいと考えている企業に既存投稿が存在するかをチェックしたい」などが考えられるため)
- バッチ処理での定期的な企業ホームページ画像の取得・保存によるパフォーマンス改善(現在は投稿データ取得時のサーバ処理内でog:imageを取得しているが、そのせいでパフォーマンスが悪くなっているため投稿データ取得時の画像取得は廃止したい)
- LINEやTwitterなどのSNSアプリへの投稿内容の共有
プログラム面
- Nuxt.jsを使用時のプロパティデコレータの使用(この課題がある状態ではあるが...)
- class-validatorの使用(デコレータベースの入力値チェック→express-validatorだとプロパティ名のタイプミスに気付かないため)
- class-transformerの使用…API層→ビジネスロジック層などへのデータ詰め替え(自前でのRequestデータ→Parameterデータの詰め型処理は煩雑になりやすいため)
- Controllerクラスについての変更
- デコレータベースのHttpメソッド指定、エンドポイント指定
- ルーティング登録を個別クラス側で完結できるようにする(スケールできるようにするため)
(現在はこちらのように一括で登録している)
- DIコンテナ(Inversify)への登録を個別クラス側で完結できるようにする(スケールできるようにするため)
(現在はこちらのように一括で登録している)
今回の開発を通して大切だと思ったこと
最後に、今回の個人開発で大切だと思ったことを記載したいと思います。
- モチベーションの維持
- あまりにも開発開始~リリースまでが長くなってしまうとモチベーションの維持が難しくなって途中でやめてしまうことになりかねないため、初回リリースは可能な限り開発開始~リリースまでを短くすると良さそう(個人的には、リリースに含める機能は開発開始~リリースまでが1か月以内に収まるくらいを目安にすると良いと感じました)
- 最小限の機能をつけてとりあえずリリースしてみる
私自身、1か月たったくらいから徐々にモチベーションが下がってしまうことがあったのではじめは最低限の機能のみを実装してリリースするくらいの気持ちでよいのかなと思います。
- 作っているものの方向性が正しいこと(ユーザへのウケが良いこの)の確認
- 各機能作成段階でテスト的に第3者に開発環境でアプリを触ってもらう
- 0からの開発の場合は特に開発スピードを気にして、早めにユーザに使ってもらうことを第一に考える(次回以降のリリースで、どのような機能をつけるかの指標になるため)
私自身の反省でもあるのですが、今回の開発では最後作り終わってからほかの人に見てもらう形となりました。その見てもらった際に追加要望を受けたので、できることなら実装が始まる前や実装中に「今作ろうとしている/作っている機能に改善点はないのか」を周りの人に聞きつつ進められると良いかもしれません。自分が作成したものを他の人にも喜んで使ってもらえるとモチベーションにもなると思うので、適度に他の人にも見てもらいつつ進めることも良い開発につながるかなと思います。
- 自分ではどうにもならないことは、他の人の力を借りる
個人開発ではいっけん1人で開発しているように思われます。しかし、幸いにもエンジニアは無料で様々な方法で他の人に質問することができるため、国籍・地理的な距離に関係なく様々な人の力を借りられます。
以下のようなサービスを使用してほかの人の力を借りることができますので、行き詰った時にはぜひ他の人の力を借りてみてください。- Stack Overflow
- GitHubのIssuesへの投稿
- Teratail
参考
Express.js - エラー処理を記述する
Ubuntu 20.04にPostgreSQLをインストールする方法 [クイックスタート]
Twitter - クソコード動画「共通化の罠」
Nuxt.jsのアプリケーションをinitd-foreverを使ってデーモン化、自動起動する