ENGINEER BLOG

ENGINEER BLOG

SPA(Single Page Application)で業務アプリケーションを構築する

こんにちは。SCI本部の横山です。
Single Page Application(以下SPA)で業務アプリケーションを構築するメリットについて考察してみます。

SPAについて

従来のWebアプリケーションでは、画面遷移のたびにサーバーから1画面分のHTMLを受信し、画面全体を書き換える手法が一般的です。これをMPA(Multiple Page Application)と呼びます。
これに対してSPAは、単一ページ内のHTMLを部分的に書き換えることにより、画面遷移を実現します。

一般にSPAは、ネイティブアプリに近いユーザー体験を実現できる反面、実装が複雑になり、コストも高いと言われているようです。
この技術を、「ネイティブアプリに近いユーザー体験」が特に要求されない、一般的な業務アプリケーションで採用したらメリットはあるのか? というのが今回のテーマですが、結論を先に言うと「メリットはある」ということになりました。
従来から、データ管理機能をバックエンドのRESTful API、ユーザーから見える部分をフロントエンドのWebアプリケーションという構成で開発をしてきましたので、Webアプリケーションの業務機能がブラウザのJavaScriptに移行しただけという感覚です。
WebアプリケーションとJavaScriptを併用するのに比べれば、ユーザー機能がブラウザに一元化されるのでシステム構成がシンプルになり、開発中の混乱も起こりにくくなると思います。
ただし、フロント側はすべてJavaScriptまたはTypeScriptでの開発となるので開発者のスキルセットが分かれますし、公開できないデータやアルゴリズムを誤ってブラウザ側に保持、実装しないよう注意する必要があります。

利用したフレームワークなど

SPAを実現するためのJavaScriptフレームワークには、Vue.js 3.2を使用しました。
サーバー側はSpring Bootを使用していますが、本稿では詳述しません。

システム構成

システム構成は下図のようになります。

システムは、以下の機能から構成されます。

  • 画面
    • 検索画面:入力された検索条件で注文情報を検索し、結果を一覧表示します。
    • 詳細画面:検索画面で選択された注文の詳細を表示し、更新、削除します。また、新規に注文を追加します。
  • RESTful API
    • 注文管理:注文を取得・照会・追加・更新・削除します。
    • 顧客管理:登録されている顧客の情報をリストで返します。
    • 商品管理:登録されている商品の情報をリストで返します。
  • データベース
    • 注文情報:注文の情報を格納します。注文は、注文(ヘッダー)と注文明細が1:nの階層構造になっています。
    • 顧客情報:顧客の情報を格納します。
    • 商品情報:商品の情報を格納します。

データ構造は下図のようになります。

画面サンプル

検索画面

入力された検索条件で注文情報を検索し、結果を一覧表示します。

検索画面

  • 検索条件
    • 注文状況:チェックされたステータスのいずれかに該当するデータを抽出します。
    • 受注年月日:指定された範囲に該当するデータを抽出します。
    • 顧客名:部分一致検索を行ないます。
    • 商品名:部分一致検索を行ないます。
  • 追加ボタン:詳細画面に遷移し、新たな注文情報を追加できます。
  • 一覧
    • ヘッダー:クリックした項目で並び替えます。
    • 注文ID:詳細画面に遷移し、クリックした注文情報を表示します。
  • ページナビ
    • ページ移動ボタン:クリックしたページを表示します。
    • 1ページ行数:1ページに表示する行数を選択します。

詳細画面

検索画面で選択された注文の詳細を表示し、更新、削除します。また、新規に注文を追加します。

詳細画面

  • 注文ID~備考:注文情報の項目を編集します。
  • 明細
    • 商品~備考:注文明細の項目を編集します。
    • 明細削除:当該の明細行を画面上から削除します。
  • 明細追加:新たな注文明細を画面上に追加します。
  • 追加ボタン:注文情報を追加します。
  • 更新ボタン:注文情報を更新します。
  • 削除ボタン:注文情報を削除します。

MPA(Multiple Page Application)と比較したメリットとデメリット

ブラウザとサーバーの役割分担

SPAになると、ブラウザとサーバーの役割分担が変わります。
これを一覧にすると以下のようになります。

機能
分類

詳細
MPA
ブラウザ

サーバー
SPA
ブラウザ

サーバー
UI ページレンダリング テンプレートエンジンによるHTML生成 JavaScriptによるDOM操作
ユーザー操作への応答 JavaScriptによる処理 Form送信への応答 JavaScriptによる処理
画面遷移 遷移先画面のHttp応答を返す JavaScriptによる処理
画面状態の保持 セッション等に格納 ブラウザ上に格納
ビジネスロジック データの編集・整形 JavaScriptによる処理 Javaによる処理 JavaScriptによる処理
データ入力制約 HTML、JavaScriptによる処理 HTML、JavaScriptによる処理
入力チェック Javaによる処理 Javaによる処理
データ管理 Java、SQLによる処理 Java、SQLによる処理

メリットとデメリット

SPAのメリット

  • MPAでは、同じ機能分類でもブラウザとサーバーにまたがって実装されるものがあったのに対し、SPAでは役割分担が明確になる。
  • このため、同じ機能をどちらに実装すべきかという混乱がなくなり、生産性、品質が向上する。
  • ブラウザ側とサーバー側の開発を別の担当者が並行して進めることが可能になり、工期が短縮できる。
  • 画面生成をサーバー側のテンプレートエンジンに依存しないため、デザインと機能の結合がブラウザ上で完結する。

SPAのデメリット

  • ブラウザ側の開発者は、JavaScriptやTypeScriptのスキルが必要になる。
  • ブラウザ側の資源は、難読化するとはいえユーザーによる解析が可能なため、非公開のデータやアルゴリズムが含まれないよう、配慮が必要になる。

上記のような得失を考慮すると、業務アプリケーションでSPAを採用する意味は十分にあると考えます。


[参考資料] コードと解説

検索画面と詳細画面のソースコードは以下のようになっています。
共通コンポーネントや共通ライブラリを使用している部分もありますが、処理の流れは把握できると思います。
末尾には、代表的な共通コンポーネントのコードも掲載します。

Vue.jsでSPAを開発する上で、何かのご参考になれば幸いです。

検索画面のコード

インポートと外部機能の利用宣言など

ここでは、Vue.jsの機能とVuexの他、共通定数(/consts/に格納)、共通ライブラリ(/functions/に格納)、共通コンポーネント(/components/に格納)をインポートしています。

<script setup>
import { reactive, onMounted } from 'vue'
import { useStore } from 'vuex'
import AppConsts from '@/consts/AppConsts'
import RestAPIClient from '@/functions/RestAPIClient'
import ViewUtils from '@/functions/ViewUtils'
import CodeCheckGroup from '@/components/CodeCheckGroup.vue'
import PageNavi from '@/components/PageNavi.vue'




/** Vuexデータ */
const store = useStore()
/** RESTful APIクライアント */
const { restSearch } = RestAPIClient()
/** 画面操作支援機能ライブラリ */
const { changeSort, getSort, createPageNavi, setPageNavi } = ViewUtils()
/** Vuexに画面データを格納する。 */
const setPageData = (value) => store.dispatch('setPageData', value)
/** RESTful API中継要求のパス */
const REST_PATH = AppConsts.PATH_ORDERS

画面データの宣言

検索入力項目の値、一覧のソート順などを格納する領域を宣言します。
リアクティブにより、画面上のデータと双方向にバインドされます。
これらのデータはVuexに格納することで、画面を再表示する際にも利用します。

/** 画面データ */
const page = reactive ({
  /** 画面識別 */
  id: 'orderListView',
  /** 画面設定 */
  value: {
    /** 検索入力項目 */
    search: {
      /** 注文ID */
      orderId: '',
      /** 注文状況 */
      orderStatus: [],
      /** 受注年月日(開始) */
      orderDateFrom: '',
      /** 受注年月日(終了) */
      orderDateTo: '',
      /** 顧客名 */
      customerName: '',
      /** 商品名 */
      productName: '',
    },
    /** 一覧ヘッダー名と並び順 */
    sort: {
      orderId: '注文ID',
      orderStatus: '注文状況',
      orderDate: '受注年月日' + AppConsts.SORT_DESC,
      customerId: '顧客ID',
      customerName: '顧客名',
      totalAmount: '合計金額',
    },
    /** 表示ページ情報 */
    navi: createPageNavi(),
  }
})

送受信データの宣言

サーバー側のRESTful APIから受け取るデータを宣言します。
リアクティブにより、画面上のデータと双方向にバインドされます。

/** 送受信データ */
const data = reactive ({
  /** 受信したリソースの配列(JSON) */
  resources: {},
  /** 受信したエラーの配列(JSON) */
  errors: {},
  /** 応答ステータス */
  status: {},
  /** 該当件数 */
  count: 0,
})

初期表示時の処理

すでにこの画面が表示され、検索が実行されているときは、その検索結果を再表示します。

/*****************************************************************************
 * 初期表示
 ****************************************************************************/
onMounted(() => {
  // 画面データが待避されているとき
  if (store.state.pageData[page.id]) {
    // 画面設定を復元する。※ Vuexの警告を回避するため、ディープコピーする。
    page.value = (JSON.parse(JSON.stringify(store.state.pageData[page.id].value)))
    // 前回表示していたページを再表示する。
    showPage()
  }
})

一覧ヘッダーがクリックされたときの処理

クリックされた列の内容で並び替えます。昇順のとき、降順で並び替えます。

/*****************************************************************************
 * 一覧ヘッダー.クリック
 ****************************************************************************/
const headerOnClick = (event) => {
  // ソート指定を変更する。
  changeSort(page.value.sort, event.target.id.slice('sort.'.length))
  // 現在のページを再表示する。
  showPage()
}

ページを表示する処理

1ページ分のデータをRESTful APIから取得します。
取得したデータはdataに格納され、自動的に画面上に表示されます。

/*****************************************************************************
 * ページを表示する。
 * @param pageNo 表示するページの番号(1から開始)。指定しないとき、現在のページを再表示する。
 ****************************************************************************/
const showPage = async (pageNo) => {
  // ページ番号が指定されたとき、現在のページに格納する。
  if (pageNo) {
    page.value.navi.currentPage = pageNo
  }
  // 取得開始位置を算出する。
  page.value.navi.offset = (page.value.navi.currentPage - 1) * page.value.navi.limit
  // APIのURLを組み立てる。
  let url = REST_PATH
          + '?orderStatus=' + page.value.search.orderStatus
          + '&orderDateFrom=' + page.value.search.orderDateFrom
          + '&orderDateTo=' + page.value.search.orderDateTo
          + '&customerName=' + page.value.search.customerName
          + '&productName=' + page.value.search.productName
          + '&sort=' + getSort(page.value.sort)
          + '&offset=' + page.value.navi.offset
          + '&limit=' + page.value.navi.limit
          + '&count=true'
  // 注文情報管理.照会を呼び出す。
  await restSearch(url, data)
  // 表示ページ情報を再計算する。
  setPageNavi(page.value.navi, data.count)
  // 画面データを待避する。※ Vuexの警告を回避するため、ディープコピーする。
  setPageData(JSON.parse(JSON.stringify(page)))
}
</script>

画面定義

注文状況の検索条件を指定するために、独自に作成した共通コンポーネントCodeCheckGroupを使用しています。
また、ページナビ部分も、共通コンポーネントPageNaviとして共通化しています。

<template>
  <div class="main">
    <h1>注文情報検索一覧</h1>
    注文状況<CodeCheckGroup id="search.orderStatus" :codes="AppConsts.CODE_ORDER_STATUS" v-model="page.value.search.orderStatus" /><br/>
    受注年月日<input id="search.orderDateFrom" type="date" v-model="page.value.search.orderDateFrom" placeholder="受注年月日(From)">~<input id="search.orderDateTo" type="date" v-model="page.value.search.orderDateTo" placeholder="受注年月日(From)"><br/>
    顧客名<input id="search.customerName" v-model.trim="page.value.search.customerName" placeholder="顧客名"><br/>
    商品名<input id="search.productName" v-model.trim="page.value.search.productName" placeholder="商品名"><br/>
    <input id="search" type="button" @click="showPage(1)" value="検索" />
    <router-link :to="{ name: 'OrderDetailAdd' }">追加</router-link>
    <PageNavi id="navi" :showPage="showPage" :navi="page.value.navi" />
    <div>
      <table>
        <thead>
          <tr>
            <th><input id="sort.orderId" type="button" @click="headerOnClick($event)" v-model="page.value.sort.orderId" /></th>
            <th><input id="sort.orderStatus" type="button" @click="headerOnClick($event)" v-model="page.value.sort.orderStatus" /></th>
            <th><input id="sort.orderDate" type="button" @click="headerOnClick($event)" v-model="page.value.sort.orderDate" /></th>
            <th><input id="sort.customerId" type="button" @click="headerOnClick($event)" v-model="page.value.sort.customerId" /></th>
            <th><input id="sort.customerName" type="button" @click="headerOnClick($event)" v-model="page.value.sort.customerName" /></th>
            <th><input id="sort.totalAmount" type="button" @click="headerOnClick($event)" v-model="page.value.sort.totalAmount" /></th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="item in data.resources" :key="item.orderId">
            <td><router-link :to="{ name: 'OrderDetail', params: { orderId: item.orderId }}">{{ item.orderId }}</router-link></td>
            <td>{{ item.orderStatus }}</td>
            <td>{{ item.orderDate }}</td>
            <td>{{ item.customerId }}</td>
            <td>{{ item.customerName }}</td>
            <td>{{ item.totalAmount }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

詳細画面のコード

インポートと外部機能の利用宣言など

<script setup>
import { reactive, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import AppConsts from '@/consts/AppConsts'
import RestAPIClient from '@/functions/RestAPIClient'
import CodeRadioGroup from '@/components/CodeRadioGroup.vue';
import CustomerDropdownList from '@/components/CustomerDropdownList.vue';
import ProductDropdownList from '@/components/ProductDropdownList.vue';




/** Vue Router */
const route = useRoute()
/** RESTful APIクライアント */
const { restRead, restSearch, restCreate, restUpdate, restDelete } = RestAPIClient()
/** RESTful API中継要求のパス */
const REST_PATH = AppConsts.PATH_ORDERS

送受信データの宣言

サーバー側のRESTful APIと送受信するデータを宣言します。
GETのときは受け取ったデータを格納します。
POST、PUTのときは、このデータをRESTful APIに送信します。
リアクティブにより、画面上のデータと双方向にバインドされます。

/** 送受信データ */
const data = reactive ({
  /** 送信、および、受信するリソース(JSON) */
  resource: {},
  /** 受信したエラーの配列(JSON) */
  errors: {},
  /** 応答ステータス */
  status: {},
})

その他に、画面のドロップダウンリストに表示するデータを取得するための領域を宣言します。
これらは画面上のデータと同期する必要がないので、リアクティブしません。

/** 顧客リスト */
const customers = {
  /** 受信したリソースの配列(JSON) */
  resources: {},
  /** 受信したエラーの配列(JSON) */
  errors: {},
  /** 応答ステータス */
  status: {},
}
/** 商品リスト */
const products = {
  /** 受信したリソースの配列(JSON) */
  resources: {},
  /** 受信したエラーの配列(JSON) */
  errors: {},
  /** 応答ステータス */
  status: {},
}

初期表示時の処理

画面のドロップダウンリストに表示するデータを取得します。
引数に注文IDが指定されたとき、注文情報を取得します。

/*****************************************************************************
 * 初期表示
 ****************************************************************************/
onMounted( async () => {
  // 顧客リストを取得する(同期処理)。
  await restSearch(AppConsts.PATH_CUSTOMERS + '?sort=customerNameKn,asc', customers)
  // 商品リストを取得する(同期処理)。
  await restSearch(AppConsts.PATH_PRODUCTS + '?sort=productCd,asc', products)
  // 引数.注文IDが指定されたとき
  if (route.params.orderId) {
    // APIのURLを組み立てる。
    let url = REST_PATH + '/' + route.params.orderId
    // 注文情報管理.取得を呼び出す(同期処理)。
    await restRead(url, data)
  }
})

追加ボタンクリック時の処理

送受信データをRESTful APIにPOSTします。
※ API呼び出しの処理は共通メソッド化しています。

/*****************************************************************************
 * 追加.クリック
 ****************************************************************************/
const createOnClick = () => {
  // APIのURLを組み立てる。
  let url = REST_PATH + '/'
  // 注文情報管理.追加を呼び出す。
  restCreate(url, data)
}

更新ボタンクリック時の処理

送受信データをRESTful APIにPUTします。
※ API呼び出しの処理は共通メソッド化しています。

/*****************************************************************************
 * 更新.クリック
 ****************************************************************************/
const updateOnClick = () => {
  // APIのURLを組み立てる。
  let url = REST_PATH + '/' + route.params.orderId
  // 注文情報管理.更新を呼び出す。
  restUpdate(url, data)
}

削除ボタンクリック時の処理

RESTful APIにDELETEを送信します。
削除なのでデータは送信しませんが、バージョンチェックのため、引数に最終更新年月日を指定しています。
※ API呼び出しの処理は共通メソッド化しています。

/*****************************************************************************
 * 削除.クリック
 ****************************************************************************/
const deleteOnClick = () => {
  // APIのURLを組み立てる。
  let url = REST_PATH + '/' + route.params.orderId
          + '?updateTime=' + data.resource.updateTime
  // 注文情報管理.削除を呼び出す。
  restDelete(url, data)
}

明細追加ボタンクリック時の処理

新しい明細行を生成して追加します。
追加、または、更新ボタンをクリックするまでは、データベースには反映しません。

/*****************************************************************************
 * 明細追加.クリック
 ****************************************************************************/
const addDetailOnClick = () => {
  // 注文明細のリストが存在しないとき、生成する。
  if (!data.resource.orderDetails) {
    data.resource.orderDetails = []
  }
  // 追加する注文明細情報を準備する。
  let orderDetail = {
    orderId: data.resource.orderId,
    detailNo: data.resource.orderDetails.length + 1,
    quantity: 0,
    detailAmount: 0,
  }
  // 注文明細のリストの最後に追加する。
  data.resource.orderDetails.push(orderDetail)  
}

明細削除ボタンクリック時の処理

指定された明細行を削除します。
追加、または、更新ボタンをクリックするまでは、データベースには反映しません。

/*****************************************************************************
 * 明細削除.クリック
 ****************************************************************************/
const deleteDetailOnClick = (index) => {
  // 指定された注文明細情報を削除する。
  data.resource.orderDetails.splice(index, 1)  
  // 明細番号を再設定する。
  for (let i = 0; i < data.resource.orderDetails.length; i++) {
    data.resource.orderDetails[i].detailNo = i + 1
  }
}
</script>

画面定義

明細行に登場するProductDropdownListは商品選択ドロップダウンリストです。そのコードは次項で紹介します。

<template>
  <div class="main">
    <h1>注文情報詳細</h1>
    注文ID<input id="orderId" v-model.trim="data.resource.orderId" placeholder="注文ID"><br/>
    注文状況<CodeRadioGroup id="orderStatus" :codes="AppConsts.CODE_ORDER_STATUS" v-model="data.resource.orderStatus" /><br/>
    受注年月日<input id="orderDate" type="date" v-model="data.resource.orderDate" placeholder="受注年月日"><br/>
    顧客<CustomerDropdownList id="customerId" :customers="customers.resources" v-model="data.resource.customerId" /><br/>
    合計金額<input id="totalAmount" v-model.trim="data.resource.totalAmount" placeholder="合計金額"><br/>
    備考<textarea id="remark" v-model.trim="data.resource.remark" placeholder="備考"></textarea><br/>
    <div>
      <table>
        <thead>
          <tr>
            <th>明細番号</th>
            <th>商品</th>
            <th>個数</th>
            <th>明細金額</th>
            <th>備考</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(item, index) in data.resource.orderDetails" :key="item.productId">
            <td>{{ item.detailNo }}</td>
            <td>< :id="'detail.productId.' + index" :products="products.resources" v-model="item.productId" /></td>
            <td><input :id="'detail.quantity.' + index" v-model.trim="item.quantity" placeholder="個数"></td>
            <td><input :id="'detail.detailAmount.' + index" v-model.trim="item.detailAmount" placeholder="明細金額"></td>
            <td><input :id="'detail.remark.' + index" v-model.trim="item.remark" placeholder="備考"></td>
            <td><input :id="'deleteDetail.' + index" type="button" @click="deleteDetailOnClick(index)" value="明細削除" /></td>
          </tr>
          <tr>
            <td><input id="addDetail" type="button" @click="addDetailOnClick" value="明細追加" /></td>
          </tr>
        </tbody>
      </table>
    </div>
    <input id="create" type="button" @click="createOnClick" value="追加" />
    <input id="update" type="button" @click="updateOnClick" value="更新" />
    <input id="delete" type="button" @click="deleteOnClick" value="削除" />
  </div>
</template>

共通コンポーネントのコード(商品選択ドロップダウンリスト)

プロパティ定義

親コンポーネントから、コントロールID、商品情報の配列、対象項目を受け取ります。
選択結果は対象項目に格納されます。

<script setup>
const props = defineProps([
  /** コントロールID */
  'id',
  /** 選択肢に表示する商品情報の配列 */
  'products',
  /** 対象項目 */
  'modelValue'
])
</script>

HTML

親コンポーネントから渡された商品情報の数だけ<option>タグを出力します。

<template>
  <select :id="id" :value="modelValue" @change="$emit('update:modelValue', $event.target.value)"  >
    <option v-for="product in products" :key="product.productId" :value="product.productId" :selected="product.productId === modelValue">
      [{{ product.productCd }}]&nbsp;{{ product.productName }}
    </option>
  </select>
</template>