2023年12月 6日 (水)

Spring Bootを最新化した(通算3度目)

0. 重い腰を上げて(ry

弊社で稼働中の受注監視バッチは、2度のバージョンアップを経てSpring Boot 2.7で稼働していたのだが、このほどSpring Boot 2.7/3.0のOSSサポートが切れる、ということで、必要に駆られてSpring Bootの最新化を敢行した。

結論から言うと、今までのバージョンアップで一番きつかったorz

1. 当初「やること」と思っていたこと

2.7からのアップグレードになるので、やること自体はSpring Boot 3.0 Migration Guideをもとに行うのだが、弊社の場合Webモジュールよりもバッチモジュールのほうが主体なので、世の中でよく言われている(と思っている)

  • Spring Securityに由来する諸々の変更
  • Jakarta EE 10への対応
  • Spring MVCにおけるエンドポイントの末尾スラッシュの取り扱い

のほか、Spring Batchの更新が主体だった。
が、これが鬼門だった。

2. Spring Boot 3.0にしてからの作業

Spring Boot 3.xはJava 17が必須なので、これを扱えるGradle 7.5以降にGradle Wrapperを更新したのち、Spring Bootのバージョンを3.0系列の最新版(当時)の3.0.12にひとまず設定。

Spring MVCの話は結論から言うと特に何もすることはなかったので割愛するが、それ以外の対応でここからが長丁場になる。

2-1. Spring Securityに由来する諸々の変更

Spring Securityは6.0が標準となるなるので、その前にある5.8のマイグレーションガイドをもとに、

  • PasswordEncoderの更新
  • Servlet周りの更新

を行っていく。

PasswordEncoderは、デフォルトがDelegatingPasswordEncoderから呼ばれるBCryptだそうなので、PasswordEncoderのBean定義を

  @Bean
  public PasswordEncoder passwordEncoder() {
    //return new BCrypotPasswordEncoder();
    return PasswordEncoderFactories.createDelegatingPasswordEncoder();
  }

に書き換えた上で、

  UPDATE APP_USER SET PASSWD = CONCAT('{bcrypt}', PASSWD)
  WHERE PASSWD NOT LIKE '{bcrypt}%';

と、DelegatingPasswordEncoderがBCryptを選択できるようにしておく。
SQL自体は、FlyWayのマイグレーションに組み込んでおけば確実に実行される。

その他Servlet周りの更新については、該当するものが

の3点だったので、それぞれを対応。とはいえ、ほぼマイグレーションガイドの記載のとおりなんだが。

2-2. Jakarta EE 10への対応

データアクセスやServletがもろに影響を受けるjavax.*からjakarta.*へのパッケージ移動だが、弊社の環境ではJAXBとMailSenderも影響下にあった。

メール送信テストでGreenMailという組み込みメールサーバを使っているが、こちらも必要に駆られて更新。

  dependencies {
    def greenMailVer = "2.1.0-alpha-3"
    testImplementation("com.icegreen:greenmail:$greenMailVer")
    testImplementation("com.icegreen:greenmail-junit5:$greenMailVer")
    testRuntimeOnly("org.eclipse.angus:angus-mail:2.0.2")
  }

最後のEclipse Angus MailはGreenMailが内部で利用しているもののようだが、GreeenMailからの呼び出しに失敗していたため、明示的にアーティファクトを追加することで対応。

また後工程のアーティファクトのビルド時に発覚することだが、JAXBもJakarta EE 10対応品の4.0に更新した際、jaxb-api.jar:4.0.3において

> アーティファクトの重複を検知したがどうするかが定義されていない

としてビルドが途中で止まるハプニングがあった。

JAXBはYahoo!ショッピングへのAPIアクセスで使っているため、ここのAPIを使う可能性のあるモジュールに

  bootJar {
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
  }

を追加することで対応した。

3. そしてSpring Batch

そして今回最大の難関、Spring Batchの更新である。

こちらも例によってSpring Batch 5.0 Migration Guideをもとに移行作業を行っていくわけだが、結論から言うと、メタデータはまるっと捨てた。

3-1. メタデータスキーマの構造変更

まず、BATCH_STEP_EXECUTIONとBATCH_JOB_EXECUTION_PARAMSに、カラムの追加削除が発生する。

DBMSがOracle DatabaseMicrosoft SQL Serverの場合は追加の作業が発生するが、弊社の場合MySQLなので、移行自体は

  ALTER TABLE BATCH_STEP_EXECUTION ADD CREATE_TIME DATETIME(6) NOT NULL DEFAULT '1970-01-01 00:00:00';
  ALTER TABLE BATCH_STEP_EXECUTION MODIFY START_TIME DATETIME(6) NULL;

  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS DROP COLUMN DATE_VAL;
  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS DROP COLUMN LONG_VAL;
  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS DROP COLUMN DOUBLE_VAL;

  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS CHANGE COLUMN TYPE_CD PARAMETER_TYPE VARCHAR(100);
  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS CHANGE COLUMN KEY_NAME PARAMETER_NAME VARCHAR(100);
  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS CHANGE COLUMN STRING_VAL PARAMETER_VALUE VARCHAR(2500);

を実行することで終わる。移行SQL自体はSpring Batchのソースの中、migration-mysql.sqlとして格納されているので、これを実行すればよいことになる。

が、これをそのまま実行すると少々面倒なことになることが後で判明する。

一方BATCH_JOB_EXECUTION.JOB_CONFIGURATION_LOCATIONカラムは使われなくなったのでドロップできる、とのこと。
ドロップする場合は以下の用に実行すればよいそうだ。

  ALTER TABLE BATCH_JOB_EXECUTION DROP COLUMN JOB_CONFIGURATION_LOCATION;

ただ、これ自体は実行していないので、カラムは残ったまま。

3-2. EnableBatchProcessing アノテーションの扱い変更

Spring Boot 3.0 Migration Guideには、

Previously, @EnableBatchProcessing could be used to enable Spring Boot’s auto-configuration of Spring Batch. It is no longer required and should be removed from applications that want to use Boot’s auto-configuration. A bean that is annotated with @EnableBatchProcessing or that extends Batch’s DefaultBatchConfiguration can now be defined to tell the auto-configuration to back off, allowing the application to take complete control of how Batch is configured.

とあり、以前のようにEnableBatchProcessingをつけているとSpring Bootの自動構成が止まるようになっている、とのこと。

早い話、EnableBatchProcessingの中にConigurationアノテーションがつかなくなったということなので、追加でConfigurationをバッチ定義クラスにつける。

この状態でテストを実行。見事にコケる。スタックトレースには、「データソースがない」などと出ている。

いろいろ調べた結果、Spring BootでSpring Batchを使う場合、

  • @EnableBatchProcessingあり → JDBCベースのデータソース構成とTransactionManagerが必要
  • @EnableBatchProcessingなし → Spring Bootが作った構成をそのまま使用

なので、バッチ定義クラスからEnableBatchProcessingを削除した。これ、よく見たら上で書いてるやつだな、うん。

3-3. ExecutionContextSerializerの変更

ステップ間でデータの引き渡しをするのに、ExecutionContextを使うケースがあるかと思う。弊社はまさにこれ。
実際には、引き渡すオブジェクトはJSON文字列としてやり取りされる。

Spring Batch 5から、com.fasterxml.jackson.core:jackson-coreがspring-batch-coreの依存から外れ、オプション扱いになり、これに伴い(というか後述の結果として、と言うべきか)ExecutionContextSerializerのデフォルト実装をJacksonExecutionContextStringSerializerからDefaultExecutionContextSerializerに変えた、とのこと。

このDefaultExecutionContextSerializer、既定でBASE64でシリアライズするので、保存対象のメンバーの型にはjava.io.Serializableの実装が必要だったりする。

したがって、java.nio.file.Pathなどは保存不可になっているのと、それまで使っていたJSONでシリアライズされた情報と互換性がなくなる。

やろうと思えばJSONのままにできるそうだが、EnableBatchProcessingを使ってExecutionContextSerializerへの参照を定義する必要がある。
これはとりもなおさず、Spring Bootがやってくれる自動構成をすべて自力で書く必要があることになるので、保存されているExecutionContextは廃棄することにした。

3-4. その他

その他細かいところで言えば、Taskletなんかで参照可能なステップの実行時刻などがjava.util.Dateからjava.time.LocalDateTimeに変わっていて、実行時間の計測処理に手を入れることになった。

4. そしてSpring Boot 3.2へ

移行作業の開始当初は、最新版が3.1だったのでそこを目標にしていたのだが、やってる最中にSpring Boot 3.2がGAになったので、急遽ターゲットを変更。

Spring Boot 3.1の段階で、Spring Data JPAの技術基盤であるHibernateが6.2になっており、OneToOneユニーク制約がつくように改修されている。
Hibernateプロジェクト曰く、「1対1対応という観点でいうと、ユニークがつかないのは不適」だそうで。

また本来位取り(scale)の概念がないdoubleやfloatといった型にscaleを定義していると怒られるようになったので、以下のいずれかの対応が必要。簡単なのは前者。

CIによるテストもパスしたので、本番デプロイに入ったところ、やはりというか問題発生。ExecutionContextの読み出しでソリッドに例外を吐く。キャストに失敗している模様。

スタックトレースをもとにSpring Batchのソースを追っていくと、該当箇所はこうなっていた。

JdbcJobExecutionDao

  @SuppressWarnings(value = { "unchecked", "rawtypes" })
  protected JobParameters getJobParameters(Long executionId) {
    final Map> map = new HashMap<>();
    RowCallbackHandler handler = rs -> {
      String parameterName = rs.getString("PARAMETER_NAME");

      Class parameterType = null;
      try {
        parameterType = Class.forName(rs.getString("PARAMETER_TYPE"));
      }
      catch (ClassNotFoundException e) {
        throw new RuntimeException(e);
      }
      String stringValue = rs.getString("PARAMETER_VALUE");
      Object typedValue = conversionService.convert(stringValue, parameterType);

      boolean identifying = rs.getString("IDENTIFYING").equalsIgnoreCase("Y");

      JobParameter jobParameter = new JobParameter(typedValue, parameterType, identifying);

      map.put(parameterName, jobParameter);
    };

    getJdbcTemplate().query(getQuery(FIND_PARAMS_FROM_ID), handler, executionId);

    return new JobParameters(map);
  }

上記14行目でResultSetの値からリフレクションをかけようとしていたのがわかったので、すぐさま

  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_TYPE = 'java.lang.Long' WHERE PARAMETER_TYPE = 'LONG';
  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_TYPE = 'java.lang.String' WHERE PARAMETER_TYPE = 'STRING';

を行おうとしたものの、先に LONG_VAL カラムをドロップしてしまっていたため、書き込むべき値がなくなってしまっていた。

このためすべてのテーブルのtruncateを試みたが、BATCH_JOB_EXECUTION、BATCH_STEP_EXECUTIONが外部参照によりtruncateできなかったため、結局のところ以下のような方法でデータを廃棄した。

  DELETE
  FROM BATCH_STEP_EXECUTION_CONTEXT
  WHERE STEP_EXECUTION_ID IN (SELECT BATCH_STEP_EXECUTION.STEP_EXECUTION_ID
                              FROM BATCH_STEP_EXECUTION
                              WHERE JOB_EXECUTION_ID IN (SELECT JOB_EXECUTION_ID
                                                         FROM BATCH_JOB_EXECUTION
                                                         WHERE STATUS IN ('COMPLETED', 'FAILED')));
  DELETE
  FROM BATCH_STEP_EXECUTION
  WHERE JOB_EXECUTION_ID IN (SELECT JOB_EXECUTION_ID FROM BATCH_JOB_EXECUTION WHERE STATUS IN ('COMPLETED', 'FAILED'));

  DELETE
  FROM BATCH_JOB_EXECUTION_CONTEXT
  WHERE JOB_EXECUTION_ID IN (SELECT JOB_EXECUTION_ID FROM BATCH_JOB_EXECUTION WHERE STATUS IN ('COMPLETED', 'FAILED'));

  DELETE
  FROM BATCH_JOB_EXECUTION_PARAMS
  WHERE JOB_EXECUTION_ID IN (SELECT JOB_EXECUTION_ID FROM BATCH_JOB_EXECUTION WHERE STATUS IN ('COMPLETED', 'FAILED'));

  DELETE
  FROM BATCH_JOB_EXECUTION
  WHERE STATUS IN ('COMPLETED', 'FAILED');

  DELETE
  FROM BATCH_JOB_INSTANCE
  WHERE JOB_INSTANCE_ID NOT IN (SELECT BATCH_JOB_EXECUTION.JOB_INSTANCE_ID FROM BATCH_JOB_EXECUTION);

  TRUNCATE TABLE BATCH_STEP_EXECUTION_SEQ;
  INSERT INTO BATCH_STEP_EXECUTION_SEQ (ID, UNIQUE_KEY)
  select *
  from (select 0 as ID, '0' as UNIQUE_KEY) as tmp
  where not exists(select * from BATCH_STEP_EXECUTION_SEQ);

  TRUNCATE TABLE BATCH_JOB_EXECUTION_SEQ;
  INSERT INTO BATCH_JOB_EXECUTION_SEQ (ID, UNIQUE_KEY)
  select *
  from (select 0 as ID, '0' as UNIQUE_KEY) as tmp
  where not exists(select * from BATCH_JOB_EXECUTION_SEQ);

  TRUNCATE TABLE BATCH_JOB_SEQ;
  INSERT INTO BATCH_JOB_SEQ (ID, UNIQUE_KEY)
  select *
  from (select 0 as ID, '0' as UNIQUE_KEY) as tmp
  where not exists(select * from BATCH_JOB_SEQ);

この結果を踏まえると、前述のマイグレーションSQLは以下のようにしたほうがよかった、といえるかもしれない。
ただ試せてないので実環境での実行は参考程度で。

  ALTER TABLE BATCH_STEP_EXECUTION ADD CREATE_TIME DATETIME(6) NOT NULL DEFAULT '1970-01-01 00:00:00';
  ALTER TABLE BATCH_STEP_EXECUTION MODIFY START_TIME DATETIME(6) NULL;

  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS CHANGE COLUMN TYPE_CD PARAMETER_TYPE VARCHAR(100);
  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS CHANGE COLUMN KEY_NAME PARAMETER_NAME VARCHAR(100);
  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS CHANGE COLUMN STRING_VAL PARAMETER_VALUE VARCHAR(2500);

  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_VALUE = CAST(DATE_VAL AS VARCHAR) WHERE PARAMETER_TYPE = 'DATE';
  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_VALUE = CAST(DOUBLE_VAL AS VARCHAR) WHERE PARAMETER_TYPE = 'DOUBLE';
  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_VALUE = CAST(LONG_VAL AS VARCHAR) WHERE PARAMETER_TYPE = 'LONG';

  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_TYPE = 'java.lang.Long' WHERE PARAMETER_TYPE = 'LONG';
  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_TYPE = 'java.lang.String' WHERE PARAMETER_TYPE = 'STRING';
  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_TYPE = 'java.lang.Double' WHERE PARAMETER_TYPE = 'DOUBLE';
  UPDATE BATCH_JOB_EXECUTION_PARAMS SET PARAMETER_TYPE = 'java.util.Date' WHERE PARAMETER_TYPE = 'DATE';

  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS DROP COLUMN DATE_VAL;
  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS DROP COLUMN LONG_VAL;
  ALTER TABLE BATCH_JOB_EXECUTION_PARAMS DROP COLUMN DOUBLE_VAL;

5. その後

デプロイ後の監視を行っていると、バッチモジュールの起動直後にBean生成で警告が上がっていた。
いずれもSpring BatchにまつわるBeanの模様。

  WARN  [main : o.s.c.s.PostProcessorRegistrationDelegate$BeanPostProcessorChecker] - Bean 'org.springframework.boot.autoconfigure.jdbc.DataSourceConfiguration$Hikari' of type [org.springframework.boot.autoconfigure.jdbc.DataSourceConfiguration$Hikari] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected into a currently created BeanPostProcessor [jobRegistryBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies.
  WARN  [main : o.s.c.s.PostProcessorRegistrationDelegate$BeanPostProcessorChecker] - Bean 'spring.datasource-org.springframework.boot.autoconfigure.jdbc.DataSourceProperties' of type [org.springframework.boot.autoconfigure.jdbc.DataSourceProperties] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying). Is this bean getting eagerly injected into a currently created BeanPostProcessor [jobRegistryBeanPostProcessor]? Check the corresponding BeanPostProcessor declaration and its dependencies.

ググってみるとSpring BatchのGitHubにもたびたび報告が寄せられているようで、どうやらSpring Batch 5で行われた「JDBCベースのJobRegistryへの移行」に起因している模様

なので、Spring Batchのプロジェクトがどう対応するかに依存するようなので、ひとまず様子見するしかなさそうだ。

2023年6月 1日 (木)

Spring BatchでつくるCSVを複数ファイルに分割する

0. 読み書きあれこれ

他のシステムから送られてきたCSVを読み込んでDBに書き込む、あるあるですね?

逆に他のシステムへデータを送るため、DBのレコードをかき集めてCSVに固める、これもあるあるですね?

そこでもし、送り先のシステムが「CSVを一度に読める行数が決まっている」と言い出したら?

それもこれも、Spring Batchでできちゃうのです。

今日は、そんなときどうするかを覗いてみることにしましょう~(CV:関智一)

1. Readerがあるなら...?

以前Spring Batchで複数ファイルをItemReaderに使うという記事でMultiResourceItemReaderについて書いた。

で、「ReaderがあるならWriterもあるやろ?天下のSpringなんだし」とググったら、あった。MultiResourceItemWriter

2. MultiResourceItemWriter

Wraps a ResourceAwareItemWriterItemStream and creates a new output resource when the count of items written in current resource exceeds setItemCountLimitPerResource(int).(ResourceAwareItemWriterItemStreamをラップし、現在のリソースに書き込まれたアイテムのカウントがsetItemCountLimitPerResource(int)を超えた場合に新しい出力リソースを作成する)とあるとおり、書き込み行上限に達したら新たなResourceを生成して配下のItemWriterに移譲する。

使い方はMultiResourceItemReaderとほぼ同じ。
とはいえ、書き込み先リソースの供給が必要になるItemWriterといったら、ほぼFlatFileItemWriter専用みたいな印象。

処理を移譲されるItemWriterの中身はごく普通のFlatFileItemWriterでいいので、説明は割愛。MultiResourceItemReader同様、setResourceで出力先を定める必要はない。

  @Bean
  public MuiltiResourceItemWriter<OrgFile> multiResOrgWriter(FlatFileItemWriter<OrgFile> orgWriter) {
    // ここで指定するResourceは出力先ファイル名のひな型になる
    Resource res = new FileSystemResource("/path/to/output/file.csv");

    MultiResouceItemWriter<OrgFile> writer = new MultiResouceItemWriter<>();
    // 実際に処理するwriter
    writer.setDelegate(orgWriter);
    // 出力リソース
    writer.setResource(res);
    // setItemCountLimitPerResource で出力ファイルごとの出力行数の基準値を定める
    writer.setItemCountLimitPerResource(100);
    // ジョブを再起動したとき処理を再開させたい場合はtrueにする
    writer.setSaveState(true);

    return writer;
  }

こういったBean定義をバッチの設定ソースに仕込んだ状態でジョブを実行すると、setItemCountLimitPerResourceの設定値からチャンクサイズの間でちゃんとファイルが分割されて出力される。

3. カスタマイズ

とは書いたのだが、このままだとこんな感じでファイルができているはず。

  file.csv.1
  file.csv.2
  file.csv.3

まぁ、UNIX系OSのみを対象にしているのであれば何ら問題はないはずなのだが、拡張子をもとにファイルタイプを特定するWindowsのようなOSだと、「わけのわからんファイルがいっぱいできている」と騒ぎになりかねない。

このファイル名は、MultiResourceItemWriterがResourceSuffixCreatorのデフォルト実装であるSimpleResourceSuffixCreatorが出力する.1のような文字列を単にResourceのPathに連結しているだけなので、ResourceSuffixCreatorの実装内でファイル名そのものを組み立ててやればよい。

例えば、file_20230523_001.csvみたいな書式でファイル名を設定したいときは、こんな感じになる。

  @Bean
  public MuiltiResourceItemWriter<OrgFile> multiResOrgWriter(FlatFileItemWriter<OrgFile> orgWriter, ResourceSuffixCreator resoureSuffixCreator) {
    // ここで指定するResourceは出力先ディレクトリだけ定義しておく
    Resource res = new FileSystemResource("/path/to/output/");

    MultiResouceItemWriter<OrgFile> writer = new MultiResouceItemWriter<>();
    writer.setDelegate(orgWriter);
    writer.setResource(res);
    writer.setItemCountLimitPerResource(100);
    writer.setSaveState(true);
    // ファイル名を実際に決めるResourceSuffixCreator
    writer.setResourceSuffixCreator(resourceSuffixCreator);

    return writer;
  }

  @Bean
  public ResourceSuffixCreator resoureSuffixCreator() {
    return new ResourceSuffixCreator() {
      @Override
      public String getSuffix(int index) {
        DateFormat df = new SimpleDateFormat("yyyyMMdd");
        // 添え字が渡されるのでそれを使ってファイル名そのものをここで作る
        return "file_" + df.format(Date.from(Instant.now())) + String.format("_%03d", index) + ".csv";
      }
    }
  }

こうすることで、出力結果はこうなっているはず。

  file_20230523_001.csv
  file_20230523_002.csv
  file_20230523_003.csv

肝心の1ファイル当たりの行数だが、MultiResourceItemWriterのJavaDocに

Note that new resources are created only at chunk boundaries i.e. the number of items written into one resource is between the limit set by setItemCountLimitPerResource(int) and (limit + chunk size).

とあるとおり、

  • 新しいリソース(≒ファイル)はチャンクの境界で作成される
  • 実際に書き込まれる行数はsetItemCountLimitPerResource(int)で設定したリミットとリミット+チャンクサイズの間で区切られる

ことに注意。

2023年4月 8日 (土)

StepScopeなTaskletをテストする

0. わかればどうということはないのだが

これも使い古された内容だけに、多分に自分用メモ。

Spring Batchでいろんな社内処理を書いているんだが、先行するStepの処理結果を後続Stepで使いたい、というシーンはよくあると思う。

そんなとき、

  1. 独自のワークテーブル、もしくはファイルに処理結果を出力
  2. Beanにして持ちまわる
  3. ExecutionContextに突っ込む

...あたりが候補にあげられるんじゃなかろうか。

上記のうち、1は一番柔軟に対応できるが設計が面倒だったりするし、Beanの場合は完全なインメモリ処理になるので、ちょっとした値のやり取りであればExecutionContextでお手軽に済ませる場合がほとんどだ。

1. AutowiredできないComponent

今回話題にするバッチのシナリオはこんな感じ。

  1. APIアクセスでデータを取得するStep
  2. 先行Stepの結果を加工するStep
  3. API側にページング処理がないのでタスクレットモデルで処理

ひとまずソースを貼っておく。

BatchConfig

@Configuration
@EnableBatchProcessing
public class BatchConfig {

    private final JobRepository jobRepository;
    private final PlatformTransactionManager platformTransactionManager;

    public BatchConfig(JobRepository jobRepository, PlatformTransactionManager platformTransactionManager) {
        this.jobRepository = jobRepository;
        this.platformTransactionManager = platformTransactionManager;
    }

    /**
     * このプロジェクトの {@link Job } 。
     *
     * @param fooStep 先に実行される {@link Step }
     * @param barStep あとで実行される {@link Step }
     * @return {@link Job } のインスタンス
     */
    @Bean
    public Job job1(Step fooStep, Step barStep) {
        return new JobBuilder("job1", jobRepository)
                .start(fooStep)
                .next(barStep)
                .build();
    }

    /**
     * 先に実行される {@link Step } 。
     *
     * @param fooTasklet 先に実行されるタスクレット
     * @return {@link Step } のインスタンス
     */
    @Bean
    public Step fooStep(FooTasklet fooTasklet) {
        return new StepBuilder("foo_step", jobRepository)
                .tasklet(fooTasklet, platformTransactionManager)
                .listener(makeListener(FooTasklet.PASS_PARAM1))
                .build();
    }

    /**
     * あとで実行される {@link Step } 。
     *
     * @param barTasklet あとで実行されるタスクレット
     * @return {@link Step } のインスタンス
     */
    @Bean
    public Step barStep(BarTasklet barTasklet) {
        return new StepBuilder("bar_step", jobRepository)
                .tasklet(barTasklet, platformTransactionManager)
                .build();
    }

    /**
     * JobのExecutionContextへプッシュするリスナを作る。
     *
     * @param keys プッシュ対象を監視するキー
     * @return {@link ExecutionContextPromotionListener } のインスタンス
     */
    private ExecutionContextPromotionListener makeListener(String... keys) {
        ExecutionContextPromotionListener listener = new ExecutionContextPromotionListener();
        listener.setKeys(keys);

        return listener;
    }
}

先行するタスクレット

@Component
public class FooTasklet implements Tasklet {

    /**
     * 後続タスクへ引き渡す値のキー
     */
    public static final String PASS_PARAM1 = "PassParam1FromFooTasklet";

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
        contribution.setExitStatus(ExitStatus.COMPLETED);
        ExecutionContext ec = chunkContext.getStepContext().getStepExecution().getExecutionContext();

        ec.put(PASS_PARAM1, "次に引き継ぐ値");

        Thread.sleep(15000L);

        return RepeatStatus.FINISHED;
    }
}

後続のタスクレット

@Component
@StepScope
public class BarTasklet implements Tasklet {

    private final Some1Service some1Service;
    private final Some2Service some2Service;

    public BarTasklet(Some1Service some1Service, Some2Service some2Service) {
        this.some1Service = some1Service;
        this.some2Service = some2Service;
    }

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {

        String val = (String) chunkContext.getStepContext().getStepExecution().getJobExecution().getExecutionContext().get(FooTasklet.PASS_PARAM1);

        System.out.println(val);
        some1Service.doSomething1();
        some2Service.doSomething2();

        contribution.setExitStatus(ExitStatus.COMPLETED);

        return RepeatStatus.FINISHED;
    }
}

後続のStepでは前の処理の値を使うので、通常のBean化戦略では実行時の値が決まっていないので実行の瞬間までBeanにならないようにしたい。
これは、StepScopeアノテーションを付加することで実現しているのだが、StepScopeになっているとテストコードにタスクレットをAutowiredでインジェクトできない、という制約が生じる。

まぁ、Bean化するとき中身が決まっていないのだから当然といえば当然なのだが、これでは都合が悪いので何らかの対策が必要になる。

ということで、テストはこうなる。

@SpringBootTest
class BarTaskletTest {

    @Autowired
    private Some1Service some1Service;
    @Autowired
    private Some2Service some2Service;

    @BeforeEach
    void setUp() {
    }

    @AfterEach
    void tearDown() {
    }

    @Test
    @DisplayName("BarTaskletを実行できる")
    void runsTasklet() throws Exception {
        // ChunkContextをモック化
        StepExecution se = Mockito.mock(StepExecution.class);
        StepContext sc = Mockito.mock(StepContext.class);
        ChunkContext cc = Mockito.mock(ChunkContext.class);

        JobExecution je = MetaDataInstanceFactory.createJobExecution();
        je.getExecutionContext().put(FooTasklet.PASS_PARAM1, "次に引き継ぐ値");

        Mockito.when(cc.getStepContext()).thenReturn(sc);
        Mockito.when(sc.getStepExecution()).thenReturn(se);
        Mockito.when(se.getJobExecution()).thenReturn(je);

        // StepContributionをモック化
        StepContribution contrib = Mockito.mock(StepContribution.class, Mockito.CALLS_REAL_METHODS);

        // StepScopeはAutowiredできないので自力でnewする
        BarTasklet barTasklet = new BarTasklet(some1Service, some2Service);

        RepeatStatus rs = StepScopeTestUtils.doInStepScope(se, () -> barTasklet.execute(contrib, cc));
        assertEquals(RepeatStatus.FINISHED, rs);
        assertEquals(ExitStatus.COMPLETED, contrib.getExitStatus());
    }
}

ミソは以下のとおり。
なお今回は、ExitStatusの検証もしたかったので、StepContributionのモック時にMockito.CALLS_REAL_METHODSも付加している。

2. 本日のソースコード

全文はこちらをどうぞ。

2022年2月22日 (火)

Dateをmockするメモ

0. 時刻が絡むとめんどくさい

使い古された内容だけに、多分に自分用メモ。

よくこんなコードを書かないだろうか。僕はとってもよく書く。

  Date date1 = new Date();
  // あるいは
  Date date2 = Date.from(Instant.now());
        

よくある「現在時刻が必要な処理」で出てくるやつである。

プロダクションコードだけを考えるなら、まったくこれで問題はないと思うんだけど、ちゃんと時刻が利用できているかどうかをユニットテストで確認しようとすると、これでは途端に問題になる。

こういう場合Date部分を生成部分を固定の値を返すようなモックに置き換えることができれば、安定してテストが成立しうるので、その方法を調べることに。

1. mockしてやる

ググってみると、Overriding System Time for Testing in Javaという、安定のbaeldungさんの記事がヒット。

要はInstant.now()をモックすることで対応可能とのこと。

「そういやspring-boot-starter-testにはmockito含まれてたよな?」と思い出したので確認してみる。

  $ ./gradlew dependencies
  \--- org.springframework.boot:spring-boot-starter-test -> 2.6.3
  +--- org.mockito:mockito-core:4.0.0
  +--- org.mockito:mockito-junit-jupiter:4.0.0
  |    +--- org.mockito:mockito-core:4.0.0 (*)
        

うむ、間違いなし。PoCプロジェクトにしたSpring Boot 2.6.3では、mockito-core 4.0.0が入っているようだ。

これを利用して、固定の時刻を返すDateを作ってみる。

プロダクションコード
  @NoArgsConstructor
  @AllArgsConstructor
  @Getter
  @Setter
  public class TimeNotification {
      @JsonProperty("current_time")
      @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Tokyo")
      private Date currentTime;
  }

  @RestController
  public class MainController {
      @GetMapping("/")
      public TimeNotification notifyTime() {
          return new TimeNotification(Date.from(Instant.now()));
      }
  }
テストコード
  @Test
  void index() throws Exception {
      // Instant側はZ時刻帯(=UTC)、ZoneInfoはJSTなので、9時間ずらす
      Clock clock = Clock.fixed(Instant.parse("2022-02-22T12:11:23Z"), ZoneId.of("Asia/Tokyo"));
      Instant instant = Instant.now(clock);

      try(MockedStatic mockedInstant = Mockito.mockStatic(Instant.class)) {
          mockedInstant.when(Instant::now).thenReturn(instant);

          mockMvc.perform(get("/"))
                  .andExpect(status().isOk())
                  .andExpect(content().json("{\"current_time\":\"2022-02-22 21:11:23\"}"))
                  .andReturn();
      }
  }

で、テストを実行してみたのだが、

org.mockito.exceptions.base.MockitoException: 
The used MockMaker SubclassByteBuddyMockMaker does not support the creation of static mocks

Mockito's inline mock maker supports static mocks based on the Instrumentation API.
You can simply enable this mock mode, by placing the 'mockito-inline' artifact where you are currently using 'mockito-core'.
Note that Mockito's inline mock maker is not supported on Android.

...と怒られる。スタックトレースを読むと、スタティックメソッドのモッキングにはmockito-inlineというライブラリが必要な様子。

build.gradleに追加。

  dependencies {
      testImplementation "org.mockito:mockito-inline:4.0.0"
  }

これでようやくテストが通るようになった。

2. 本日のソースコード

全文はこちらをどうぞ。

2021年6月28日 (月)

Spring Bootを最新化した

0. 重い腰を上げてアップグレード

弊社、Yahooショッピングの受注処理を自動化しているんだが、Spring Bootのバージョンが2.2.0.RELEASEなのでそろそろバージョンアップせねば、ということで作業開始。

1. 2.2から2.3へ

2.2から2.3へは、Spring Boot 2.3 Release Notesを見ながら作業するわけだが、弊社の環境で必要な作業は

だった。

Gradle 6.3以降への更新については、wrapperタスクを実行するだけなので簡単。

$ ./gradlew wrapper --gradle-version 6.3

次にspring-boot-starter-validationの依存追加は、spring-boot-starter-webから依存が外れるため。[出典]
ということなので依存をbuild.gradleに加える。

implementation("org.springframework.boot:spring-boot-starter-validation")

設定パラメータの移行については、spring-boot-properties-migratorというヘルパーがあるので、そいつをいったん依存に追加して起動することで、移行先を教えてくれる。
移行が終了したら依存から削除しておくこと。

runtime("org.springframework.boot:spring-boot-properties-migrator")

ここまでできたら、build.gradleのpluginブロックに書かれているバージョンを2.3に書き換えれば、Spring Boot 2.3になる。

以前1.4から2.0に移行したときのことを考えると、ずいぶん楽になった印象。

やってる最中で、今まで通っていたテストが通らなくなるという事案があったので、テストを修正。
そして、テストが通ったところでひとまず本番にデプロイ。

2. 2.3から2.4へ

ここからが本題。詳しくは2.4のリリースノート全文をご覧いただくとして、2.3から2.4では設定ファイルに大幅な修正が入っている。

Springにはプロファイル機能があり、テスト用、本番用と環境を切り替えることが容易になっているのは周知の事実だが、外部からのプロパティ指定や内部のデフォルト値上書きなどで時折(Spring Bootの)開発者が意図しない順序でプロパティが展開されるケースがあった、とのことで、2.4でそのあたりを大々的に改修したのだそう。出典をメモることができなかったが、「よくないのはわかっていたが、奇跡的にうまく動いていたので改修しようとしてもいろいろ反対があった」そうな。

ひとまず今すぐに移行できないような場合は、

spring.config.use-legacy-processing=true

を追加することで、今までと同じ挙動になる。のだが、ゆくゆくは削除されることは明白なので、新しい方式に作り直すことにした。

  spring:
    profiles:
      active: develop1
  settings:
    param_a: "param_common"

  ---
  spring:
    profiles: develop1
  settings:
    param_b: "param_devel1"

  ---
  spring:
    profiles: develop2
  settings:
    param_b: "param_devel2"

みたいなapplication.ymlがあったとして、これをspring.config.use-legacy-processing=trueなしで動かそうとすると、settings.param_bの値は常にparam_devel2になってしまう。

PoCプロジェクトを作って試してみたところ、

  • 共通部分としていたデフォルト値の集合部分を、独立したプロファイルの断片に切り出す
  • プロファイル固有部分を、上記同様独立したプロファイルの断片として切り出す
  • 従来プロファイルとしていた部分をプロファイルグループ名にし、必要なプロファイルの断片をその中に入れていく

という作業が必要だった。
これを踏まえ先ほどのapplication.ymlを書き換えたのがこちら。

  spring:
    profiles:
      active: develop1
      group:
        develop1: "devel1,common"
        develop2: "devel2,common"

  ---
  spring:
    config:
      activate:
        on-profile: devel1
  settings:
    param_b: "param_devel1"

  ---
  spring:
    config:
      activate:
        on-profile: devel2
  settings:
    param_b: "param_devel2"

  ---
  spring:
    config:
      activate:
        on-profile: common
  settings:
    param_a: "param_common"

ひとまずこれができたところで、2.3の時と同様build.gradleのpluginブロックに書かれているバージョンを2.4に書き換えれば、Spring Boot 2.4になる。

logback-spring.xmlで行っていた設定もapplication.ymlでできるようになっているけど、今回は割愛。

CIも通ったので、その足で本番デプロイ。

3. 2.4から2.5へ

こちらも難儀した。このバージョンではデータソースの初期化の順序とプロセスが整理されたのだが、恥ずかしながら自分が書いたとはいえ弊社のデータソース初期化まわりはこんな感じになっていた。

  • バッチ -> 原則事前定義済みスキーマを使用、テストで使うインメモリH2の場合のみschema.sqlで初期化
  • Web -> すでにスキーマがあることを前提とした内容のFlyWay管理

How To guideのInitialize a Database Using Basic SQL Scriptsによれば、schema.sqlとFlyWayのような高度なスキーマ管理機構を併用するのは推奨されなくなった。将来的に削除する方針でもあるそうなので、言い換えれば混ぜるな危険である。

ということなので、Webプロジェクトで使っていたFlyWayのバージョンマイグレーションの前に、既存のschema.sqldata.sqlが実行されるようsrc/test/resources/db/migration以下にマイグレーションファイルを作成することで対応した。
幸い、本番のマイグレーションファイルのバージョン表記は1からの連番ではなく日時のシリアル値を使用していたおかげでこういうことができたのだが、バージョンの開始を1にしていたら大変なことになっていただろう。

また、テスト時は常にマイグレーションがかかるよう、

spring.flyway.baseline-on-migrate=false

を設定した。

一方、バッチにもその実行結果とフェーズを記録するテーブル群があるので、その初期化用設定をバッチのプロジェクトに入れていく。

  spring:
    batch:
      jdbc:
        initialize-schema: "embedded"

Spring Bootのチーム曰く、「今まで奇跡的に動いていた」そうで(またかよw)、今回そこに手を入れたことで今まで通っていたテストがフィクスチャー不足で動かなくなったので、不足分を補うようテストを修正した。

最後に、Gradleが6.8以上必須になったので、wrapperタスクでGradleのバージョンを上げるわけだが、新規にSpring Boot 2.5.1のプロジェクトを作るとGradle 7.0.3を使っていたので、せっかくなので現時点の最新安定版の7.1を指定した。

ここまでできたら、build.gradleのpluginブロックに書かれているバージョンを2.5.1に書き換えれば、Spring Boot 2.5.1になる。

CIも通ったので、その足で本番デプロイ。

4. 最後に

application.ymlの移行が個人的に一番難しかったが、わかってしまえば設定を意味のある部品単位にできるので、むしろ整備性と部品の再利用がやりやすくなった、と思う。

2020年10月10日 (土)

Webショップ運営を(ほぼ)自動化した件

0. 弊社、Web販売もやってます

弊社はYahoo!ショッピングに出店しているんだが、問屋とのやり取り含め、その運営がまるごと手作業で結構手間だったりする。

一部の問屋は発送の代行もしてくれる、いわゆるドロップシッピング業者なのだが、そこを使った場合でも大まかな流れはこんな感じ。

1

人が監視する手前、受注はYahoo!ショッピングから送られてくるメールがトリガーになるので、担当者が不在の場合はどうしても遅れが生じるし、問屋への発注締め切り時刻を超過してしまうとその分エンドユーザーへの発送も遅延する。というのが悩みの種だった。

1. なので自動化に挑んだ

幸いYahoo!ショッピングにはストア運営を自動化するためのAPIが用意されているので、これを利用して受発注と発送通知の自動化に挑んだ。

で、こんな感じに。

2

1-1. 受注をfetchして発送依頼データを作るまで

件のストア運営を自動化するためのAPIを使ってストア運営する流れについては、公式の注文APIガイドに沿って順番にAPIコールしていけばいいので、これ自体は特に難しいことはなかった。
ただ、ここのAPIはXMLを投げ合う類だったりする。

肝心のシステムにはSpring Batchを使った。言語はJava。理由は単純、自分がJavaに比較的慣れていたためだ。

幸いJavaにはJava Architecture for XML Binding(JAXB)という公式の自動マッピング仕様があるため、XMLによるリクエスト/レスポンスデータとオブジェクトとの相互変換には、これを使うことで比較的容易に対応できた。
とはいえ、最初は「XMLかよ~」と泣きながらJAXBの解説記事を漁っていたのだが(あせ

で、取得した受注データから、発送依頼用ファイルを作るわけだが、相手方がシフトJISなCSVでのみ受け付けるため、色々見て回った結果opencsvを使うことにした。オブジェクトからCSVを生成するだけなので、特段苦労はなかった。

1-2. 発送依頼データをアップロードする

相手方の卸業者は受注や発送状況に関するAPIを持っていないとの話(こればっかりは仕方ない)なんだが、先方に確認したところ先方に確認したところ幸いにもRPA等の機械的なアクセスまでは禁じているわけではなかったので、Seleniumによるオートパイロットで発送依頼処理を代行させることにした。

こちらも最初はすべてSpring Batchで書いていたんだが、以下のような問題が出てきたのでSpringで書くのをやめ、処理を分離することにした。

  • WebDriverをSpringでBeanにしている場合、個々のオートパイロット処理の終了時にWebDriverをクローズすると、その「クローズされた状態」を次の処理で使いまわすため、WebDriverが開始できなくて倒れる
    まぁ、Seleniumはもともとテストツールで、JUnitなんかと一緒に動かすことが前提のものであって、インスタンスが使いまわされるような使用法は想定していないんだろう
  • AWSに受発注処理用サーバを置いて動かしていたら、相手方から攻撃とみなされてしまったらしく、IPベースで接続を遮断されてしまったため、AWS上に置けなくなってしまった
    「パブリックIP変えればええやん」とお思いになるかと思うが、Yahoo!ショッピングの本番系にAPIアクセスするには固定のパブリックPが必要で、これを変えるにはまた申請が必要だったりするので面倒

後半のやつについては「やりすぎました、ほんとごめんなさい」というだけの話で、先方にも当然のことながら謝りを入れた。けれども、一度遮断したIPアドレスを開けてくれるわけもなく、引っ越しを余儀なくされることに。

どういうわけか弊社には、社内システム用のプライベートクラウド的な仮想化基盤があり、まだ余裕がありそうだったのでそこに発送依頼データのアップロードを行うVMインスタンスを作ることにした。

引っ越し先は決まったとして、Spring Batchで書いていた処理だけを切り出してやるのではなく、せっかくなのでGoで一から書いてみることにした。
要件的にDBアクセスが必要な個所はなかったことと、幸いにもほぼSeleniumなagoutiという受け入れテスト用ライブラリがあったので、agoutiでオートパイロットする処理を実装。AWS側で発送依頼データのCSVを作ったらscpでアップロード用VMに送り込み、頃合いを見計らってGoで作ったオートパイロット処理でファイルをアップロードする、という算段だ。

1-3. 発送状況の追跡と更新

こちらは、前述とは逆の流れ。
先方からは発送状況をCSVでダウンロードできるので、Goで作った「発送状況CSVをダウンロードする処理」でCSVをダウンロードし、こいつをscpでAWS上の受発注処理用サーバに送り込んで、Spring BatchでCSVのパースと発送ステータスの変更を行う、という流れになる。

この段階でも、CSVがシフトJISという以外は特段苦労なくパースできた。

Yahoo!ショッピングのAPIで該当の注文に対してパース結果の運送業者と配送伝票番号を埋めたあと、Springのメール送信機能で注文者に発送状況を通知している。本文については、Thymeleafのテキストテンプレート機能を使った。

Springのメール送信機能については、Guide to Spring Emailという記事が大いに参考になった。

2. で、どうなったか

ひとまず、Webでの受発注はひととおり自動化したのだが、そうもいかない場合も存在する。例えばこんな感じ。

  • 「領収書の要否をカートに表示する」設定にしていて、注文ボタンが出ている画面に「領収書をどうするか」という選択が出ているのに、領収書不要でお客様要望欄に「領収書をください」と書いている場合がある
  • 同じく、領収書必要にはなっているが、お客様要望欄に宛名や但書に関する指定がある
  • 期日指定配送は承っていないのだが、お客様要望欄に「○○月××日必着で願います」と書かれている場合がある

こういう場合はどうしても人による確認が必要になるので、どんな対応が必要なのかを社内のMLに流す処理を追加することで対応した。一応、人間様は結果だけ受け取るという状況にはできたといえるが、今後は

  • 領収書の自動作成(※額面5万円以上の場合は印紙が必要になるのでテンプレートはそこで分ける必要があるが)
  • 受注結果をチャットのタイムラインに流す

あたりをやっていきたいと思う。

2019年9月16日 (月)

Apache Wicketを使ってみる[4] -認証/認可編-

0. Wicketで認証/認可を扱うには

wicket-auth-roles というdependencyが必要。

<dependency>
  <groupId>org.apache.wicket</groupId>
  <artifactId>wicket-auth-roles</artifactId>
  <version>8.5.0</version>
</dependency>

1. Wicketでの認証

通常の WebApplication に認証情報を処理できるような拡張が施された AuthenticatedWebApplication と、認証処理と認証情報のセッションストアへの格納を行う AuthenticatedWebSession で構成される。

二つとも abstract なので、使うには継承したうえで実装しなければならない処理がある。

AuthenticatedWebApplication AuthenticatedWebSession
  • protected Class<? extends WebPage> getSignInPageClass()
    サインインページを構成するクラスを返す
  • protected boolean authenticate(String username, String password)
    ユーザー名とパスワードから認証を行う
  • protected Class<? extends AbstractAuthenticatedWebSession> getWebSessionClass()
    アプリケーションで使うセッションストアのクラスを返す

上記のユーザーガイドからの抜粋をKotlinで書いてみると、こんな感じになる。

  • セッションストアクラス
class BasicAuthenticationSession(request: Request) : AuthenticatedWebSession(request) {
  override fun authenticate(username: String, password: String) : Boolean {
    // ここで認証処理を行う
    // 通常であれば、DBアクセスしてユーザーの有無やパスワード比較とかをする、はず
    return username == password && username == "wicketer"
  }

  override fun getRoles() : Roles {
    // ロールベースのアクセス制御用に、ログイン中ユーザーのロールを返す
    // 通常であれば、セッションストアのユーザー情報からロールを引いたりする、はず
    return Roles()
  }
}
  • アプリケーションクラス
class WicketApplication() : AuthenticatedWebApplication() {
  override fun getHomePage() : Class<out WebPage> {
    // いわゆる「スタートページ」を構成するクラスを返す
    return HomePage::class.java
  }
  override fun getWebSessionClass() : Class<out AbstractAuthenticatedWebSession> {
    // アプリケーションでつかうセッションストアを返す
    return BasicAuthenticationSession::class.java
  }
  override fun getSignInPageClass() : Class<out WebPage> {
    // いわゆる「ログインページ」を構成するクラスを返す
    return SignInPage::class.java
  }
}
  • サインインページ
class SignInPage() : WebPage() {
  private var username: String = ""
  private var password: String = ""

  override fun onInitialize() {
    super.onInitialize()

    // ログインフォームを構成するStatelessForm
    val form: StatelessForm<Unit> = object : StatelessForm<Unit>("form") {
      override fun onSubmit() {
        if (username.length == 0) {
          return
        }

        // 認証処理を呼び出し、認証成功のときは前ページで設定されたリダイレクト先へ遷移する
        val authResult = AuthenticatedWebSession.get().signIn(username, password)
        if (authResult) {
          // ここではそのままリダイレクトしているが、ここでユーザーロールのチェックとかをはさむ、はず
          continueToOriginalDestination()
        }
      }
    }

    // ModelとTextFieldをformに追加する
    form.model = CompoundPropertyModel(this)
    form.add(TextField<String>("username"))
    form.add(PasswordTextField("password"))

    add.(form)
  }
}

サインインページのデフォルト実装はあるけれど、同じくデフォルト実装のパネルと組み合わせて使う想定のようで、自力で書くのと大差ないかも....

2. 途中で認証をはさみたいときは

「トップページとかは普通に見れるようにするが、あるページは会員だけに見せたい」といった場合、途中でユーザー認証を行い、認証に成功したらユーザーがもといたページに戻す、といった処理をやることになる。

そういった場合は、以下の二つの方法をとる、とのこと。[出典]

いずれも、実行時に遷移前の状態をセッションストアに保存したあと、認証に成功した場合認証前の状態に復元してくれるが、認証ページへの遷移直前に RestartResponseAtInterceptPageException が内部的にスローされてページ遷移するので、以後の処理は行われなくなる。

3. Wicketでの認可

いわゆる処理に対する権限チェックである。先ほどの例で出てきた AuthenticatedWebSession#getRoles() も、認可に関する処理の一つ。
「ログインしているか否か」も、認可の範囲に入る模様。

基本処理は IAuthorizationStrategy で規定されている

boolean isActionAuthorized(Component, Action) アクションが許可されているかどうかを返す。
boolean isInstantiationAuthorized(Class<T extends IRequestableComponent>) 指定されたコンポーネントをインスタンス化できるかどうかを返す。
boolean isResourceAuthorized(IResource, PageParameters) リクエストのパラメータが、リソースへのアクセスが許可されているかどうかを返す。

だが、すべてを許可するALLOW_ALLという実装のほか、ページ単位のチェックを行う規定処理がすでに組み込まれている。個人的には、一番しっくりきたのがロールベース戦略として紹介されている MetaDataRoleAuthorizationStrategyAnnotationsRoleAuthorizationStrategy だった。
それぞれ、用途的には以下のような違いがある。

MetaDataRoleAuthorizationStrategy アプリケーション起動時に、あらかじめ使用可能な権限をページごとに列挙しておく
AnnotationsRoleAuthorizationStrategy ページクラスに対し、アノテーションで権限を宣言していく

両者とも一長一短ではあるけど、たとえば、 AdminOperatablePage というページに「サインインしていたら表示可能、スーパーユーザーなら中のFormを操作可能」という権限を設定する場合、

  • MetaDataRoleAuthorizationStrategy のばあい
class WicketApplication() : AuthenticatedWebApplication() {
  override fun init() {
    securitySettings.authorizationStrategy = MetaDataRoleAuthorizationStrategy(this)

    // ログインしていたら "AdminOperatablePage" はインスタンス化を許可
    MetaDataRoleAuthorizationStrategy.authorize(AdminOperatablePage::class.java, "SIGNED_IN")

    // adminForm というコンポーネントは、"ADMIN"なら操作可能にする
    // "ADMIN"でないときはロックされる
    MetaDataRoleAuthorizationStrategy.authorize(adminForm, Action.ENABLE, "ADMIN")
  }
}
  • AnnotationsRoleAuthorizationStrategy のばあい
@AuthorizeInstantiation("SIGNED_IN")
@AuthorizeAction(action = "ENABLE", roles = {"ADMIN"})
class AdminOperatablePage(params: PageParameters) : WebPage(params) {
  // @AuthorizeInstantiation で "SIGNED_IN" を持つ場合にインスタンス化を許可
  // ここの @AuthorizeAction は、ページ全体に効果がある
  ...
}

....といった感じになるはず。

なお、権限は AuthenticatedWebSession#getRoles() で取得されるので、先ほどのセッションストアクラスを書き直してみると、

class BasicAuthenticationSession(request: Request) : AuthenticatedWebSession(request) {

  override fun getRoles() : Roles {
    // ロールは中身がHashSet<String>なので、条件に応じたロール識別文字列をaddしていく
    // @see https://ci.apache.org/projects/wicket/apidocs/8.x/org/apache/wicket/authroles/authorization/strategies/role/Roles.html
    val resultRoles = Roles()

    if (isSignedIn()) {
      // たとえば、サインインしていたら"SIGNED_IN"をおく、とか
      resultRoles.add("SIGNED_IN")
    }
    if (username == "superuser") {
      // スーパーユーザーだったら"ADMIN"を置く、とか
      resultRoles.add("ADMIN")
    }
    // このほか、ユーザーのオブジェクトを保管しているなら、そのオブジェクトにぶら下がっている
    // ロールをaddしていくことになる、はず

    return resultRoles
  }
}

...といった具合になるはず。

4. 認可に失敗したときは

認可に失敗したとき、デフォルトでは

  • ユーザーがサインインしていないときは、既定のサインインページへ遷移させる
  • サインインしているがコンポーネントが認可できない場合は、 UnauthorizedInstantiationException がスローされる

....という挙動だが、カスタム処理にしたい場合は IUnauthorizedComponentInstantiationListener というイベントリスナがあるので、これの中で処理する。

  securitySettings.unauthorizedComponentInstantiationListener = IUnauthorizedComponentInstantiationListener {
    // 認可失敗を処理する "AuthWarningPage" というページをレスポンスにする
    it.setResponsePage(AuthWarningPage::class.java)
  }

ロールの検証を追加するなら、こんな感じになるはず。

  securitySettings.unauthorizedComponentInstantiationListener = object : IRoleCheckingStrategy, IUnauthorizedComponentInstantiationListener {
    override fun onUnauthorizedInstantiation(component: Component?) {
      // 認可失敗を処理する "AuthWarningPage" というページをレスポンスにする
      component!!.setResponsePage(AuthWarningPage::class.java)
    }

    override fun hasAnyRole(roles: Roles): Boolean {
      // 指定ロールが見つかったらtrueを返す処理を書く
      var result = roles.hasRole("ADMIN")
      return result
    }
  }

まぁ、このあたりはデフォルト実装でもどうにかなるレベルなので、やるかどうかはケースバイケースか。

2019年9月 7日 (土)

Apache Wicketを使ってみる[3] -Model編-

0. Wicketでいう「Model」とは何か

Modelというと、だいたいビジネスロジック層とかデータアクセス層とかの認識でいるわけだが、Wicketにおける「Model」はセッションストアを含むデータアクセスも、Viewへの入出力を含む状態の受け渡しもやる。[出典]
こう書いてしまうと、かなりViewModelっぽい気もする。

1. 基本中の基本

単純なラベル表示の場合、たいていのチュートリアルにはこう書かれているはず。

HTML
<div><span wicket:id="strLabel">Label!!</span></div>
Java
add(new Label("strLabel", "Hello, World!!));

要は「"strLabel"というIDをもつ要素に"Hello, World!!"という文字列を置け」というだけのものだけれど、内部的には

add(new Label("strLabel", new Model<String>("Hello, World!!")));

と、Viewへの出力を行う基本的な実装を持つModelを仲介している、そうな。[出典]

2. 応用編

前述の例は単純なリテラルの出力だけだけど、当然のことながら「Java Beansとのやりとり」も「別のModelが吐いた出力を受けた入力」も、Modelとして処理できる。

全文はhttps://github.com/bitstorm/Wicket-tutorial-examples/tree/master/ModelChainingExampleを参照いただくとして、「ドロップダウンリストの選択値変化に応じてFormのテキストボックスの値を動的に変更する」処理も容易に実現できる。
なお、こちらのコードは例によってKotlinで書いてみた。

class PersonListDetails() : WebPage() {
    /** 連動して値を反映させる先のForm */
    private lateinit var forms: Form<Unit>
    /** ドロップダウンリストの中身(select + option) */
    private lateinit var personList: DropDownChoice<Person%>

    init {
        // selectとformの橋渡しをするModel
        val listModel = Model<Person>
        // selectで選択された値を拾うレンダラー
        personRender = ChoiceRenderer<Person>("fullName")

        // ドロップダウンリストを画面へ割り当てる
        personList = DropDownChoice<Person>("persons", listModel, loadPersons(), personRender)
        // 更新通知を受け取るためのイベントリスナー的なもの
        // これをトリガーに、下で定義しているformにModelが値を反映してくれる
        personsList.add(FormComponentUpdatingBehavior());
        // 画面へコンポーネントを反映
        add(personList)

        // selectの変化を受け取るModelを引数にとるModelをformに張る
        form = Form("form". CompoundPropertyModel<Person>(listModel))
        //   それぞれ、listModelが持っているJava Beansのプロパティに紐づいている
        form.add(TextField("name"));
        form.add(TextField("surname"));
        form.add(TextField("address"));
        form.add(TextField("email"));

        add(form)
    }

    // selectに張る中身のListを作る処理
    fun loadPerson(): List<Person> {...}
}

// 名(name)と姓(surname)を受け取る
data class Person(var name:String, var surname: String): Serializable {
    // 住所
    var address: String
    // メアド
    var email: String
    // 配偶者
    var spouse: Person?
    // 子供
    var children: MutableList<Person>?
}

これをStrutsでやろうと思うと、JSPにhiddenでいっぱい値をねじ込んでおかないとサクッとはできないよな....

ちなみに、前述のチュートリアルを動かすには pom.xmlのパッケージング設定がjarになっているのでwarに変更する 必要があるので注意。

2019年8月15日 (木)

Apache Wicketを使ってみる

0. 事の始まり

ある筋から「Apache Wicketを学習しといて」とのお達しがきたので自分向けメモ。

1. Apache Wicketとは

公式の記載をざっくり集約するとこんなところ。

  • 2000年代半ばのJava Webアプリケーションフレームワーク戦争の、数少ない生き残りの一つ(2004年に世に出た)
  • Apacheソフトウェア財団のもと、Apache 2.0 Licenseで公開されているOSS
  • Ajax対応機能を内包
  • 多言語対応(デフォルトで25言語を内包)
  • ページやコンポーネントはJavaオブジェクトとして扱える
  • 複数タブ/ウィンドウへの対応
  • ページやコンポーネントのテストのサポート
  • CDIやSpring、GuiceなどのDIをサポート
  • JPA、Bean ValidationなどのJava EE 6サポート
  • 最新安定版のWicket 8では、Java SE 8の言語仕様に対応するため、Java 8とServlet API 3.1が必須に

まぁ、最後のやつは、いまどきならよほどのことがない限りクリアできていると思うけど。
※そのよほどの(ry

2. セットアップ

プロジェクト自体はMavenで管理可能。
※言うまでもないが、Maven自体の扱いについては割愛。

MavenのコマンドラインはQuick Start Wizardで作成できる。

このページで生成されたコマンドラインをターミナルに投入すれば、Mavenが必要なものをそろえてくれるんだが、Windows使いで、PowerShellを使うときには注意が必要。

PS > mvn archetype:generate `
>>   "-DarchetypeGroupId=org.apache.wicket" `
>>   "-DarchetypeArtifactId=wicket-archetype-quickstart" `
>>   "-DarchetypeVersion=8.5.0" `
>>   "-DgroupId=com.mycompany" `
>>   "-DartifactId=myproject" `
>>   "-DarchetypeRepository=https://repository.apache.org/" `
>>   "-DinteractiveMode=false"

といった具合にmvnの -D オプションをダブルクォーテーションで囲わないと、「出力ディレクトリがわからない」とMavenに怒られる。
ハイフンで始まる部分がPowerShellのオプションと解釈されてしまうのが、どうも原因なようだ。なお、DOS窓なら問題なし。

Mavenがプロジェクトを生成したら、mvn clean installで必要ライブラリをダウンロードする。このあたりは、ほかのMavenプロジェクトと同じ扱い。

あとはIDEにMavenプロジェクトとしてインポートすれば、開発体制はとりあえず整う。
必要があれば、愛用のIDEにプラグインを入れてやろう。

3. 開発サーバの起動

生成されたpom.xmlをみると、dependencyブロックにJetty 9が含まれているので、

PS > mvn jetty:run

で localhost:8080 でJettyが起動する。

4. ページの構成など

Strutsなんかに代表されるクラシックなJava Webアプリケーションと違い、ViewとなるHTMLと、処理を受け持つJavaクラス(≒バッキングビーン)が1対1対応していて、ともに同じJavaパッケージ階層に置く必要がある。

このあたり、どちらかといえばASP.NETに近いかも。ただし、CSSなどを駆使して描画する枠線などは、独自タグのあるASP.NETと違い、普通にCSSで定義する。以下、GitHubにあるexampleから引用。

<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:wicket="http://wicket.apache.org">
<head>
  <title>Wicket Examples - sample panel</title>
</head>
<body>
Everything outside of the <wicket:border> tags will be ignored.
Might be handy as preview code.

  <wicket:border>
    <div style="border: 2px dotted #fc0; width: 400px; padding: 5px;">
      before the border contents <br />
    <wicket:body/>
    <br />after the border contents <br />
    </div>
  </wicket:border>

</body>
</html>

JSPとは違い、HTMLにカスタムタグを埋め込む形式なので、普通にプレビューできるのはいいと思う。

サブミットの処理については、バッキングビーンにViewのformに対応するインナークラスを定義して処理するそうな。
以下、こちらもGitHubにある公式のexampleから引用。

public final class GuestBook extends WicketExamplePage {
  /** A global list of all comments from all users across all sessions */
  private static final List<Comment> commentList = new ArrayList<>();

  /**
   * Constructor that is invoked when page is invoked without a session.
   */
  public GuestBook() {
    // Add comment form
    add(new CommentForm("commentForm"));

    // Add commentListView of existing comments
    add(new PropertyListView<>("comments", commentList) {
      @Override
      public void populateItem(final ListItem listItem) {
        listItem.add(new Label("date"));
        listItem.add(new MultiLineLabel("text"));
      }
    }).setVersioned(false);
  }

  /**
   * A form that allows a user to add a comment.
   *
   * @author Jonathan Locke
   */
  public final class CommentForm extends Form<ValueMap> {
    /**
     * Constructor
     *
     * @param id The name of this component
     */
    public CommentForm(final String id) {
      // Construct form with no validation listener
      super(id, new CompoundPropertyModel<>(new ValueMap()));

      // this is just to make the unit test happy
      setMarkupId("commentForm");

      // Add text entry widget
      add(new TextArea<>("text").setType(String.class));

      // Add simple automated spam prevention measure.
      add(new TextField<>("comment").setType(String.class));
    }

    /**
     * Show the resulting valid edit
     */
    @Override
    public final void onSubmit() {
      ValueMap values = getModelObject();

      // check if the honey pot is filled
      final String _comment = (String) values.get("comment");
      if (_comment != null && !_comment.isBlank()) {
        error("Caught a spammer!!!");
        return;
      }
      // Construct a copy of the edited comment
      Comment comment = new Comment();

      // Set date of comment to add
      comment.setDate(new Date());
      comment.setText((String)values.get("text"));
      commentList.add(0, comment);

      // Clear out the text component
      values.put("text", "");
    }
  }

  /**
   * Clears the comments.
   */
  public static void clear() {
    commentList.clear();
  }
}

5. Ajax対応について

サブミット処理の応用。処理を受け付けるクラスの中で、AjaxFallbackLinkで発動用のリンクと紐づけ、AjaxRequestTargetに処理結果を渡す。

このしくみだと、JavaScriptでイベントハンドラをゴリゴリ書かなくてもAjax対応できるのはいいかもしれない。

6. データアクセス

Wicket自体にはデータアクセス用のコンポーネントはないので、Spring Dataなんかと組み合わせる必要がある。

7. 雑感

今回やったところとしてはこんなところ。

  • Viewがあるページには、普通にHTMLのプレビューがきくので、画面の作成はJSPを使うものより効率よくできそう
  • Ajaxをつかった動きのあるページの作成は、比較的簡単に作れそう

認証とテストについては、また日を改めて挑むことにしよう。

2017年9月17日 (日)

ログイン失敗時のメッセージを増やしたい on Spring Boot

0. ことのはじまり

先日、ログイン/ログアウト前後でクエリパラメータを保つ on Spring Bootと題して、ログイン/ログアウト時にクエリパラメータを保持する方法について書いたんだけど、こいつの参照実装を後輩君に教えてやったところ、「ログイン失敗時のメッセージが出なくなったんすけど?」とかいうリプライが。

どうやら、槙 俊明さんのはじめての Spring Boot[改訂版]をもとに(というかほぼそのまんまの模様)Spring Securityの設定をしていたようだが、「ログインに失敗したときに、画面上どうふるまうか」を理解せぬまま、僕が示した参照実装のコードを丸写ししていたようで、あんなリプライを出してきた模様。

このあたり、「自分で考えろ」と突き放してもよかったんだが、後学のために「どうすればいいか」を少々考えてみることにする。

1. 実際どうなっているのか

氏の書籍には、だいたいこんな感じでエラーメッセージ周りの処理が紹介されている。

Viewのhtml(抜粋)



<div th:if="${param.error}>ユーザー名、またはパスワードが違います。</div>

SecurityConfig(抜粋)



protected void configure(HttpSecurity http) throws Exception {
http.formLogin()
.loginProcessingUrl("/login")
.loginPage("/loginForm")
.failureUrl("/loginForm?error")
.defaultSuccessUrl("/", true)
.usernameParameter("username")
.passwordParameter("password")
.permitAll();
}

勘のいい方はお分かりと思うが、


  • 認証に成功したら、「/」にリダイレクト

  • 認証に失敗したら、「/loginForm?error」に移動

と、 URLだけで制御している ことがわかると思う。

それが証拠に、認証に失敗しなくても、「/loginForm?error」をブラウザのアドレスバーに直接入力すると、エラーメッセージが出るはずである。

これ自体は、話を簡単にするための処置なのだろう、と考えているのだが、今回の一件では、認証の成功、失敗で適切にクエリパラメータを加工して流す考慮が必要になるので、アプローチとしては以下の2つがあると考える。


  1. 認証ハンドラで、クエリパラメータを適切に追加、削除する

  2. 失敗時の認証ハンドラで、エラーメッセージをセッションに置く


2. URLだけで制御する場合

Viewの実装をそのままにしたい場合、いままでどおりURLでコントロールするよう、クエリパラメータを適切に追加、削除する処理を書けばよい、ということになる。

たとえば、こんな感じ。

認証成功時(抜粋)



// add whole query parameters to url
String queryParams = request.getQueryString() == null ? "" : "?" + request.getQueryString();

// remove error parameter if present.
if (queryParams.contains("error&")) {
queryParams.replaceAll("error&", "");
} else if (queryParams.contains("&error")) {
queryParams.replaceAll("&error", "");
}

認証失敗時(抜粋)



// add whole query parameters to url
String queryParams = request.getQueryString() == null ? "" : "?" + request.getQueryString();

// add error parameter if not present
if (!queryParams.contains("error")) {
if (queryParams.length() == 0) {
queryParams = queryParams + "?error";
} else {
queryParams = queryParams + "&error";
}
}

汚い実装で恐縮だが、雰囲気は伝わっただろうか。

3. セッションを使う場合

認証失敗時に呼ばれる AuthenticationFailurehandler#onAuthenticationFailure(HttpServletRequest, HttpServletResponse, AuthenticationException) では、認証失敗に至った理由が AuthenticationException のオブジェクトとして受け取ることができるようになっている。(参考

「パスワードが違う」「アカウントが有効期限切れ」「アカウント凍結中」など、詳細に調べることができるので、これを使って流すエラーメッセージを選別すればいい。

たとえば、こんな感じ。

認証失敗時(抜粋)



// Analyze the cause of the error
String errReason = null;
if (exception instanceof BadCredentialsException) {
errReason = "Invalid user name or password.";
} else if (exception instanceof AccountExpiredException) {
errReason = "This account is expired. Please contact administrator.";
} else if (exception instanceof CredentialsExpiredException) {
errReason = "Your password is expired. Please contact administrator.";
} else if (exception instanceof DisabledException) {
errReason = "Your password is disabled. Please contact administrator.";
} else if (exception instanceof LockedException) {
errReason = "Your accouunt is locked. Please contact administrator.";
} else {
errReason = "Unknown problem occured. Please contact administrator.";
}

if (errReason != null && errReason.length() > 0) {
HttpSession session = request.getSession();
session.setAttribute("errReason", errReason);
}

一方Thymeleafには、セッションオブジェクトを扱うための「session」というそのものずばりな予約変数がある。

利用方法は「Spring MVC and Thymeleaf: how to access data from templates - Thymeleaf」という公式ドキュメントに詳しく書かれているので、ひとまずそちらを参照していただくとして、セッションにエラーメッセージを置くなら、セッションから値を取り出して張る処理をViewテンプレートに書く。

たとえば、こんな感じ。

Viewのテンプレート(抜粋)



<div th:unless="${session == null}" th:text="${session.errReason}">Hoge</div>

この方法のメリットは、クエリパラメータをこねくり回す必要がなくなる点で、おそらくこういう方法のほうがフレームワークの設計思想に沿ったものなんじゃないだろうか、と個人的に考えている。

4. とりあえず動く参照実装

URLだけでコントロールしたい場合こちらを、セッションを使う場合こちらを、それぞれご参照ください。

2023年12月
          1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31            

最近のトラックバック

無料ブログはココログ