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

2021年1月 9日 (土)

Vue.jsのプロジェクトをSpringに突っ込む

0. もう何番煎じだか

正直なところ、もう何番煎じだかわからないネタだけど、多分に自分用メモ。

今回目指したのはこんな感じのプロジェクトである。

  • フロントエンド側をVue.jsによるSPAにする
  • バックエンドはSpring BootによるREST APIサーバにする
  • フロントエンド、バックエンドとも、Gradleマルチプロジェクトとして一括管理する
  • フロントエンドの表示は、Spring BootのTomcatに同居させる

早い話、フロント用にWebサーバを用意するだけのリソースがないので一つにまとめてしまおう、というだけのことである。いうまでもないけれど、負荷分散とかを考えるならフロントとバックエンドを別のインスタンスに分離するなりの考慮は必要。

1. はじめてみる

できればIDEでちょいちょいとできればいいんだけど、やってみた感じCLIで少しずつ作っていくほうがやりやすかったので、今回はIDEを極力使わず、ターミナルとテキストエディタで対応する。

目標とするのはこういったディレクトリ構造にする。

./ → プロジェクトルート
├─backend/ -> APIサーバになるSpring Bootプロジェクト
|  └─build.gradle.kts -> バックエンドのguild.gradle.kts
├─front-vue/ -> フロントになるVue.jsプロジェクト
|  └─build.gradle.kts -> フロントエンドのguild.gradle.kts
└─build.gradle.kts -> ルートプロジェクトのguild.gradle.kts

1-1. 親プロジェクトを作る

ルートプロジェクトから作るわけだが、今回はマルチプロジェクトにするので、最低必要なものだけ生成する。

  > gradle init --type basic --dsl kotlin --project-name vue-with-spring

ビルドタイプをbasicにするのがミソ。こうすることで、何の設定も書かれていないbuild.gradle.ktsが生成される。

生成できたら、今後のために.gitignoreにIDE周りの無視設定を今のうちに入れておく。

また、システムに入れているGradleのバージョンが古い場合は、wrapperタスクで新しくしておくとよい。

  > ./gradlew wrapper --gradle-version=6.7.1

1-2. Vue.jsのプロジェクトを作る

次にVue.jsのプロジェクトを作る。Vue CLIでvue createすればよい。
Vue.jsのバージョンやらコンポーネントやらはお好みで。

  > vue create front-vue

生成したVue.jsのプロジェクトをGradle配下に置く。これには二つの工程がある。

  1. ルートプロジェクトのsettings.gradle.ktsにプロジェクトのインクルード設定を書く
  2. Vue.jsのプロジェクトにbuild.gradle.ktsを書く

一つ目については、単にinclude行を追加するだけだ。

  rootProject.name = "vue-with-spring"
  include("front-vue") // 追加

二つ目については、こんな感じのファイルを作った。

  import com.github.gradle.node.npm.task.NpmTask

  plugins {
      base
      // node-gradle プラグインで npm を扱う
      id("com.github.node-gradle.node") version("3.0.0-rc5")
  }

  // ``npm run build`` を実行するタスク定義
  tasks.register("npmBuild", NpmTask::class.java) {
      args.add("run")
      args.add("build")
  }

baseプラグインを導入することで最低必要なタスクが実行できるようになるのと、node-gradleプラグインを使うことでNode.jsとnpmをGradleのタスクとして扱うことができるようになる。

後の工程でnpm run buildを実行する必要があるので、これを行うnpmBuildタスクを用意した。

1-3. Spring Bootプロジェクトを組み込む

次に、Spring Bootのプロジェクトを作る。これはSpring Initializrで作る。最低必要なのはSpring Webだけど、プロダクション運用ならこれにデータアクセスとか、認証・認可とか、クラウド連携とかのライブラリが入るはず。

Spring_initilizr

今回はプロジェクトをGradleに、言語をKotlinにした。

この状態でGenerateボタン押すとプロジェクトがZipで降ってくるんだが、この中から必要なファイルだけ取り出し、バックエンドのプロジェクトに移植する。

Demo

バックエンドのプロジェクト自体はすでにGradleプロジェクトになっていて、Spring InitializrですでにGradle Kotlin DSLになっているので、追加したSpring Bootプロジェクトをルートプロジェクトの配下にするだけでよい。

  rootProject.name = "vue-with-spring"
include("front-vue", "backend") // 追加

1-4. Vue.jsのビルド成果物をSpringに取り込めるようにする

Vue.jsのプロジェクトはnpm run buildを実行すると、プロジェクト内の dist ディレクトリ内にプロダクションビルドした内容を出力する。
これをSpring側に反映すればいいのだが、毎度手でコピーしていると面倒極まりないのでビルド成果物を直接Springに出力するよう設定する。

これは、Vue.jsプロジェクト側にVue.js自体の設定ファイルであるvue.config.jsを作成する。(リファレンス

  module.exports = {
    outputDir: "../backend/src/main/resources/static/"
  }

要は、バックエンドプロジェクトのsrc/main/resources/static/に直接出力するわけで、SpringとしてはここにあるファイルをWebコンテナに取り込んで起動するので、これで動いてしまうわけだ。

あとはbootRunタスクを実行すれば、Vue.jsをフロントにしたサイトを表示できる。

  > ./gradlew :backend:bootRun

:backend:bootRunタスクの前処理に:front-vue:npmBuildタスクを実行するような設定を書いてやるのでもいいかもしれないが、開発中はVue.jsのプロジェクトとSpring Bootのプロジェクトを独立して動かして動作確認する、といったシーンも考えられる。
そういった場合は

  .PHONY: front-boot-run
  front-boot-run:
      ./gradlew :front-vue:npmBuild
      ./gradlew :backend:bootRun

みたいなMakefileを書いて、makeターゲットとしてしまうのがいいのかもしれない。

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万円以上の場合は印紙が必要になるのでテンプレートはそこで分ける必要があるが)
  • 受注結果をチャットのタイムラインに流す

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

2020年5月 6日 (水)

Spring Batchで改行がめちゃくちゃなCSVと戦う

0. まだまだSpring Batchと格闘中です

先日実際に起きたお話。

取引先からダウンロードしたとあるCSVをSpring Batchで書いた取込処理にかけたところ、ファイルの読み込みで死ぬケースが起きたので調べてみたところ、CSVのカラムに改行が入っていたことでFlatFileItemReaderがお亡くなりになっていた。
要はこんな感じである。

Csv_with_messy_line_breaks

実リリース前のテストで発覚したのでよかったものの、これはあかん、ということで、どうするべきかを考えることに。

1. 今回のお題目

  • 改行を含む可能性があるのは、特定のカラムだけ
  • 改行コードはバラバラ(CRLF、LF、CRのいずれもありうる)、かつエスケープされていない
  • データのカラムはダブルクォーテーションで囲まれている
  • 1行目はヘッダ行なので読み飛ばす必要がある
  • ....をチャンクモデルでどう扱うかを考えるわけだが、最大の問題は2番目のかつエスケープされていないという箇所。これがもとで、標準のDelimitedLineTokenizerArrayIndexOutOfBoundsExceptionでお亡くなりになってしまう。

    ちゃんとエスケープされているなら大丈夫だそうなんだが。
    TERASOLUNA Batch Frameworkのファイルアクセスの項を参照。

    そういう時にはCSVを読み込むカスタムReaderを作るべしということだそうなので、実際にやってみた。

    2. ItemReader / ItemProcessor / ItemWriter

    読み込みのときに問題があるので、まずItemReaderから。
    先ほどのQiitaの記事ほぼそのままで恐縮だが、使い慣れていたOpenCSVをパーサにしているのと、Builderを追加しているのが違うくらい。

      class CsvItemReader<T>() : AbstractItemCountingItemStreamItemReader<T>(),
          ResourceAwareItemReaderItemStream<T>, InitializingBean {
    
        var charset: Charset = Charset.defaultCharset()
        var linesToSkip: Int = 0;
        var delimiter: Char = ','
        var quotedChar: Char = '"'
        var escapeChar: Char = '"'
    
        private lateinit var resourceToRead: Resource
        private lateinit var headers: Array<String>
        private lateinit var fieldSetMapper: FieldSetMapper<T>
    
        private var noInput: Boolean = false
        private lateinit var csvReader: CSVReader
    
        init {
          setName(this.javaClass.simpleName)
        }
    
        override fun doOpen() {
          Assert.notNull(resourceToRead, "Resource to read is required")
    
          // 例外をスローするとバッチにブレーキがかかる
          noInput = true
          if (!resourceToRead.exists()) {
            throw IllegalStateException("Input resource does not exist : $resourceToRead")
          }
          if (!resourceToRead.isReadable) {
            throw IllegalStateException("Input resource must be readable : $resourceToRead")
          }
    
          // ここからOpenCSVの初期化
          // CSVParser
          val csvParserBuilder = CSVParserBuilder().withSeparator(delimiter)
              .withQuoteChar(quotedChar)
              .withStrictQuotes(true)
          // 同じ値を書き込むと怒られるので、不一致の場合のみにする
          if (quotedChar != escapeChar) {
            csvParserBuilder.withEscapeChar(escapeChar)
          }
    
          csvReader = CSVReaderBuilder(FileReader(resourceToRead.file, charset))
              .withCSVParser(csvParserBuilder.build())
              .withSkipLines(linesToSkip)
              .build()
    
          noInput = false
        }
    
        override fun doRead(): T? {
          // 読める状態にない、あるいは読んだ内容が空だったときは null を渡すと空データとして処理される
          if (noInput) {
            return null
          }
    
          if (csvReader == null) {
            throw ReaderNotOpenException("CSVReader is not initialized")
          }
    
          // OpenCSVで行を読む
          val line: Array<out String> = csvReader.readNext() ?: return null
    
          // FieldSetMapperに読んだ行を渡してPOJOにマップさせる
          val fs: FieldSet = DefaultFieldSet(line, headers)
          return fieldSetMapper.mapFieldSet(fs)
        }
    
        override fun doClose() {
          // 終了処理で呼ばれる
          // 各種パーサはここで閉じておくべし
          csvReader.close()
        }
    
        override fun setResource(resource: Resource) {
          // ResourceAwareItemReaderItemStream から。
          // これを実装しておくと、 MultiResourceItemReader の委譲先にすることができるようになるっぽい
          this.resourceToRead = resource
        }
    
        override fun afterPropertiesSet() {
          Assert.notNull(this.headers, "header is required")
          Assert.notNull(this.fieldSetMapper, "FieldSetMapper is required")
        }
    
        fun setHeaders(headers: Array<String>) {
          this.headers = headers
        }
    
        fun setFieldSetMapper(fieldSetMapper: FieldSetMapper<T>) {
          this.fieldSetMapper = fieldSetMapper
        }
    
        /**
         * 上記CsvItemReaderのビルダ。
         */
        class Builder<T>() {
          private val reader: CsvItemReader<T> = CsvItemReader()
    
          fun build(): CsvItemReader<T> {
            return reader
          }
    
          fun withResource(resource: Resource): Builder<T> {
            reader.setResource(resource)
            return this
          }
    
          fun withFieldSetMapper(fieldSetMapper: FieldSetMapper<T>): Builder<T> {
            reader.fieldSetMapper = fieldSetMapper
            return this
          }
    
          fun withHeaders(headers: Array<String>): Builder<T> {
            reader.headers = headers
            return this
          }
    
          fun withCharset(charset: Charset): Builder<T> {
            reader.charset = charset
            return this
          }
    
          fun withLinesToSkip(linesToSkip: Int): Builder<T> {
            reader.linesToSkip = linesToSkip
            return this
          }
    
          fun withDelimiterChar(delimiter: Char): Builder<T> {
            reader.delimiter = delimiter
            return this
          }
    
          fun withQuotedChar(quotedChar: Char): Builder<T> {
            reader.quotedChar = quotedChar
            return this
          }
    
          fun withEscapeChar(escapeChar: Char): Builder<T> {
            reader.escapeChar = escapeChar
            return this
          }
        }
      }

    ItemReaderとセットで使うFieldSetMapperは単純。たぶん、みればわかるレベル。

      @Component
      class CsvUserMapper: FieldSetMapper<CsvUser> {
        override fun mapFieldSet(fieldSet: FieldSet): CsvUser {
          // fieldSet の値を順番に抜いてはめて返すだけ
          return CsvUser(fieldSet.readString(0), fieldSet.readString(1))
        }
      }

    そのほか、中間処理をうけもつItemProcessorと書き込みを受け持つItemWriterも、話を単純にするためごく単純にしてみた。

    • ItemProcessor
      @Component
      class CsvImporterProcessor : ItemProcessor<CsvUser, AppUser> {
        override fun process(item: CsvUser): AppUser? {
          // 単にインスタンスを組み替えるだけ
          return AppUser(item.username, item.description)
        }
      }
    • ItemWriter
      @Component
      class CsvItemWriter(private val appUserRepository: AppUserRepository): ItemWriter<AppUser> {
        override fun write(items: MutableList<out AppUser>) {
          // こちらも右から左に永続化するだけ
          // AppUserRepository は AppUser の JpaRepository
          appUserRepository.saveAll(items)
        }
      }

    この状態でStepをSpring Beanとして構成してやる。

      @Bean
      fun csvItemReader(csvUserMapper: CsvUserMapper): ItemReader<CsvUser> {
        return CsvItemReader.Builder<CsvUser>()
                .withCharset(StandardCharsets.UTF_8)
                .withResource(ClassPathResource("/csv/userdata.csv")) // クラスパス内にあるファイルを指定している
                .withFieldSetMapper(csvUserMapper)
                .withLinesToSkip(1) // 1行飛ばす
                .withHeaders(arrayOf("username", "description"))  // ヘッダをマップするメンバーの定義
                .withDelimiterChar(',') // 区切り記号
                .withQuotedChar('"')    // 囲み文字
                .build()
      }
    
      @Bean
      fun step1(csvItemReader: ItemReader<CsvUser>, csvItemWriter: CsvItemWriter, csvImporterProcessor: CsvImporterProcessor): Step {
        return stepBuilderFactory.get("csvItemReaderStep")
                .chunk<CsvUser, AppUser>(10)
                .reader(csvItemReader)
                .processor(csvImporterProcessor)
                .writer(csvItemWriter)
                .build()
      }

    こうしてやることで、ようやく改行を含むカラムをちゃんと読めるようになった。
    DBに書き込んだ結果がこちら。

    Insert_result_1

    3. ソースコード

    https://github.com/f97one/LineBreakAwareCsvImporterDemoをご参照ください。

    2020年5月 4日 (月)

    WicketとSpringを悪魔合体させる その2

    0. ではがったいさせるぞ(通算二度目)

    WicketとSpringを悪魔合体させるアプローチとして、前回の記事では

    1. WicketをベースにSpringのDIを個別に組み込む
    2. Spring BootをベースにフロントをWicketにする

    のうち前者にチャレンジしてみたが、今回は後者をやってみる。

    1. 準備

    Spring Bootをベースにするので、まずは普通にSpring InitializrでSpring Bootなプロジェクトを作る。
    この時のポイントは、テンプレートエンジンは入れないこと。Apache WicketはViewよりの機能を提供するので、画面組立はWicketにさせるためだ。

    以下、pom.xmlのdependenciesを抜粋。

      <dependencies>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-kotlin</artifactId>
    </dependency>
    <dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-reflect</artifactId>
    </dependency>
    <dependency>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-stdlib-jdk8</artifactId>
    </dependency>
    <!-- Apache Wicket -->
    <dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-spring</artifactId>
    <version>${wicket.version}</version>
    </dependency>
    <dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-core</artifactId>
    <version>${wicket.version}</version>
    </dependency>
    <dependency>
    <groupId>org.apache.wicket</groupId>
    <artifactId>wicket-ioc</artifactId>
    <version>${wicket.version}</version>
    </dependency>
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
    <exclusion>
    <groupId>org.junit.vintage</groupId>
    <artifactId>junit-vintage-engine</artifactId>
    </exclusion>
    </exclusions>
    </dependency>
    </dependencies>

    2. 普通のSpring Bootアプリケーションからいじるところ

    まず、Spring BootにすることでKotlinのコードはsrc/main/kotlinに、ページに紐づくViewのHTML等それ以外のファイルはsrc/main/resourcesに、それぞれ配置されるようになるが、WicketのQuick Startで作ったプロジェクトではJavaのコードもページに紐づくHTMLも同じ場所に置かれるようになっている。

    ここは趣味の問題と言えるけど、Wicketのしきたりに従うなら、pom.xmlのbuildセクションをいじってHTMLをsrc/main/kotlin以下に置けるようにするといい。

      <build>
    <resources>
    <resource>
    <filtering>false</filtering>
    <directory>src/main/resources</directory>
    </resource>
    <resource>
    <filtering>false</filtering>
    <directory>src/main/kotlin</directory>
    <includes>
    <include>**</include>
    </includes>
    <excludes>
    <exclude>**/*.kt</exclude>
    </excludes>
    </resource>
    </resources>
    <testResources>
    <testResource>
    <filtering>false</filtering>
    <directory>src/test/resources</directory>
    </testResource>
    <testResource>
    <filtering>false</filtering>
    <directory>src/test/kotlin</directory>
    <includes>
    <include>**</include>
    </includes>
    <excludes>
    <exclude>**/*.kt</exclude>
    </excludes>
    </testResource>
    </testResources>
    <!-- 以下略 -->
    </build>

    またWicketには、クラシックなwarで使われるweb.xmlで設定している設定が必要になる。
    Create a Wicket Quickstartで作ることができるmvn archetype:generateの結果には、以下のようなweb.xmlが含まれているはず。

    <?xml version="1.0" encoding="ISO-8859-1"?>
    <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://www.oracle.com/webfolder/technetwork/jsc/xml/ns/javaee/web-app_3_1.xsd"
      version="3.1">
    
      <display-name>test-wicket-app1</display-name>
    
      <!--
        There are three means to configure Wickets configuration mode and they
        are tested in the order given.
    
        1) A system property: -Dwicket.configuration
        2) servlet specific <init-param>
        3) context specific <context-param>
    
        The value might be either "development" (reloading when templates change) or
        "deployment". If no configuration is found, "development" is the default. -->
    
      <filter>
        <filter-name>wicket.test-wicket-app1</filter-name>
        <filter-class>org.apache.wicket.protocol.http.WicketFilter</filter-class>
        <init-param>
          <param-name>applicationClassName</param-name>
          <param-value>net.formula97.webapps.WicketbootlinApplication</param-value>
        </init-param>
      </filter>
    
      <filter-mapping>
        <filter-name>wicket.test-wicket-app1</filter-name>
        <url-pattern>/*</url-pattern>
      </filter-mapping>
    </web-app>

    Spring BootにすることでJava Configで書くことができるようになるので、これをもとにServletContextInitializerの実装クラスをConfigurationにして書いてやる。

      @Configuration
      class ServletInitializer: ServletContextInitializer {
        override fun onStartup(servletContext: ServletContext) {
          // web.xml の設定をもとに書く
          // WicketFilter は Apache Wicket の ServletFilter
          val registration: FilterRegistration = servletContext.addFilter("wicket.wicketbootlin", WicketFilter::class.java)
          registration.setInitParameter(WicketFilter.APP_FACT_PARAM, SpringWebApplicationFactory::class.java.name)
    
          // メインクラスのFQCNをここで指定する
          registration.setInitParameter("applicationClassName", WicketbootlinApplication::class.java.name)
          // いわゆるサーブレットフィルタ部分
          registration.setInitParameter(WicketFilter.FILTER_MAPPING_PARAM, "/*")
          registration.addMappingForUrlPatterns(null, false, "/*")
    
          // 起動モード指定
          // spring.profiles.active の値に応じて development と deployment を切り替えるとかもアリだろう
          registration.setInitParameter("configuration", "development")
        }
      }

    最後にエントリーポイントになるWebApplicationだが、Spring Bootのエントリーポイントは、作った直後ではこうなっているはず。

      @SpringBootApplication
    class WicketbootlinApplication
    companion object { @JvmStatic fun main(args: Array) { runApplication(*args) } }

    なので、エントリーポイントのクラスをWebApplicationの継承クラスにして、getHomePage()とinit()を実装する。

      @SpringBootApplication
      class WicketbootlinApplication: WebApplication() {
        @Autowired
        private lateinit var applicationContext: ApplicationContext
    
        companion object {
          @JvmStatic
          fun main(args: Array) {
            runApplication(*args)
          }
        }
    
        override fun getHomePage(): Class {
          return HomePage::class.java
        }
    
        override fun init() {
          super.init()
    
          // レスポンスとマークアップ時のエンコーディングをUTF-8にする
          requestCycleSettings.responseRequestEncoding = CharEncoding.UTF_8
          markupSettings.defaultMarkupEncoding = CharEncoding.UTF_8
    
          // ComponentScanの結果を反映
          // これで Wicket から Spring による DIコンポーネントを使うことができるようになる
           componentInstantiationListeners.add(SpringComponentInjector(this, applicationContext))
    
          // todo ページルーティングを書く
          mountPage("/", HomePage::class.java)
        }
      }

    これでフロントがWicketのSpring Bootアプリケーションになったので、

      @Service
      interface EnterpriseMessage {
        fun getMessage(): String
        fun getVersionCode(): String
      }

    こんなServiceを

      class HomePage(params: PageParameters): WebPage(params) {
        // Page クラスは Spring の配下にあるわけではないので、
        // Autowired ではなく org.apache.wicket.spring.injection.annot.SpringBean を使う
        @SpringBean
        private lateinit var enterpriseMessage: EnterpriseMessage
    init { // ページに値を張る add(Label("message", Model.of(enterpriseMessage.getMessage()))) add(Label("versionCode", Model.of(enterpriseMessage.getVersionCode()))) } }

    とやることでPageクラスから利用できるようになる。
    ページに値を張った結果はこんな感じ。
    Injected

    あとは、ビジネスロジックやデータアクセスに関する部分は、Springのもつ強力な機能を活用すればいいだろう。

    2020年5月 3日 (日)

    WicketとSpringを悪魔合体させる その1

    0. ではがったいさせるぞ

    データアクセスがからむユニットテストをやるには、やはりDIの力を借りるのが手っ取り早い。

    CDIでもいいんだけれど、今回はSpringを使ってみることにする。
    アプローチとしては

    1. WicketをベースにSpringのDIを個別に組み込む
    2. Spring BootをベースにフロントをWicketにする

    の二つがあるが、今回は前者にチャレンジしてみる。

    1. 準備

    何はなくともpom.xmlの依存設定だ。wicket-springを依存に追加する。
    そのほか、SpringのDIをつかうので、Spring Contextとjavax.annotation-apiも加える。

      <dependency>
        <groupId>org.apache.wicket</groupId>
        <artifactId>wicket-spring</artifactId>
        <version>${wicket.version}</version>
      </dependency>
      <dependency>
        <groupId>org.springframework</groupId>
        <artifactid>spring-context</artifactId>
        <version>5.1.10.RELEASE</version>
      </dependency>
      <dependency>
        <groupId>javax.annotation</groupId>
        <artifactid>javax.annotation-api</artifactId>
        <version>1.3.2</version>
      </dependency>

    2. 各所の実装

    話を簡単にするため、今回はView側にDIでねじ込まれた値を張るだけにしてみる。こんな感じ。

    • HomePage.html
      <!DOCTYPE html>
      <html xmlns:wicket="http://wicket.apache.org" lang="ja">
      <head>
          <meta charset="utf-8" />
          <title></title>
      </head>
      <body>
      <h1>
          <span wicket:id="message">hello</span>
      </h1>
      <p>
          java.version = <span wicket:id="versionCode">8</span> .
      </p>
      </body>
      </html>
    • HomePage.kt
      class HomePage(parameters: PageParameters): WebPage(parameters) {
        // Autowired ではなく org.apache.wicket.spring.injection.annot.SpringBean を使う
        @SpringBean
        private lateinit var enterpriseMessage: EnterpriseMessage
    
        init {
          add(Label("message", Model.of(enterpriseMessage.message)))
          add(Label("versionCode", Model.of(enterpriseMessage.versionCode)))
        }
      }

    エントリーポイントになるWebApplicationの継承クラスは、DIしたいパッケージにコンポーネントスキャンをかけてSpringの管理下に置く処理を書く。

      class WicketApplication(): WebApplication {
        override fun init() {
          super.init()
    
          val ctx = AnnotationConfigApplicationContext()
          // コンポーネントスキャン対象を指定
          ctx.scan("net.formula97.webapps.beans")
          ctx.refresh()
          // Wicket-Springの機能でSpringの管理下に置く
          getComponentInstantiationListeners().add(SpringComponentInjector(this, ctx))
        }
      }

    Spring管理下に置かれたクラスは、今回は定数クラスっぽい扱いにしてみた。

      @ManagedBean
      class EnterpriseMessage {
        val message: String = "Welcome to the Spring-Integrated world!"
        val versionCode: String = System.getProperty("java.version")
      }

    こうしてやることで、マネージドビーンをnewすることなく(そもそもKotlinだとインスタンスを作るのにnewというキーワードは使わないのだが)使うことができるようになる。

    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            

    最近のトラックバック

    無料ブログはココログ