システム開発で思うところ

Javaで主にシステム開発をしながら思うところをツラツラを綴る。主に自分向けのメモ。EE関連の情報が少なく自分自身がそういう情報があったら良いなぁということで他の人の参考になれば幸い

Jakarta EEでjava.util.logging.Loggerを使ったLogging

vermeer.hatenablog.jp

で学んだことを駆使しつつ、自分なりの Jakarta EEでのLoggingの実装が大体 整理できましたので、至る経緯と考察を残すためのメモ。

生成と破棄

これは、この記事のちょっとした続きです。

vermeer.hatenablog.jp

肝心の LogFileCloserの実装が漏れていました。
また、ちょっと追記もあります。
追記したのは、logger.removeHandler(h);のところです。
removeHandlerをしないと、ConsoleHandlerが残っていて 開発中だと LoggerにHandlerがどんどん追加付与されてしまって、同じログが連続して出力されてしまいます。
FileHandlerの方は、closeだけでイイ感じにしてくれるみたいで 重複出力は無かったんですけどね。

Java EE インジェクト可能なロガーの作り方と注意点 - Qiita
を参考にさせていただきました。

public class LogFileCloser {

    public void close(String rootLoggerName) {
        Logger logger = Logger.getLogger(rootLoggerName);
        for (Handler h : logger.getHandlers()) {
            h.close();
            logger.removeHandler(h);
        }
    }
}

また呼出し側も ちょっと変えたので そこに追記。
参考したものだと、RootとなるLoggerだけにHandlerを付与するので問題なかったのですが、複数のHandlerをもったLoggerをRoot配下に設けたい場合には、機能として不足していたので ちょっと追記。

@ApplicationScoped
public class LoggerLifecycleHandler {

(略)

    public void shutdown(@Observes @Destroyed(ApplicationScoped.class) Object event) {
        System.out.println("<< Cleanup:Closing logging file <<");
        LogFileCloser logFileCloser = new LogFileCloser();

        Collections.list(LogManager.getLogManager().getLoggerNames()).stream()
                .filter(name -> name.startsWith(LoggerName.ROOT_NAME))
                .forEach(logFileCloser::close);
    }
}

フォーマット

Console用と、File用のフォーマットをそれぞれ準備しました。
とはいえ、基本的な出力は同じなので、LogRecordConverterとして、編集ロジック自体は共通化しました。

JUL を少しマシにする - A Memorandum
を参考にさせていただきました。

手を加えたところのポイント

  • System.lineSeparator()の位置
  • 短縮形の要否

Fileと違って Consoleは出力都度 自動で改行が入るので タイミングを変えています。
また、Consoleは 開発時に出力ウィンドウに出力された内容を確認するために使うので クラス名が分かれば 十分なので短縮形で良いのですが、Fileは運用時とか より正確な情報が欲しいと思ったので短縮形表記を止めました。
このあたりは設計思想というか そのあたりによって考え方が変わると思います。

public class LogRecordConverter {

    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("uuuu-MM-dd HH:mm:ss.SSS");

    private static final Map<Level, String> levelMsgMap = Collections.unmodifiableMap(new HashMap<Level, String>() {
        private static final long serialVersionUID = 1L;

        {
            put(Level.SEVERE, "SEVERE");
            put(Level.WARNING, "WARN");
            put(Level.INFO, "INFO");
            put(Level.CONFIG, "CONF");
            put(Level.FINE, "FINE");
            put(Level.FINER, "FINE");
            put(Level.FINEST, "FINE");
        }
    });

    private static final AtomicInteger nameColumnWidth = new AtomicInteger(32);

    private final LocalDateTime dateTime;
    private final Level logLevel;
    private final String className;
    private final String methodName;
    private final Throwable throwable;
    private final String message;

    private LogRecordConverter(LocalDateTime dateTime, Level logLevel, String className, String methodName, Throwable throwable, String message) {
        this.dateTime = dateTime;
        this.logLevel = logLevel;
        this.className = className;
        this.methodName = methodName;
        this.throwable = throwable;
        this.message = message;
    }

    public static LogRecordConverter of(LocalDateTime dateTime, Level logLevel, String className, String methodName, Throwable throwable, String message) {
        return new LogRecordConverter(dateTime, logLevel, className, methodName, throwable, message);
    }

    public String toConsole() {
        StringBuilder sb = new StringBuilder(300);
        this.appendCommonRecord(sb);
        this.appendShorteningCategoryAndMessage(sb);

        if (this.throwable != null) {
            sb.append(System.lineSeparator());
            this.appendThrown(sb);
        }
        return sb.toString();
    }

    public String toFile() {
        StringBuilder sb = new StringBuilder(300);
        this.appendCommonRecord(sb);
        this.appendCategoryAndMessage(sb);

        sb.append(System.lineSeparator());
        if (this.throwable != null) {
            this.appendThrown(sb);
        }
        return sb.toString();
    }

    private void appendCommonRecord(StringBuilder sb) {
        sb.append(this.timeStamp());
        sb.append(" ");

        sb.append(levelMsgMap.get(this.logLevel));
        sb.append(" ");
    }

    private void appendShorteningCategoryAndMessage(StringBuilder sb) {
        int width = nameColumnWidth.intValue();
        String category = adjustCategoryLength(baseCategory(), width);
        sb.append("[[");
        sb.append(category);
        sb.append("]] ");
        this.updateNameColumnWidth(width, category.length());

        sb.append(message);
    }

    private void appendCategoryAndMessage(StringBuilder sb) {
        sb.append("[[");
        sb.append(baseCategory());
        sb.append("]] ");
        sb.append(message);
    }

    private void appendThrown(StringBuilder sb) {
        StringWriter sw = new StringWriter();
        try (PrintWriter pw = new PrintWriter(sw)) {
            this.throwable.printStackTrace(pw);
        }
        sb.append(sw.toString());
    }

    private String timeStamp() {
        return formatter.format(dateTime);
    }

    private String baseCategory() {
        return className == null
               ? ""
               : methodName == null
                 ? className
                 : className + "#" + methodName;
    }

    private String adjustCategoryLength(String packageName, int aimLength) {

        int overflowWidth = packageName.length() - aimLength;

        String[] fragment = packageName.split(Pattern.quote("."));
        for (int i = 0; i < fragment.length - 1; i++) {
            if (1 < fragment[i].length() && 0 < overflowWidth) {

                int cutting = (fragment[i].length() - 1) - overflowWidth;
                cutting = (cutting < 0) ? (fragment[i].length() - 1) : overflowWidth;

                fragment[i] = fragment[i].substring(0, fragment[i].length() - cutting);
                overflowWidth -= cutting;
            }
        }

        String result = String.join(".", fragment);

        int cnt = aimLength - result.length();
        if (cnt <= 0) {
            return result;
        }
        String blank = new String(new char[cnt]).replace("\0", " ");
        return result + blank;
    }

    private void updateNameColumnWidth(int width, int categoryLength) {
        if (width < categoryLength) {
            nameColumnWidth.compareAndSet(width, categoryLength);
        }
    }
}


Handlerへの設定は、コンテナ起動時に一度だけ実施すれば良いので、以下のクラスを作りました。*1

Java EEアプリケーションで起動時になにかしらの処理をする方法 — 裏紙
を参考にして、Serveletに依存させず、CDIだけで 初期化処理を行っています。

@ApplicationScoped
public class LoggerLifecycleHandler {

    private static final Logger rootLogger = Logger.getLogger(LoggerName.ROOT_NAME);
(略)

    public void startUp(@Observes @Initialized(ApplicationScoped.class) Object event) {
        System.out.println(">> Startup:Initialize RootLogger >>");
        LoggerInitializer.builder()
                .rootLogger(rootLogger)
                .propertiesFilePath("/logging.properties")
                .consoleHandlerClass(LogConsoleHandler.class, LogConsoleFormatter.class)
                .fileHandlerClass(LogFileHandler.class, LogFileFormatter.class)
                .execute();

  (略)
    }

    public void shutdown(@Observes @Destroyed(ApplicationScoped.class) Object event) {
        System.out.println("<< Cleanup:Closing logging file <<");
        LogFileCloser logFileCloser = new LogFileCloser();

        Collections.list(LogManager.getLogManager().getLoggerNames()).stream()
                .filter(name -> name.startsWith(LoggerName.ROOT_NAME))
                .forEach(logFileCloser::close);
    }
}


初期設定するクラスLoggerInitializerにおけるポイントは
setUseParentHandlersです。

これも、ConsoleとFileで扱いが微妙に違ったというか、そういう理由です。

Fileだと、setUseParentHandlers(false)を指定しなくても問題ないのですが、Consoleは デフォルトのLoggerに伝播しているか、EEサーバ側が内部的に伝播しているか 良く分かりませんが、2行ログが出力されるために必要な実装でした。

public class LoggerInitializer {

(略)

    public static class Builder implements RootLoggerBuilder, PropertiesFileBuilder {
        public void execute() {

            this.initConsoleFormatter();
            this.setConsoleHander();

            this.initFileFormatter();
            this.setFileHander();

            this.rootLogger.setUseParentHandlers(false);
        }

(略)

}

Interceptorとの連携

Java EEのCDIで定義しておくと便利なプロデューサーとインターセプタ - きしだのはてな を参考にさせていただきました。

InvocationContext を使うことで、実行元の情報を取得する事が出来ます。

ただ、このやり方だとGCのタイミングで問題が起こる可能性があるかもしれないです。

参考
ログレベルが突然変わる謎の事象を追う ~ あるOSSサポートエンジニアの1日 - Qiita

ということで、上述で示した 強参照の RootLogger配下に強制するように Loggerの生成は、簡単なクラス(LogFactory)を作りました。
ただし、直接使うのではなく、InterceptorとかProducer経由でLoggerを生成する前提にしようと思っていたので、このクラスについては default*2にしました。

class LoggerFactory {

    private LoggerFactory() {
    }

    static Logger getLogger(String name) {
        return Logger.getLogger(LoggerName.ROOT_NAME + "." + name);
    }

}

加えて、InvocationContextからLoggerに必要な情報を取得できるようなクラスを作りました。

public class InvocationContextLogger {

    private final String actionClass;
    private final String actionMethod;
    private final Logger logger;

    private InvocationContextLogger(Logger logger, String actionClass, String actionMethod) {
        this.logger = logger;
        this.actionClass = actionClass;
        this.actionMethod = actionMethod;
    }

    public static InvocationContextLogger getLogger(InvocationContext ic) {
        String actionClass = ic.getTarget().getClass().getSuperclass().getName();
        String actionMethod = ic.getMethod().getName();
        Logger logger = LoggerFactory.getLogger(actionClass);
        return new InvocationContextLogger(logger, actionClass, actionMethod);
    }

    public void severe(Supplier<String> msgSupplier) {
        logger.logp(Level.SEVERE, actionClass, actionMethod, msgSupplier);
    }

(省略)

}


実際に使用しているところ(抜粋)。
当初、LogeerFactoryを使うやり方で考えていたのですが、戻り値を InvocationContextLoggerとすることを鑑みて、別クラスにしました。
ちなみに、Loggerがインターフェースだったら 方式を変えた可能性があります。

@Action
@Interceptor
@Priority(Interceptor.Priority.APPLICATION + 5)
public class ActionInterceptor {

(略)
    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {

     (略)

        InvocationContextLogger logger = InvocationContextLogger.getLogger(ic);
        try {
            logger.fine(() -> "start");
            return ic.proceed();
        } finally {
            logger.fine(() -> "end");
        }

    }
}

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

いったん、基本的な実装まで。 次にデバッグ用にアイディアネタ的な仕組みも実装したので、それについて書きたいと思います。


*1:過去の記事でも 取り上げ済みの再掲です

*2:パッケージプライベート

Loggerファイルの初期生成と破棄

vermeer.hatenablog.jp

ちょっとだけ 忘れないようにしておきたいトピックがあったので そこだけの抜粋です。

アプリケーションを経由してログ出力をすると hoge.log.lckというファイルが残ってしまうので 能動的にCloseする必要があります。

参考としては こちらが詳しいです。

Java EE インジェクト可能なロガーの作り方と注意点 - Qiita

これで目的は十分達成できるのですが

  • DIを管理するクラスに直接ロジックを書くことを避けたかった
  • 開始・終了制御は CDIで完結させたかった

ということで、あれこれやりました。

差分コード

vermeer_etc / jsf-ddd / commit / 006cd7411230 — Bitbucket

抜粋するのは生成と破棄のところです。*1

@ApplicationScoped
public class LoggerLifecycleHandler {

    private static final Logger rootLogger = Logger.getLogger("root");

    public void startUp(@Observes @Initialized(ApplicationScoped.class) Object event) {
        System.out.println(">> Startup:Initialize RootLogger >>");
        LoggerInitializer.builder()
                .rootLogger(rootLogger)
                .propertiesFilePath("/logging.properties")
                .consoleHandlerClass(LogConsoleHandler.class, LogConsoleFormatter.class)
                .fileHandlerClass(LogFileHandler.class, LogFileFormatter.class)
                .execute();
    }

    public void shutdown(@Observes @Destroyed(ApplicationScoped.class) Object event) {
        System.out.println("<< Cleanup:Closing logging file <<");
        new LogFileCloser().close("root");
    }

}

@Observes @Destroyedは メソッドアノテーション@PreDestroyでも同じことが出来るのは確認したのですが、開始と終了との実装のバランスを鑑みてあわせました。

参考資料

Java EEアプリケーションで起動時になにかしらの処理をする方法 — 裏紙

java - CDI 1.1: Is @Observes @Initialized(TransactionScoped.class) supposed to work? - Stack Overflow

Loggerについての補足

Payaraで試しましたが、CosoleLogHandlerに何かしらフォーマットを適用しようとしたら、標準エラーログになってしまいます。
Loggerとして logger.infoとしてもエラーになります。
残念ながら、この解決方法は分かりませんでした。


*1:初期起動時と終了時の破棄に関して同じようなことを調べる可能性があると思っての抜粋

Loggingのメモ

参考資料


まずは、この記事を きちんと読む
java.util.loggingの使い方 - Qiita

そのあとで、この記事を読む
JUL を少しマシにする - A Memorandum


入門から実践までJavaで学べる「ログ」の常識 (1/4):プログラマーの常識をJavaで身につける(10) - @IT

[Java] 標準APIのLoggerを使用してログを外部ファイルに出力する。 | DevelopersIO

7.1. ロギング — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.2.1.RELEASE documentation

CDIのInjectionPointを使ってみる - CLOVER🍀

JavaEE7をはじめよう(18) - CDI インターセプターとステレオタイプ - エンタープライズギークス (Enterprise Geeks)

Java EEのCDIで定義しておくと便利なプロデューサーとインターセプタ - きしだのはてな

Java TM ロギングの概要

アプリへのslf4j + logback 導入時の java.util.logging 向け対処 - Qiita

[Java]Java SE標準ロギングのプロパティ設定で複数のファイルにログを分けて出力する - torutkのブログ

Java EE インジェクト可能なロガーの作り方と注意点 - Qiita

ログレベルが突然変わる謎の事象を追う ~ あるOSSサポートエンジニアの1日 - Qiita

Payara のログ・ビューワの制限事項 (ja) - notepad

java - 複数のパラメータによるロギング - 答えた

java.util.logging - FileHandler.patternで指定したディレクトリを作成する - Kazzzの日記

logging - Java - configure custom loggers for use - Stack Overflow

メモ

JavaのLoggerは実装ライブラリを、DIなどで置き換えられるように インターフェースと実装を分離させるようなことはしない方が良い気がする。
理由は、既に十分に抽象化で混とんとしているから。
とりあえず標準Loggerをインターフェースとして標準Loggerは実装クラスなので実装置き換えということは出来ませんが、slf4jを使うことにしたときに一気に書き換えをするという割り切りをしておけばいいかなと今は考えています。
それに基本的なLoggingについては、Interceptor経由で出力する前提で考えているから影響範囲も かなり限定的だと思っています。

メモ2

資料を参考に注意することなどの整理*1

  • Loggerはstatic final なフィールドで保持して強参照にする*2
  • Logger生成時に強制的にRootLogger配下に属するようにするべき?
  • Formatterを作って出力情報をきちんとする
  • FileHandlerは独自のFileHandlerを作って出力先を振り分けできる
  • WebApplicationの場合はLoggerのCloseをする
  • 設定情報はpropertiesを参照する仕組みとして独自のpropertiesがない場合の考慮も忘れずに
  • Formatterは共通実装で良い(が良い)
  • Handlerで出力制御が確定する
  • EEサーバー固有のpropertiesと衝突するのでLogManager.getLogManager().readConfiguration()を使ってはいけない
  • 各種制御をするのは独自パーツのみと割り切る(各種環境設定との衝突を避ける)
  • ログ出力制御はpropertiesもあるけど、Filterもある。独自制御の場合はFilterの方が良いかもしれない

メモ(Payara)

  • 独自ログの出力先指定は絶対パス指定にする。
    相対パスだと C:\payara5\glassfish\domains\domain1\config配下に作成されるので注意すること。

  • 独自フォーマットは標準出力としては エラー扱いになる?
    多分、これが根拠になりそう。
    GlassFish 4.1 のロギング (ja) - notepad

こうなると もはや JULは ミドル用のLoggerで、LogBackなどのライブラリをアプリケーション用のLoggerとして使う として大きく振り切った方が 安全な気がしてきた。
欠点は、Payaraだと LogViewerが使えたりするけど、その恩恵も捨てるということになるところ。 まぁLog監視は そもそもPayaraでやらずにログ監視用サーバーでやるべきかもしれない。

FileHanderについては指定したフォーマットをそのまま使ってくれているようだということは確認が出来ているので及第点かなぁ。

Code

Loggerの実装についてコミットログ(雑に)

vermeer_etc / jsf-ddd / commit / 006cd7411230 — Bitbucket


*1:当たり前を当たり前として認識することも含めて

*2:そうしないとGCでLoggerインスタンスが破棄される可能性がある

Lambdaを使って内部リストを暴露しないで処理をする

最近、ちょっとずつStreamAPIだけでなく、Lambdaを使うことに少しずつ慣れてきつつあります。

Lambdaは無名クラスが簡単に使えるくらいの印象しか持っていなかったのですが、遅延評価の仕組みとして 徐々に使い方が分かってくると 何でもトンカチで殴りたくなるものです。 というわけではないのですが、自分で実装してみて「これは 良いかも」と思ったので ご紹介。

よくやる実装

ファーストクラスコレクションを作って構造と責務を閉じようとするのですが、どうしても内部コレクションを公開したいことはあります。
特に 副作用のある操作をしたい場合に、どうしても そういう実装をせざるを得ないケースです。 そういうときは Collections#unmodifiableList を使って不変なリストを返却するようなメソッドを実装して対応しています。

public class Callee {

    private List<String> items;

    public Callee() {
        this.items = Arrays.asList("aaa", "bbb", "ccc", "ddd");
    }

    public List<String> items() {
        return Collections.unmodifiableList(items);
    }
}
public class Caller {

    public void useList() {
        Callee callee = new Callee();
        List<String> items = callee.items();
        items.stream().forEachOrdered(System.out::println);
    }

}

Lambdaを使って構造公開しない実装

public class Callee {

    private List<String> items;

    public Callee() {
        this.items = Arrays.asList("aaa", "bbb", "ccc", "ddd");
    }

    public void forEachOrdered(Consumer<? super String> action) {
        this.items.stream().forEachOrdered(item -> action.accept(item));
    }

}


ポイントは List<String> items を外部公開することなく 処理が行えているところです。

public class Caller {

    public void useLambda() {
        Callee callee = new Callee();
        callee.forEachOrdered(System.out::println);
    }

}

補足

内部リストの公開以外にもドメインオブジェクト(ファーストクラスコレクション)側に外部依存が少なくなるのも嬉しいところです。

例えば DBアクセスのような処理です。 update(Connection conn, String userId)みたいなメソッドを準備すれば良いんじゃないの?というのが容易に想像できるわけですが、それだと ドメインオブジェクトにインフラ層のパッケージへの依存が生まれます。私としては それを回避できるのも嬉しいところです。*1

気をつけること

当たり前ですが なんでも、このやり方でやるのは良くないです。
Collections.unmodifiableList で不変を担保する代替にはなると思いますが、責務の放棄までしない方が良いです。
例えば、Caller側の情報を使うこともなく、副作用の伴う処理をしているわけでもない、ただ繰り返し処理をするだけというのであれば 目的としている使い方ではありません。それは Collections.unmodifiableListで公開していることと同じことになってしまうので、きちんとドメインオブジェクト側で メソッドとして準備をして、それを使うようにすべきだと思います。

さいごに

なんでも Lambda、なんでも StreamAPI、は正しいとは思いませんが、だからといって忌避することなく、少しずつ使ってみて 理解を深めておくことは大事かな、と思います。 私もまだまだ理解も実践も乏しいところがあります。
「おっ、おもしろそう」と何かしらの一助になれば幸いです。

*1:もちろん、緩衝材としてインターフェースを介すれば問題ないよね?というのもありますし、それはそれで有効な手段だと思います。

JSFでツールチップ

vermeer.hatenablog.jp

の続きと言うか、ついでに。

やりたいこと

エラーメッセージを入力領域にツールチップを出力する

やりかた

xhtmlのinput要素のtitleにエラーメッセージを出力する

前回の記事と ほぼ同じ発想です。

実装

Titleに出力メッセージを扱うクラス

BeanValidationExceptionのInterceptorで

  • 画面上のコンポーネントを取得して通常のIDとJSFのClinetIdを関連付けたクラス
  • エラーとなったClientIdとメッセージのリスト

をマージして、Titleに出力するメッセージ用のクラスを編集します。

@Action
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
@Dependent
public class BeanValidationExceptionInterceptor {

    private final CurrentViewContext context;
    private final MessageConverter messageConverter;
    private final MessageWriter messageWriter;
    private final ClientComplementManager clientComplementManager;
    private final ErrorStyle errorStyle;
    private final ErrorTooltip errorTooltip;

    @Inject
    public BeanValidationExceptionInterceptor(CurrentViewContext context,
                                              MessageConverter messageConverter, MessageWriter messageWriter,
                                              ClientComplementManager clientComplementManager,
                                              ErrorStyle errorStyle, ErrorTooltip errorTooltip) {
        this.context = context;
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
        this.clientComplementManager = clientComplementManager;
        this.errorStyle = errorStyle;
        this.errorTooltip = errorTooltip;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {

        String currentViewId = context.currentViewId();

        try {
            return ic.proceed();
        } catch (BeanValidationException ex) {

            ClientIdsWithComponents clientIdsWithInputComponents = new InputComponentScanner().scan();

            ClientIdMessages clientidMessages
                             = messageConverter.toClientIdMessages(ex.getValidatedResults(),
                                                                   ic.getTarget().getClass().getSuperclass(),
                                                                   clientIdsWithInputComponents);

            ClientIdsWithComponents clientIdsWithHtmlMessages = new HtmlMessageScanner().scan();
            messageWriter.appendErrorMessageToComponent(clientidMessages.toClientIdMessagesForWriting(clientIdsWithHtmlMessages));

            FacesContext.getCurrentInstance().validationFailed();
            clientComplementManager.setClientidMessages(clientidMessages);

            this.errorStyle.set(clientIdsWithInputComponents, clientidMessages);

            //ツールチップ用のインスタンスに情報を渡します
            this.errorTooltip.set(clientIdsWithInputComponents, clientidMessages);
            return currentViewId;
        }

    }
}


情報を保持するクラス

@Named
@RequestScoped
public class ErrorTooltip {

    private ClientIdsWithComponents clientIdsWithComponents;
    private ClientIdMessages clientIdMessages;

    @PostConstruct
    private void init() {
        this.clientIdsWithComponents = new ClientIdsWithComponents();
        this.clientIdMessages = new ClientIdMessages();
    }

    public void set(ClientIdsWithComponents clientIdsWithInputComponents, ClientIdMessages clientIdMessages) {

        Set<String> clientIds = clientIdMessages.getList().stream()
                .map(ClientIdMessage::getClientId)
                .collect(Collectors.toSet());

        this.clientIdsWithComponents = clientIdsWithInputComponents.filter(clientIds);
        this.clientIdMessages = clientIdMessages;
    }

    /**
     * 指定したIDの項目が検証不正だった場合に適用する メッセージ を返却します.
     * <P>
     * xhtmlでのパラメータ指定時には、シングルクウォートで値を指定してください.
     *
     * @param id 対象となるコンポーネントのID(JSFのクライアントIDではありません)
     * @return 当該項目IDにエラーがない場合は 空文字を返却します.
     */
    public String byId(String id) {
        String clientId = this.clientIdsWithComponents.getOrNull(id);
        if (clientId == null) {
            return "";
        }
        return clientIdMessages.getMessage(clientId);
    }

    /**
     * 指定したClientId(フルパス)の項目が検証不正だった場合に適用する メッセージ を返却します.
     *
     * @param clientId 対象となるコンポーネントのID(JSFのクライアントIDではありません)
     * @return 当該項目IDにエラーがない場合は 空文字を返却します.
     */
    public String byClientId(String clientId) {
        return clientIdMessages.getMessage(clientId);
    }

}

xhtmlの記述

画面IDとクライアントID、いずれからでもメッセージを取得するメソッドを準備していますが、input要素に直接出力するので 通常は クライアントIDをパラメータとするメソッドを 使うことになると思います。
引数はcomponent.clientIdを使えば、当該コンポーネントのクライアントIDが取得できます。

<div class="field">
    <label>利用者ID</label>
    <input jsf:styleClass="short-input" type="text" placeholder="someone@example.com"
           jsf:id="email" jsf:value="#{userRegistrationPage.email}"
           title="#{errorTooltip.byClientId(component.clientId)}"/>

</div>

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいご

まだ繰り返し領域に関する制御は保留だけど
とりあえず、これで メッセージ関連は一段落かな?

JSFでエラーフィールドの背景色を変える(3)

vermeer.hatenablog.jp

の続き。

前回の記事で 課題とした

https://github.com/system-sekkei/isolating-the-domain
のように 背景色というよりも、指定領域のスタイルを変更する という要件には応えられていません。

について、今回は取り組みたいと思います。

やりたいこと

  • エラー対象に関連付けた任意の場所のスタイルを変更する
  • (ついでに)jsf:styleClassを使わないHTML Friendlyなxhtmlの記述ができる

やりかた

  • xhtmlで 関連付け対象のIDを引数としてスタイルを返却する

イメージは、

JSFでエラーのある項目の背景色を変える - じゃばらの手記

に近しいですが、異なるのは component.clientIdではなく、通常のIDを指定するところです。 ここまでに、通常のIDとJSFのClinetIdを関連付ける仕組みを実装済みなので、それを使います。

実装

xhtmlから使用するスタイル制御を行うクラス

BeanValidationExceptionのInterceptorで

  • 画面上のコンポーネントを取得して通常のIDとJSFのClinetIdを関連付けたクラス
  • エラーとなったClientIdのリスト

を取得しているので、それらをマージして目的の判定を行う情報を編集します。

@Action
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
@Dependent
public class BeanValidationExceptionInterceptor {

    private final CurrentViewContext context;
    private final MessageConverter messageConverter;
    private final MessageWriter messageWriter;
    private final ClientComplementManager clientComplementManager;
    private final ErrorStyle errorStyle;

    @Inject
    public BeanValidationExceptionInterceptor(CurrentViewContext context,
                                              MessageConverter messageConverter, MessageWriter messageWriter,
                                              ClientComplementManager clientComplementManager, ErrorStyle errorStyle) {
        this.context = context;
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
        this.clientComplementManager = clientComplementManager;
        this.errorStyle = errorStyle;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {

        String currentViewId = context.currentViewId();

        try {
            return ic.proceed();
        } catch (BeanValidationException ex) {

            ClientIdsWithComponents clientIdsWithInputComponents = new InputComponentScanner().scan();

            ClientIdMessages clientidMessages
                             = messageConverter.toClientIdMessages(ex.getValidatedResults(),
                                                                   ic.getTarget().getClass().getSuperclass(),
                                                                   clientIdsWithInputComponents);

            ClientIdsWithComponents clientIdsWithHtmlMessages = new HtmlMessageScanner().scan();
            messageWriter.appendErrorMessageToComponent(clientidMessages.toClientIdMessagesForWriting(clientIdsWithHtmlMessages));

            FacesContext.getCurrentInstance().validationFailed();
            clientComplementManager.setClientidMessages(clientidMessages);

            //ここで、エラースタイルを管理するクラスへ情報を渡す
            this.errorStyle.set(clientIdsWithInputComponents, clientidMessages);
            return currentViewId;
        }

    }
}


実際に状態を保持するクラス

@Named
@RequestScoped
public class ErrorStyle {

    private ClientIdsWithComponents clientIdsWithComponents;

    private String errorStyle;

    @PostConstruct
    private void init() {
        this.errorStyle = "error";
        this.clientIdsWithComponents = new ClientIdsWithComponents();
    }

    public void set(ClientIdsWithComponents clientIdsWithInputComponents, ClientIdMessages clientidMessages) {

        Set<String> clientIds = clientidMessages.getList().stream()
                .map(ClientIdMessage::getClientId)
                .collect(Collectors.toSet());

        this.clientIdsWithComponents = clientIdsWithInputComponents.filter(clientIds);
    }

    /**
     * 指定したIDの項目が検証不正だった場合に適用する styleClass を返却します.
     * <P>
     * xhtmlでのパラメータ指定時には、シングルクウォートで値を指定してください.
     *
     * @param id 対象となるコンポーネントのID(JSFのクライアントIDではありません)
     * @return 当該項目IDにエラーがない場合は 空文字を返却します.
     */
    public String byId(String id) {
        return this.clientIdsWithComponents.getOrNull(id) == null ? "" : errorStyle;

    }

}

xhtml側での記述

エラーになった際に関連付けさせてCSSクラス文字列を返したいところに EL式を記述します。 ポイントは xhtmlのIdを そのまま使っているところです。

また、jsf:styleClassで記述していた箇所を classとすることで、HTML Friendlyな記述が出来ました。
これで デザイナーとの協業も実現できますね。

気をつけるのは、Idの指定は シングルクウォートで囲むというところです。*1

<div class="field #{errorStyle.byId('email')}">
    <label>利用者ID</label>
    <input class="short-input" type="text" placeholder="someone@example.com" jsf:id="email" jsf:value="#{userRegistrationPage.email}"/>
    <h:message for="email" styleClass="error-message ui left pointing red basic label" />
</div>

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

結局のところ、xhtmlで記述したIDと JSFが導出するIDとの関連付けを どうにかすれば色んなことが出来るんですよね。
私は自作をしましたが*2 JSFのオリジナルの部品として、そういうのを扱うAPIなりクラスがあれば もっと使いやすくなるんじゃないでしょうかねぇ。*3


*1:ダブルクウォートで起動時にエラーになったのは凡ミス。。

*2:まだ繰り返し領域についての扱いは未対応ですが

*3:JavaDocを全て見たわけではないので、あるかもしれませんが

JSFでエラーフィールドの背景色を変える(2)

vermeer.hatenablog.jp

の続きです。

私の仕組みでは、Application層の検証不正に対しても画面項目と関連付け出来るようにしています。
そこで その仕組みを利用して 先のパターンに独自の実装を追加します。

やりたいこと

メッセージ出力に関係なく、任意の入力項目の背景色を変更する

やりかた

  • BeanValidationExceptionの対象となったUIInputのClientIdを取得して出力先情報として保持
  • PhaseListnerで保持した情報を使う

前回のやり方は getClientIdsWithMessagesを使って、h:messageが出力対象になっている事が前提になっていますが、このやり方であれば h:messageの有無に関係なく背景色を変えられます。

実装

UIInputのClientIdを取得

前回の h:message に関する情報を取得するだけでしたが、今回は コンポーネント内すべてのUIInputのClientIdも取得します。

この情報とBeanValitionの結果をPhaseListnerで参照するようにします。

なお、メッセージ出力に関係する箇所は、前回同様 h:message に関する情報を元に編集をします。

@Action
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
@Dependent
public class BeanValidationExceptionInterceptor {

    private final CurrentViewContext context;
    private final MessageConverter messageConverter;
    private final MessageWriter messageWriter;
    private final ClientComplementManager clientComplementManager;

    @Inject
    public BeanValidationExceptionInterceptor(CurrentViewContext context, MessageConverter messageConverter, MessageWriter messageWriter,
                                              ClientComplementManager clientComplementManager) {
        this.context = context;
        this.messageConverter = messageConverter;
        this.messageWriter = messageWriter;
        this.clientComplementManager = clientComplementManager;
    }

    @AroundInvoke
    public Object invoke(InvocationContext ic) throws Exception {

        String currentViewId = context.currentViewId();

        try {
            return ic.proceed();
        } catch (BeanValidationException ex) {

            ClientIdsWithComponents clientIdsWithInputComponents = new InputComponentScanner().scan();

            ClientIdMessages clientidMessages
                             = messageConverter.toClientIdMessages(ex.getValidatedResults(),
                                                                   ic.getTarget().getClass().getSuperclass(),
                                                                   clientIdsWithInputComponents);

            ClientIdsWithComponents clientIdsWithHtmlMessages = new HtmlMessageScanner().scan();
            messageWriter.appendErrorMessageToComponent(clientidMessages.toClientIdMessagesForWriting(clientIdsWithHtmlMessages));

            FacesContext.getCurrentInstance().validationFailed();
            clientComplementManager.setClientidMessages(clientidMessages);

            return currentViewId;
        }

    }
}

PhaseListner

UIInputのClientIdと検証不正対象となった入力項目の関連付けを保持したインスタンスを参照先として追加します。

public class InputFieldColorHandlerPhaseListner implements PhaseListener {

    private static final long serialVersionUID = 1L;

    @Override
    public void afterPhase(PhaseEvent phaseEvent) {
    }

    @Override
    public void beforePhase(PhaseEvent phaseEvent) {
        FacesContext context = phaseEvent.getFacesContext();

        InputFieldColorHandler fieldColorHandler = CDI.current().select(InputFieldColorHandler.class).get();
        ClientComplementManager clientComplementManager = CDI.current().select(ClientComplementManager.class).get();
        fieldColorHandler.updateErrorFieldColor(context, clientComplementManager);
    }

    @Override
    public PhaseId getPhaseId() {
        return PhaseId.RENDER_RESPONSE;
    }

}


実際のUIComponentの更新をしているクラス

既存の仕組み(updateColorHtmlMessage)も残しつつ、新たな制御(updateColorInputComponent)を追加します。

@ApplicationScoped
public class InputFieldColorHandler {

    private String errorClass;

    @PostConstruct
    public void init() {
        this.errorClass = "error-field";
    }

    public void updateErrorFieldColor(FacesContext context, ClientComplementManager clientComplementManager) {
        this.clearErrorColor(context);

        if (context.isValidationFailed() == false) {
            return;
        }

        this.updateColorHtmlMessage(context);
        this.updateColorInputComponent(context, clientComplementManager);
    }

    private void clearErrorColor(FacesContext context) {

        recursiveScan(context.getViewRoot().getChildren())
                .forEach(c -> {
                    String styleClass = String.valueOf(c.getAttributes().get("styleClass"));
                    if (styleClass != null && styleClass.contains(errorClass)) {
                        c.getAttributes().put("styleClass", styleClass.replace(errorClass, "").trim());
                    }
                });

    }

    private Set<UIComponent> recursiveScan(List<UIComponent> components) {
        Set<UIComponent> set = new HashSet<>();
        if (components == null) {
            return set;
        }

        components.forEach(component -> {
            set.add(component);
            set.addAll(recursiveScan(component.getChildren()));
        });
        return set;
    }

    private void updateColorHtmlMessage(FacesContext context) {
        context.getClientIdsWithMessages().forEachRemaining(clientId -> {
            if (clientId == null) {
                return;
            }
            UIComponent component = context.getViewRoot().findComponent(clientId);
            String styleClass = String.valueOf(component.getAttributes().get("styleClass"));
            if (styleClass != null) {
                component.getAttributes().put("styleClass", styleClass.trim() + " " + errorClass);
            }
        });
    }

    private void updateColorInputComponent(FacesContext context, ClientComplementManager clientComplementManager) {
        clientComplementManager.clientIds().stream()
                .forEach(clientId -> {
                    UIComponent component = context.getViewRoot().findComponent(clientId);
                    String styleClass = String.valueOf(component.getAttributes().get("styleClass"));
                    if (styleClass != null && styleClass.contains(errorClass) == false) {
                        component.getAttributes().put("styleClass", styleClass.trim() + " " + errorClass);
                    }
                });
    }

}

Code

vermeer_etc / jsf-ddd / source / — Bitbucket

さいごに

背景色の変更は、概ね これで良いかなと思います。
ただ、参考にした

https://github.com/system-sekkei/isolating-the-domain

のように 背景色というよりも、指定領域のスタイルを変更する という要件には応えられていません。

つぎは、そのあたりかな?