目次
・概要
・本日の成果・考え
・最後に
概要
前回リンクテストをようやく1つ完了し、その勢いのまま次のリンクテストに着手していたのですが、また問題が発生しました。
というのも、次のリンクテストではJSからの入力→Controllerクラスの実行(API通信呼び出し前)を予定していました。
しかし、テストのスコープを検討する中で、画面から送信した後の中継役であるJavaBridgeからController間をスコープとして対象を絞ったほうが良いと思ったのですが、そうなると入力内容を精査するバリデーションが実装できていないため、必要なテストパターンを試せないことに気がつきました。
API通信クラスの時同様に、未実装機能がアプリとしての品質に大きく影響することが判明したため、テストを中断してでも対応することにしました。
本日の成果
別の不備改修
バリデーション実装の前に、画面(HTML)で入力値が空の時には実行されないようにしていたのですが、
ユーザーに空であることを伝えるようにできていない点に気がついたのでついでに回収しておきます。
// メッセージ送信処理
function sendMessage() {
//入力メッセージチェック
const message = input.value.trim();
if (!message) return;
if (!message) return;で処理を抜けてしまっています。
これでは、ユーザー視点では何もわからないです。
// メッセージ送信処理
function sendMessage() {
//入力メッセージチェック
const message = input.value.trim();
if (!message) {
showError("リクエストが入力されていません。");
return;
}
//この下のコードは省略
}
// エラー表示処理
function showError(msg) {
const botWrapper = document.createElement("div");
botWrapper.className = "chat-wrapper bot";
const botMsg = document.createElement("div");
botMsg.className = "message bot complete";
botMsg.textContent = msg;
botWrapper.appendChild(botMsg);
chatArea.appendChild(botWrapper);
chatArea.scrollTop = chatArea.scrollHeight;
}
alert("入力してください");での実装も試してみましたが、JavaFXで表示している画面では、HTMLにalertを入れてもイベントの発火自体はJavaに伝わりますが画面に表示できませんでした。
一応JavaFXの機能でアラート機能を導入しておけば表示できるみたいです。
しかし、現在の設計では画面更新系はJavaScriptによるDOMを前提に設計しているため、アラートだけJavaFXの機能を導入する理由もないため、showErrorで対応しようと思います。
バリデーション項目
- 空入力チェック(入力文字の前後の空白は除去)
- 入力文字数の最大長(3000文字)
※レスポンスは制限なし - 制御文字のチェック
※ 改行とTabだけ許可するようにします。
追加改修
- 2重送信防止
※送信ボタンの非活性化で制御しているがすり抜けてきた時の予防。
今回の改修では、以下のクラスの追加と修正をする予定です。
- InputValidator.java:新規追加
- WindowController.java:画面から入力された後API通信を実行する前にバリデーションを実行させる。2重送信防止のためのフラグの実装。
- WindowLogic.java:バリデーションクラスのインスタンスを生成して、Controllerクラスに注入させる。
設計方針
現行では、WindowLogicで画面に必要なクラスのインスタンスを管理し、必要なクラスに注入する設計をとっております。
そこを崩さずに今回のバリデーションに関与するControllerとLogicに導入する形をとりました。
package app.windowView.validation;
public class InputValidator {
// 最大長 3000
private static final int MAX_LEN = 3000;
/**
* 入力チェック(Fail-fast)
* NGなら例外を投げる。
*
* バリデーション方針:
* - 画面のJavaScriptのtrim後空はNG
* - CRLF/CRはLFに正規化(Windows対策)
* - 許可する制御文字はLFとTABのみ
* - 入力の最大長:3000
*
* @param input ユーザー入力
* @return 正規化後の文字列(LFに統一)
*/
public String validateExecute(String input) {
if (input == null) {
throw new IllegalArgumentException("入力してください。");
}
// 改行統一(CRLF/CR → LF)
String normalized = input.replace("\r\n", "\n").replace("\r", "\n");
// 空チェック
if (normalized.trim().isEmpty()) {
throw new IllegalArgumentException("入力してください。");
}
// 制御文字チェック(許可:LF(\n) と TAB(\t) のみ)
for (int i = 0; i < normalized.length(); i++) {
char c = normalized.charAt(i);
if (c < 0x20) { // C0制御文字領域
if (c != '\n' && c != '\t') {
throw new IllegalArgumentException("使用できない制御文字が含まれています。");
}
}
if (c == 0x7F) { // DEL
throw new IllegalArgumentException("使用できない制御文字が含まれています。");
}
}
// 最大長
if (normalized.length() > MAX_LEN) {
throw new IllegalArgumentException("入力が長すぎます。最大" + MAX_LEN + "文字です。");
}
return normalized;
}
}
C0制御文字は、NULバイト注入というものがあり、文字列の中であってもコンピュータ側で意味持ったものとして解釈されてしまいます。
具体的にはNUL文字を文字列の終端として認識され、その後に悪意あるデータをチェック処理の後続に流すことができるそうです。
DELは、削除文字として消してしまう機能を持っており、ログや入力を一部消してしまうためUI上やログ上で原因が追えなくなるそうです。
※基本的に意図しない限り手入力で発生することはありえないため、除去する方針としました。
参考
public class WindowController {
// 表示ウィンドウのWebEngineラッパー
private final WebEngineWrapper webEngine;
// API通信用インスタンス
private final DifyApiClient apiClient;
// UIスレッド実行ラッパー
private final UiIniWrapper uiRunnable;
// 入力バリデータ
private final InputValidator validator;
// ロガー
private static final Logger logger = LoggerFactory.getLogger(WindowController.class);
// API通信の重複起動防止フラグ(送信中trueを設定)
private final AtomicBoolean enqueFlg = new AtomicBoolean(false);
/************
* メソッド名:引数付きコンストラクタ
* 処理内容:ロジッククラスで初期化する際に、現在表示中のウィンドウのオブジェクトを取得する。
* @param webEngine 現在表示中のウィンドウのWebEngineラッパー
* @param apiClient Dify APIクライアント
* @param r UIスレッド実行ラッパー
* @param validator 入力バリデータ
************/
public WindowController(WebEngineWrapper webEngine, DifyApiClient apiClient, UiIniWrapper r,
InputValidator validator) {
this.webEngine = webEngine;
this.apiClient = apiClient;
this.uiRunnable = r;
this.validator = validator;
}
/************
* メソッド名:イベントハンドラーメソッド
* 処理内容:JSのイベント発火をロジッククラス経由で受け取る処理。API通信処理を呼び出す。
* @param msg ユーザー入力メッセージ
************/
public void onSendMessage(String msg) {
// 受付の事実(本文は出さない)
logger.info("input.accepted len={}", msg == null ? 0 : msg.length());
// 二重送信防止:すでに送信中なら即終了
if (!enqueFlg.compareAndSet(false, true)) {
logger.info("通信処理実行中のため起動を拒否。");
return;
}
final String normalized;
try {
// 入力メッセージのバリデーションチェック
normalized = validator.validateExecute(msg);
} catch (IllegalArgumentException e) {
// 入力NG → UIエラー表示して復帰
uiRunnable.runLater(() -> showError(e.getMessage()));
enqueFlg.set(false);
return;
} catch (Exception e) {
// 想定外例外
logger.error("バリデーションチェックに該当したためエラー:", e);
uiRunnable.runLater(() -> showError("入力チェックでエラーが発生しました。"));
enqueFlg.set(false);
return;
}
// リクエストDto生成(正規化後文字列を投入)
DifyRequestDto dto = new DifyRequestDto(normalized);
// API通信用スレッド作成(通信終了後に破棄)
Thread communicationThread = new Thread(() -> {
try {
apiClient.streamingMsg(
dto,
chunk -> uiRunnable.runLater(() -> appendChatChunk(chunk)),
() -> uiRunnable.runLater(this::onChatComplete),
err -> uiRunnable.runLater(() -> showError(err)));
} catch (Exception e) {
logger.error("API通信ディスパッチ:異常終了", e);
uiRunnable.runLater(() -> showError("通信処理でエラーが発生しました。"));
} finally {
// 実行フラグのリセット
enqueFlg.set(false);
}
});
communicationThread.setDaemon(true);
communicationThread.start();
}
/************
* メソッド名:エラーメッセージ表示
* 処理内容:API通信またはUI更新処理内でエラーが発生したらエラーメッセージを表示する処理を呼び出す。
* @param err エラー内容
************/
private void showError(String err) {
String msg = "エラーが発生しました: " + err;
webEngine.call("showError(" + CommonFunction.escapeForJS(msg) + ")");
}
/************
* メソッド名:受信チャンク表示
* 処理内容:受信したチャンク毎に表示処理を呼び出す
* @param chunk Difyからのレスポンス
************/
private void appendChatChunk(String chunk) {
webEngine.call("appendMsg(" + CommonFunction.escapeForJS(chunk) + ")");
}
/************
* メソッド名:受信完了メッセージ表示
* 処理内容:全てのチャンクの受信が完了した旨を表示する処理を呼び出す。
************/
private void onChatComplete() {
logger.info("API通信ディスパッチ:正常終了");
webEngine.call("completeMsg()");
}
}
2重起動制御のフラグはバリデーションチェックに引っかかった時と、API通信が完了した時にリセットできるようにしています。
※画面上は送信ボタンを非活性にしていますが、API通信中にエラーとなった場合非活性から活性状態に戻せるようになっています。
WindowLogicクラスの改修ソースはControllerへのコンストラクタへの注入のみのためほとんど変更がないためここに記載しません。
最後に
後回しにしていたバリデーションの実装を行いました。
その結果、要件の抜け漏れが途中で何度も発覚し、都度追加改修が必要になる状況となっています。
今回の開発を通して、要件定義段階で「何を実現したいアプリなのか」「そのために最低限必要な機能は何か」を十分に整理できていなかったことを痛感しています。
次回以降の開発では、まずアプリの目的を明確にし、その目的を満たすために必要な機能・制約・非機能要件を整理した上で設計に入るという進め方を徹底したいと思います。
ここまでお付き合いありがとうございます。