レガシーWebアプリケーションをhtmxへ切り替えた話

アソビューでバックオフィス向けのシステムを担当しているアズマです。今回ご紹介するのは、レガシー技術で構築されていたWebアプリケーションをhtmxで置き換えていく話です。

htmxとは

htmxのコンセプトは、HTMLに可能な限りJavaScriptを記述することなくAJAX、CSS変換、サーバサイドレンダリングを実現するJavaScriptライブラリです。手軽に非同期処理ができ、コンテンツ切り替えもHTMLの属性を1つ2つ追加するだけで完了できます。

htmxを採用した背景と目的

比較的古い技術スタックで構成しているバックオフィス用のSpring Bootアプリケーションがあり、このアプリケーションには以下の課題がありました。

  • バックオフィス用とはいえ画面描画が遅く、操作性も悪い。
  • 使われているJavaScriptライブラリのバージョンが古く、さらにはメンテナンスがされていない依存関係のライブラリがあり、改修の手間がかかる。
  • 古くなったフロント側の実装を切り替えるために、サーバサイドの実装を大規模な変更はしたくない。

これを解決する方法として、画面描画が遅い問題は非同期処理でコンテンツを分割しつつ、繰り返しになりますが、あまり大きな改修はせずに行いたかったところもあって、これに最適だったのがhtmxでした。

課題のあった画面

対象のアプリケーションは弊社の販売管理アプリケーションで、画面や定期実行の処理から一括で1カ月単位の売上や販売額を集計する機能と、その集計結果を出力する機能があります。集計処理には数秒で完了するものと、最大数時間かかるものとさまざまあるため、画面からは非同期で集計処理を実行してすぐに画面を表示しておき、その進行具合を確認できる画面を用意しています。

今回課題のあった画面のコンテンツは、

  • 現在実行中の非同期処理を表示する
  • 同期処理の実行履歴の一覧を表示する

の2つです。

この画面の表示はかつて2秒前後程度で済みましたが、近頃急激にレスポンスが悪くなる事象につきあたり、遅いときは10秒以上かかる状態でした。 調べたところ、表示するデータはページングで分割しているにも関わらず、データベースからデータを取り出すときには特に検索のフィルタもせずに全件取得していました。実装した当初はその総件数も少なく、あまりパフォーマンスの懸念がなかったこともあるようでした。表示に必要なデータをJavaの実装でフィルタしている状態だったため、データベースから出力に不要なデータを取り出している状態ですから、件数が膨れ上がるとデータベースからの出力件数も多く、これによりJavaへデータを変換する際に大きなオブジェクトを持つことになり、メモリ消費量もデータの出力量も多くなるので、パフォーマンスが悪くなるのは当然です。 また、進行具合を確認するためにもこの全件検索を使っているため、全般的にシステム負荷の高い状態なのを確認できました。

これを直していきます。

解決のために行ったこと

負荷の高いクエリを見直す

手始めに、抽出するデータを絞ります。これはSQLのLIMIT+OFFSETを使いページング用のパラメータを与えれば簡単に改修できます。以下のSQLは、LIMITとOFFSETを指定してページングした部分のデータだけを抽出するクエリの抜粋です。

SELECT
    functions.asynchronous_function_id
    , functions.asynchronous_classification
    , functions.asynchronous_function_name
    ...(中略)...
    , functions.message
FROM
    asynchronous_functions functions
WHERE
    functions.asynchronous_state != 'OPERATING'
ORDER BY
    functions.asynchronous_function_id DESC
LIMIT #{pagination.perPage} OFFSET #{pagination.offset}

このクエリはMyBatisを使った動的SQLにしています。一番最後の行に追加したLIMITで1ページに表示する件数、OFFSETで取り出すデータの開始地点を指定します。 実はこれだけでも、データベースからデータを抽出する総量が減り、Javaのオブジェクトへマッピングする量も減るため、その速度もメモリ消費量も大幅に改善しています。 実測したところ1秒もかからずに画面が出力できたのを確認できましたから、これだけで良しとしても十分でしたが、実行中の非同期処理については毎回画面を再読み込みしないとその進捗が分からない状態なので、利便性の面で改善ができそうです。

コンポーネントごとに実装を分割する

次に行ったのは、

  • 現在実行中の非同期処理を表示する
  • 同期処理の実行履歴の一覧を表示する

の2つをそれぞれ分割し、分割したコンポーネントを非同期で出力する変更です。 処理中と実行結果の取得については、サーバサイドは部分的に分離できていましたので、これを1つのControllerから呼び出すのではなく、コンポーネントごとに分離します。

コンポーネントごとに分割した一覧画面

「実行中の非同期処理」(画面上部の青枠)と、「非同期処理の結果」(画面中央にある一覧)で分割し、さらにこの画面全体を呼び出す実装を行うControllerに変更します。

package com.asoview.sales.web.asynchronous;

import com.asoview.sales.config.PaginationProperties;
import com.asoview.sales.core.fundamentals.Pager;
import com.asoview.sales.core.fundamentals.Pagination;
import com.asoview.sales.core.model.asynchronous.AsynchronousFunctions;
import com.asoview.sales.service.asynchronous.AsynchronousService;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;

@Controller
@SessionAttributes(value = {"asynchronousForm"})
@RequestMapping("/asynchronous")@AllArgsConstructor
@Slf4j
public class AsynchronousController {
    private AsynchronousService asynchronousService;
    private PaginationProperties paginationProperties;

    @ModelAttribute("asynchronousForm")
    AsynchronousForm asynchronousForm() {
        return new AsynchronousForm();
    }

    @GetMapping("")
    public ModelAndView display(ModelAndView mnv, @ModelAttribute AsynchronousForm asynchronousForm) {
        int count = asynchronousService.count();
        asynchronousForm.setCount(count);

        mnv.setViewName("asynchronous/display");
        return mnv;
    }

    @GetMapping("/page/{page}")
    public ModelAndView page(ModelAndView mnv, @PathVariable("page") int page, @ModelAttribute("asynchronousForm") AsynchronousForm asynchronousForm) {
        Pagination pagination = Pagination.of(page, paginationProperties.getDefaultSize());
        Pager pager = new Pager(asynchronousForm.getCount(), pagination);
        mnv.addObject("pager", pager);

        AsynchronousFunctions asynchronousFunctions = asynchronousService.findByPage(pagination);
        mnv.addObject("asynchronousFunctions", asynchronousFunctions.getList());
        mnv.setViewName("asynchronous/list");
        return mnv;
    }

    @GetMapping("/in-progress")
    public ModelAndView inProgress(ModelAndView mnv) {
        AsynchronousFunctions asynchronousFunctions = asynchronousService.findInProgress();
        mnv.addObject("asynchronousFunctions", asynchronousFunctions.getList());
        mnv.setViewName("asynchronous/in-progress");
        return mnv;
    }
}

これに対応する画面の実装は以下です。 まずは画面全体です。全般的にThymeleafのHTMLテンプレートを用いています。

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport"
        content="width=device-width, initial-scale=1, shrink-to-fit=no" />
  <title th:inline="text">非同期処理</title>
  <link rel="stylesheet" th:href="@{/webjars/bootstrap/{version}/css/bootstrap.min.css(version=${@webjarsConfig.bootstrap})}" />
  <link rel="stylesheet" th:href="@{/css/bootstrap-icons.css}"/>
  <script th:src="@{/js/htmx.min.js}"></script>
</head>
<body>
<div class="container-fluid">
  <div class="container-fluid">
    <div class="row">
      <h3 class="border-bottom border-success">実行中の非同期処理</h3>
      <div class="table-responsive" th:with="url='/asynchronous/in-progress'">
        <div class="col-sm-12" id="in-progress-table" th:attr="hx-get=${url}" hx-trigger="load, every 4s">
          <div class="progress" id="progress-in-progress-bar" role="progressbar" aria-label="Animated striped example" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
            <div class="htmx-indicator progress-bar progress-bar-striped progress-bar-animated bg-yellow" style="width: 100%"></div>
          </div>
        </div>
      </div>

      <h3 class="border-bottom border-success">非同期処理の結果</h3>
      <div class="table-responsive" th:with="url='/asynchronous/page/0'">
        <div class="col-sm-12" id="result-table" th:attr="hx-get=${url}" hx-trigger="load">
          <div class="progress" id="progress-result-table" role="progressbar" aria-label="Animated striped example" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
            <div class="htmx-indicator progress-bar progress-bar-striped progress-bar-animated bg-green" style="width: 100%"></div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
</body>
</html>

実行中の非同期処理を出力する箇所は、以下のように hx-get にてリクエストするURLと、このURLをリクエストするタイミングを hx-trigger で定義します。

th:with="url='/asynchronous/in-progress'"
th:attr="hx-get=${url}"
hx-trigger="load, every 4s"

ここではリクエストするURLと属性の定義を分離して記述していますので、HTMLは以下のように出力されます。

hx-get="/asynchronous/in-progress"
hx-trigger="load, every 4s"

hx-trigger で指定している内容は、

  • load :画面を出力した直後に実行する
  • every 4s :4秒ごとに実行する

です。実はこれだけでポーリングも可能になり、表示が完了したあと、実行している非同期処理の進行が4秒おきに更新されます。

実行中の非同期処理を出力するHTMLは以下です。いままで利用していた実行中の非同期処理のHTMLをほぼそのまま使え、特にテーブル部分は丸ごと使えましたので、移植も容易でした。

<html lang="Ja" xmlns:th="http://www.thymeleaf.org">
<body>
    <div class="row">
        <div class="col-12" th:unless="${asynchronousFunctions.isEmpty()}">
            <table class="table table-bordered table-striped responsive-table" id="fixed_header_table_doing">
                <thead>
                    <tr>
                        <th>通番</th>
                        <th>ステータス</th>
                        <th>処理種別</th>
                        <th>開始日時</th>
                        <th>処理件数</th>
                        <th>処理名</th>
                        <th>実行条件</th>
                    </tr>
                </thead>
                <tbody>
                    <tr th:each="asynchronous : ${asynchronousFunctions}">
                        <td class="text-end" th:text="${asynchronous.asynchronousFunctionId.value}">処理通番</td>
                        <td th:text="${asynchronous.asynchronousState.getName()}">非同期処理ステータス</td>
                        <td th:text="${asynchronous.asynchronousClassification.getName()}">処理種別</td>
                        <td th:text="${asynchronous.startMoment.recordDateTime.getDisplayFormat()}">処理開始日時</td>
                        <td>
                            <div class="progress" role="progressbar" aria-label="in-progress" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100">
                                <div class="progress-bar progress-bar-striped progress-bar-animated text-bg-warning overflow-visible text-center" th:style="${'width: ' + asynchronous.currentOperationPercentage + '%'}" th:inline="text">
                                    [[${#numbers.formatInteger(asynchronous.currentOperationCount.value,1,'COMMA')}]] / [[${#numbers.formatInteger(asynchronous.totalOperationCount.value,1,'COMMA')}]]
                                </div>
                            </div>
                        </td>
                        <td th:text="${asynchronous.asynchronousFunctionName.getLabel()}">処理名</td>
                        <td th:if="${asynchronous.parameters}" th:text="${asynchronous.parameters.loggingLabel()}">画面から入力した処理条件</td>
                        <td th:unless="${asynchronous.parameters}"></td>
                    </tr>
                </tbody>
            </table>
        </div>
        <div class="col-12" th:if="${asynchronousFunctions.isEmpty()}">
            <p>実行中の非同期処理はありません</p>
        </div>
    </div>
</body>
</html>

一覧結果も同様に、今まで使っていたHTMLをほぼ流用して、ページングのURL指定をhtmxの属性に切り替えるだけです。

<html lang="Ja" xmlns:th="http://www.thymeleaf.org">
<body>
    <!-- Pager -->
    <div th:if="${pager}">
        <ul class="pagination" th:with="listurl='/asynchronous/page/'">
            <li class="page-item" th:if="${pager.isHasBeforePage()}">
                <a class="page-link" href="#" aria-label="Previous"
                   th:attr="hx-get=${listurl + pager.getPrevPageButtonIndex()}" hx-trigger="click" hx-target="#result-table">
                    <span aria-hidden="true">&laquo;</span>
                </a>
            </li>
            <li class="page-item" th:each="page, pageStat : ${pager.getPages()}">
                <a class="page-link" href="#"
                   th:attr="hx-get=${listurl + page.getIndex()}" hx-trigger="click" hx-target="#result-table"
                   th:text="${page.getIndex() + 1}"
                   th:classappend="${page.getIsCurrentPage() ? 'active' : ''}"></a>
            </li>
            <li class="page-item" th:if="${pager.isHasAfterPage()}">
                <a class="page-link" href="#" aria-label="Next" th:attr="hx-get=${listurl + pager.getNextPageButtonIndex()}" hx-trigger="click" hx-target="#result-table">
                    <span aria-hidden="true">&raquo;</span>
                </a>
            </li>
        </ul>
    </div>

    <div class="row">
        <table class="table table-bordered table-striped responsive-table">
         ....(中略).....
        </table>
    </div>
</body>
</html>

以上でコンポーネント単位に処理を分割し、それぞれ非同期で呼び出すことで実装も分割でき、表示したいタイミングで分割もできました。

今回取り上げた画面はとても簡素な画面ですが、他の一覧画面でも同様にコンポーネントを分割し、既存の実装を大きく変えることなく非同期処理が実現できました。

コンポーネント切り替えの例:モーダルダイアログ

今回htmxを適用するにあたって、他にも切り替えたいコンポーネントにモーダルダイアログがありました。このモーダルダイアログの表示や内容の切り替えも、htmxで簡単に置き換えができ、さらに実装も簡素にできます。

htmxを導入するついでにフロントの実装をBootstrap5.3に置き換え、モーダルダイアログの実装を行います。 表示したい内容は従来通りの実装をそのまま使います。

モーダルダイアログを呼び出す側の実装例は以下です。これは商品一覧を表示し、行ごとに配置したボタンを押すとその商品の詳細を表示する画面です。 また、この一覧画面は、先ほどの非同期処理の実装と同様に、元となる画面全体から非同期で呼び出しています。 画面全体の実装は以下です。

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
    <link rel="stylesheet" th:href="@{/webjars/bootstrap/{version}/css/bootstrap.min.css(version=${@webJarsProperties.bootstrap})}" />
    <script th:src="@{/webjars/bootstrap/{version}/js/bootstrap.min.js(version=${@webJarsProperties.bootstrap})}"></script>
    <script th:src="@{/js/htmx.min.js}"></script>
</head>
<body>
<div class="container-fluid">
    <div class="container p-3">
        <div>
            <div class="alert">一覧を表示します</div>
        </div>
        <div class="table-responsive">
            <div class="col-sm-12" id="result-table" th:hx-get="'/list'" hx-trigger="load">
                <div class="progress" id="progress-bar" role="progressbar" aria-label="Animated striped example" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100">
                    <div class="htmx-indicator progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
                </div>
            </div>
        </div>
    </div>
</div>
<div id="modals-here" class="modal fade" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
    <div class="modal-dialog" id="dialog">
        <div class="modal-content" id="modal-content">
        </div>
    </div>
</div>
</body>
</html>

この最下部にある <div id=”modals-here”> がモーダル用の要素です。Bootstrap5.3で実装したモーダルダイアログをそのまま使います。

商品の一覧部分は以下です。

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<!-- Pager -->
<div th:if="${pages}">
    ....(省略)....
</div>

<table class="table table-bordered table-striped text-nowrap" th:if="${pages}">
    <thead>
        <tr>
            <th>#番号</th>
            <th>名称</th>
            <th>価格</th>
            <th>在庫数</th>
        </tr>
    </thead>
    <tbody>
        <tr th:each="item : ${pages.getContent()}">
            <td>
                <button th:hx-get="'/item/' + ${item.id}" hx-target="#modal-content" hx-trigger="click"
                        data-bs-toggle="modal" data-bs-target="#modals-here"
                        class="btn btn-primary" th:text="${item.id}">1</button>
            </td>
            <td th:text="${item.name}">name</td>
            <td th:text="${item.price}" class="text-end">100</td>
            <td th:text="${item.stock}" class="text-end">10</td>
        </tr>
    </tbody>
</table>

このbuttonタグで実装したボタン <button th:tx-get="'/item/' + ${item.id}” hx-target="#modal-content" hx-trigger="click"> を押すと、押した行に表示されている商品の詳細画面がモーダルで表示されます。

tx-get で指定したURL:商品の詳細画面をリクエストし、そのレスポンスのHTMLを hx-target で指定したHTMLの要素へ出力します。

モーダルの実装は以下です。

<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<body>
    <div class="modal-header">
        <h1 class="modal-title" th:inline="text">[[${item.name}]]</h1>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
    </div>
    <div class="modal-body">
        <!-- item -->
        <div>
            <table class="table table-bordered table-striped text-nowrap" th:if="${item}">
                <thead>
                    <tr>
                        <th class="text-center">項目名</th>
                        <th class="text-center"></th>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td>番号</td>
                        <td th:inline="text">[[${item.id}]]</td>
                    </tr>
                    <tr>
                        <td>名称</td>
                        <td th:inline="text">[[${item.name}]]</td>
                    </tr>
                    ...(中略)....
                </tbody>
            </table>
        </div>
    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
    </div>
</body>
</html>

このモーダルを呼び出すボタンに定義したhtmxの属性 hx-target="#modal-content" によって、idmodal-contentの中が モーダル用のHTMLに置き換わります。つまり、

<div id="modals-here" class="modal fade" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
    <div class="modal-dialog" id="dialog">
        <div class="modal-content" id="modal-content">
        </div>
    </div>
</div>

上記で定義していたモーダルの要素は、商品ごとにあるボタンを押すことでBootstrapのモーダルが起動し、そのHTMLは以下のように切り替わります。

<div id="modals-here" class="modal fade show" tabindex="-1" aria-labelledby="exampleModalLabel" aria-modal="true" role="dialog" style="display: block;">
    <div class="modal-dialog" id="dialog">
        <div class="modal-content" id="modal-content">
    <div class="modal-header">
        <h1 class="modal-title">cherry</h1>
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
    </div>
    <div class="modal-body">
        <!-- item -->
        <div>
            <table class="table table-bordered table-striped text-nowrap">
                <thead>
                    <tr>
                        <th class="text-center">項目名</th>
                        <th class="text-center"></th>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td>番号</td>
                        <td>3</td>
                    </tr>
                    <tr>
                        <td>名称</td>
                        <td>cherry</td>
                    </tr>
                    ....(中略)....
                </tbody>
            </table>
        </div>
    </div>
    <div class="modal-footer">
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
    </div>
</div>

モーダルダイアログを表示

まとめ

以上で、既存のSpring Bootアプリケーションをhtmxを使って簡単に置き換えができることを紹介しました。古めのWebアプリケーションを手軽に置き換えできますので、採用してみてはいかがでしょうか。

最後に

アソビューではより良いプロダクトを世の中に届けられるよう共に挑戦していくエンジニアを募集しています。 カジュアル面談もやっておりますので、お気軽にエントリーください! お待ちしております。 www.asoview.co.jp

参考文献

https://htmx.org/