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年8月 6日 (土)

後付けでMDMを全社導入した話

0. 手作業はつらいのである

弊社、現時点で60名以上いる従業員ひとりひとりにiPadを貸与していて、これを社内の情報共有基盤にしている。

加えて、ビジネスチャットにLINE WORKSを使っているのだが、iPadは原則Wi-Fi運用(SIMスロットはある)でデータSIMは一部従業員にしか貸与していないので、全体的な利便性を考慮してBYOD機器に対してLINE WORKSアプリのインストールと使用を許可している。

翻って、iPadは事前にMacアプリのApple Configurator 2(以下AC2と呼称)を使い、Apple Business Manager(以下ABMと呼称)のAppとブックで取得したライセンスを機器に個別に割り当てることでキッティングし、従業員に配布している。
このため、従業員がApp Storeを使ってアプリをアップグレードしようとしても「ユーザーが違う」と怒られるため、アプリの更新はキッティングしたMacを持って従業員のところを駆けずり回る、という一大イベントになる。

結果、実際のアプリ更新だけで0.6人月程度、その他事前検証や日程調整などを含めると、iPadアプリの更新管理に年間で1人月弱の工数がかかっており、これが地味に重たい。

1. そこでMDM

このほど、モバイルデバイス管理(以下MDMと呼称)サービスの導入が許可されたため、なるべく従業員の環境を壊さずMDMを後付け導入する方法を模索していたところ、幸運にもうまくいったので、その時のことを残しておく。

1-1. 前提条件

結論から言えば、以下すべてを満たしていれば、ユーザーの環境を破壊することなく後付けでMDMを導入できる。

  • Appleお客様番号を取得している
  • ABMを利用している
  • iOS機器のキッティングは、「監視モード」で行っている

1-2. 解説

Appleお客様番号は、後述する自動デバイス登録(Automated Device Enrollment、以下ADEと呼称)を使うのに必要。
弊社の場合、iPadは直販で買ったのでAppleビジネス窓口経由で取得したが、リセラー経由の場合はリセラーに一度相談したほうがいい。

極力Apple製デバイスを買う前に取得しておくのが望ましいが、一応後からでも取得は可能。弊社は後からとった。

Appleお客様番号が払い出されると、それ以後に買ったApple製デバイスはすべてABMに自動登録される。また、その企業(または団体)専用のApple Store(Custom Apple Storeと呼ばれる)の利用申請ができるようになる。

一方「監視モード」については、キッティング時にしか選択できない。あとから監視モードにしたいときは、AC2でiOS機器を再セットアップしなければならない。

監視モードになっていると、構成プロファイルを書き込むことでiOS機器を細かくコントロールできるようになるが、MDMへの後付け加入はMDM構成プロファイルのインストールで実現しているため、監視モードは必須になる。

ABMの登録についてはキッティングの段階で使用しているはずなので、とくに問題はないはず。
ABM内で以下を行っておく。

  1. 「一番権限が低い管理対象Apple ID(Managed Apple IDと呼ばれる)」を1つ以上作成しておく
  2. Appleお客様番号を登録

2. 設定開始

いろいろ検討した結果、Optimal Bizを導入。こちらは直販ではなくリコージャパン株式会社をリセラーにした。

なので、ここからはOptimal Bizを前提にする。

  1. APNs証明書をABMに登録
    Optimal Bizからエージェントに指示を出すのにプッシュ通知を使うため、その証明書をOptimal Bizから取得し、ABMに登録する。
  2. 「Appとブック」トークン(VPPトークンとも)、ADEトークンの証明書署名要求(CSR)ファイルをOptimal Bizで作成し、これらをABMで署名、署名済み証明書をOptimal Bizに登録
  3. Wi-Fiアクセスポイント設定、VPN設定などをAC2で作成し、Optimal Bizにインポート
  4. 機能制限設定についてはOptimal Bizで作成できるので、上記と合わせて構成プロファイルセットを構成テンプレートに設定しておく
  5. 組織をOptimal Bizで作成し、構成テンプレートを組織に割り当てる
    同様に組織には、Appとブックのライセンス設定とアプリケーション配信設定も設定できる。
  6. ユーザーをOptimal Bizに登録し、組織に割り当てる
    こうすることで、同じ組織内のユーザーには同じ設定が適用される。
  7. デバイスを登録する
    ここでデバイスをユーザーに割り当てることで、次のようなメリットが生まれる。
    1. ユーザーあたり5台までアプリをインストールできるようになる
    2. アプリのライセンス割り当てとインストール割り当てを分離できるので、同じユーザーの所有機器でもアプリの有無をコントロールできるようになる
  8. 実際にiOS機器でOptimal Bizエージェントのセットアップを行う

ユーザーにアプリのライセンスを割り当てていると、「MDMへの参加」が必要になる。
ユーザー操作が必要になるが、

  1. 対象のiOS機器でユーザーがApple IDを自前で用意している場合は、「そのApple IDのパスワード」が要求されるので、ユーザーに入力させる
  2. 一方Apple IDを登録していない場合は、前述の「一番権限が低い管理対象Apple ID」でログインする

ここまでやってMDMに参加済みになると、配布設定を行っているアプリやら設定やらが落ちてくる。

3. 初期化できる場合は

初期化できる環境の場合、前述のADEが利用できる。

設定には、Optimal Bizの利用を開始すると払い出される企業コードと認証コードが必要。

  1. AC2にOptimal BizのMDMサーバURLを登録(※1回行えばよい)
    ダウンロード可能なマニュアルにも書かれていない上にFAQのWebページにしか書かれていないので、設定の際は以下を参考にされたし。
    https://biz3.optim.co.jp/企業コード/setup/ios/dep_enroll?auth_code=認証コード
  2. AC2の準備メニューで初期化
    初期化の際、手動構成のほうを選び、「ABMにデバイス登録する」チェックを入れる。こうすることで、ABMのデバイスにAC2で初期化したデバイスが登録される。

あとは通常のキッティング手順でデバイスを設定していけばよい。

キッティングが終わったら、ABM上のMDM登録先をAC2からOptimal Bizに変えておくことをお忘れなく。

4. Appleお客様番号を取得した後に買った機器の場合は

前述の3. 初期化できる場合は同様、ADEが利用できる。

すでにABMにデバイスが登録されているため、AC2で「準備」メニューを実行するだけでよい。こちらのケースの場合は自動構成とすればよい。

5. BYOD上のLINE WORKSについて

従業員貸与のiPadはMDM管理下に入ったので、有事の際は情シスが介入できるようになった。

ではBYOD上のLINE WORKSについてはどうするか。

実は、LINE WORKSにはLINE WORKS MDMと呼ばれるMDM機能が最初から用意されており、有効にしておくとアプリデータ、または機器のリモートワイプができるようになっている。

今回Optimal Bizを全社導入するにあたり、BYOD機器にLINE WORKSをインストールしている従業員には、BYOD機器でこれを有効にしてもらうこととした。

6. アプリが欲しくなったとき

Appとブックでライセンスを取得したアプリを配布しているため、ユーザー操作で自由にアプリを入れることができなくなった。(※これは想定内)

ただ、そうはいってもアプリが欲しい場合もあるので、そういう場合はインストール申請を出してもらうようにしてみた。

所定の内容が網羅されるよう、これにはテンプレート機能を使った。

Tmpl_install_req

こんな感じのテンプレートを全社共通テンプレートに登録し、必要の都度情シスに出してもらう、という想定。

なお、これを作った後で「アンケート機能を目安箱的に運用する」という方法をLWUGで紹介されたので、そっちに切り替えるかも。

7. で、どうなったか

アプリの更新管理でとられていた時間を使えるようになったことと、有事の際に情シスが介入できる幅が大幅に増えたことは、とても有意義だと思う。

ただ、まだ走り出したばかりなので、これから制度設計の見直しなども必要になるかもしれない。

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年8月26日 (木)

IT資産管理システムで白衣を管理する

0. 数が多いと大変なのだ

僕はいわゆる社内SEというやつなので、社内のIT資産のライフサイクル管理なんかもやる。

で、PCだけならまだしも従業員貸与品のiPadやスマホ、複合機なんかもあると、そりゃもうたいへんで、xls管理なんかじゃ回らないのは明白。

というわけでIT資産管理システムであるGLPIを導入してみた。

1. GLPI is 何

フランスのTeclib'というベンダーが中心になってGPL 2.0のOSSとして開発しているIT資産管理システム。PHPで書かれている。

コンピューターの資産台帳としてだけでなく、ITコンシェルジュむけのメンテナンスチケット管理機能やラッキング図の管理もできる優れものだ。
セットアップもごく普通のLAMP構成と変わらないので、難易度はそれほど高くないと思う。

ただ、GLPI自体のインベントリ機能はあまりあてにならないので、連動可能なインベントリ製品の一つであるFusionInventoryも導入してみた。

2. そして本題

GLPIの扱いがちょっとわかるようになってきたところで、弊社のボスからこんなお話を賜る。

薬局の従業員に貸し出している白衣をシステム的に管理できないか?

確かに、GLPIにはGeneric Objectというカスタム汎用オブジェクトを作るプラグインがあるので、これでどうにかならないか試してみることに。

ただこのプラグイン、インストール自体はプラグイン管理画面からサクッと終わらせることはできるんだが、入れただけでは何ら使い物にならないので注意が必要。

2-1. 要求仕様

白衣をGLPIで扱えるIT(なのか?)資産として定義するにあたり、何が必要なのかをちょっと考えてみる。
必要そうな入力項目はざっとこんなところか。

  • 名前
  • 資産番号
  • 貸出先ユーザー
  • 半袖、長袖の別
  • 男性用、女性用の別
  • サイズ
  • 貸し出し中か、在庫か
  • 導入日
  • 滅却日
  • 調達先の名前
  • その他のメモや注釈

あと、ユーザー一人に対して複数の白衣を貸与する(洗い替えも必要なので)ので、名前が一意である必要はない。というか一意名しか受け付けないとなると、名前空間がめんどくさいことになる。

2-2. 白衣を定義する

プラグインを入れたら、オブジェクトモデルを作る。
早い話ここで白衣を定義するわけだが、

Def_object_model1

この図でいうInternal identifierには小文字アルファベットしか受け付けない。で、ラベルはInternal Identifierの先頭をキャピタライズして表示される。
ここでは白衣を表すwhiterobeにしてみた。

モデルを定義すると、$GLIP_VAR_DIR/files/_plugins/genericobject/locales/whiterobe/whiterobe.ja_JP.phpというファイルができている。これが多言語リソースになるので、

  <php
  $LANG['genericobject']['PluginGenericobjectWhiterobe'][0]="白衣";

と日本語を書いていく。多次元(しかも3次元)配列なのはPHPという言語の癖と割り切るしかないが、最後の添え字の0はラベルに割り当てられているので、これでラベルが日本語になる。

次にBehaviourとプラグインを割り当てる。

Behaviourは、雑に説明するとモジュールなようなもので、子アイテムを持てるようにするものやゴミ箱、履歴などがある。
今回は以下のようにしてみた。

Def_object_model2

プラグインは、CSVによるバルクインポートができるようにinjection file pluginを、資産管理番号をシステム側で生成させるようにgeninventorynumber pluginを、それぞれ有効にしてみた。

次に入力フィールドを作る。

作成日や情報資産番号などはプリセットのフィールド定義があるので、画面下のAdd new fieldから追加すればいいのだが、サイズや購入先といったフィールドはないので自分で作る必要がある。

Def_object_model3

$GLIP_VAR_DIR/files/_plugins/genericobject/fields/以下に、whiterobe.constant.phpというファイルを作って入力フィールドを定義する。

  <php
  global $GO_FIELDS, $LANG;
  // サイズのドロップダウン
  $GO_FIELDS['plugin_genericobject_sizes_id']['name'] = $LANG['genericobject']['PluginGenericobjectWhiterobe'][1];
  $GO_FIELDS['plugin_genericobject_sizes_id']['field'] = 'size';
  $GO_FIELDS['plugin_genericobject_sizes_id']['input_type'] = 'dropdown';
  // 購入先のテキストボックス
  $GO_FIELDS['plugin_genericobject_vendornames_id']['name'] = $LANG['genericobject']['PluginGenericobjectWhiterobe'][2];
  $GO_FIELDS['plugin_genericobject_vendornames_id']['field'] = 'vendorname';
  $GO_FIELDS['plugin_genericobject_vendornames_id']['input_type'] = 'text';
  // 在庫状態のドロップダウン
  $GO_FIELDS['plugin_genericobject_stockstates_id']['name'] = $LANG['genericobject']['PluginGenericobjectWhiterobe'][3];
  $GO_FIELDS['plugin_genericobject_stockstates_id']['field'] = 'stockstate';
  $GO_FIELDS['plugin_genericobject_stockstates_id']['input_type'] = 'dropdown';

$GO_FIELDSの第1次元のキーには

Trailing s_id is mandatory in [plugin_genericobject_field*s_id*] because the GLPI framework requires foreign key fields to end with s_id. In database, glpi_plugin_genericobject_fields is table name and id, its foreign key. See GLPI developer documentation.

と、GLPIが外部キーを生成する際のルール上の制約があるので注意。(出典)

キーが正常に認識されたら、Add new fieldの中に定義したフィールドがあるはずなので、これを追加して表示順を調整すればいい。

最後は画面左のプロファイルタブを選択してプロファイルに権限を割り当てたあと、メインタブで有効に設定することで、情報資産の中に白衣が追加される。

あとはモデルやサイズを適宜追加してやれば使えるようになる。

3. 白衣を一意に特定するには

これには前述の情報資産番号を使う。数字12桁で生成すればEAN-13形式のバーコードのネタとして使えるので、TEPRA PROテープカートリッジ アイロンラベルに専用ソフトのSCP10を使って印刷すれば、白衣にバーコードを付加できるようになるので、バーコードリーダーで検索できるようになる。

2021年8月18日 (水)

小ネタ:adb start-serverできないときの対処

0. TL; DR

netsh interface portproxy reset でポート プロキシ構成をリセットする

1. 解説

普段使いのWindows機で久しぶりにAndroidアプリを書こうと思い、エミュレータを起動したときのこと。

エミュレータなので起動タイムアウトしたようで、adb serverの起動が失敗したとみなされたようだ。
以下はadb serverを起動しようとした時の出力。

  PS C:\Users\f97on> adb devices
  adb.exe: failed to check server version: protocol fault (couldn't read status): No error
  PS C:\Users\f97on> adb start-server
  * daemon not running; starting now at tcp:5037
  could not read ok from ADB Server
  * failed to start daemon
  error: cannot connect to daemon
  PS C:\Users\f97on> adb kill-server
  error: failed to read response from server
  PS C:\Users\f97on> adb start-server
  error: protocol fault (couldn't read status): No error

adb serverが起動しないことにはアプリを実行できないので、エラーメッセージをもとにググる。

すると、「netsh interface portproxy reset でポート プロキシ構成をリセットすると回復した」という記事を発見。
実際にやってみる。

  PS C:\Users\f97on> netsh interface portproxy reset

  PS C:\Users\f97on> adb start-server
  * daemon not running; starting now at tcp:5037
  * daemon started successfully

おかげさまで無事エミュレータと通信できるようになった。

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年5月27日 (木)

#LINEWORKS にBot投稿させるCLIアプリを作ってみた

0. 自動処理に組み込みたかったので

cron実行している処理の結果は、基本的に/var/logの下に書かれていくわけだが、異常な場合だけは早く知りたい、というのがある。

弊社ではLINE WORKSを導入しているので、これでBotにしゃべらせれば要件を満たせるのでは?ということで、Goでちゃちゃっと書いてみた。

ソースコードはGitHubに上げてある。

1. 使い方

さらっと書くとこんな感じ。

  LineWorksBotMessenger [options] messages
    -c configFilePath
          configuration file path
    -d userId
          Destination username to speak
    -k authorizationKeyPath
          Authorization Key file path
    messages
          messages to make LINE WORKS Bot speak

messagesの部分は、標準入力にも対応させた。そのおかげでパイプライン処理をそのまま受け付けることができるので、

cat logfile.txt | LineWorksBotMessenger [options...]

みたいな処理も可能。

使用に際しては、LINE WORKSのDeveloper Consoleと管理者画面の両方で、Botを使えるようにしておくことをお忘れなく。

なお、Bot APIは無料枠でも使えるので、システム管理のお供にどうぞ。

2. ひとりごと

最初はPythonで書こうかと思ったんだが、以下2つがネックになったのでGoで書いてみた。

  • 依存モジュールをシステムにインストールしないといけない
  • かといって、virtualenvだと実行ファイルを置くディレクトリを動かしにくくなる

2021年4月20日 (火)

読書感想文:成果を出すために長時間労働は必要か

0. Twitterで書くには長すぎるので

というブログ記事を読んだ。

普段は人様のブログ記事にアンサーバックするようなブログは書かない(というかそういう行為は割とめんどくさいと思っている)んだけど、ちょっと感想が長くなりそうなのでポストしてみる。

1. 「長時間労働しなくても仕事はどうにでもなる」について

確かにどうにでもなる。が、僕の経験の範囲でいえば、これには条件がつく。それは、成果の結果としてできたアウトプットが、冷静かつ客観的に評価される環境があることだ。

時間を使って努力した結果が成果として目に見えればいうことはないのだが、準委任契約で業務の開始時点で売上が決まっているような仕事しか割り当てられない環境だと、何をやっても同じになってしまう。仕事で努力してアプトプットを作っても、それが評価されることはないのである。

特に

成果を出せない人は重要な仕事は任されません。ここ超重要です。

なぜこれが重要なのかというと、重要な仕事を任せてもらえる人は、その仕事の経験値でさらに成長が加速していくからです。単純作業だけ任される人よりも、重要な仕事を任される人の方が成長スピードが早いのは当たり前です。

若い頃についた優劣の序列は歳をとっても逆転することはほとんどありません。むしろほとんどの場合格差は広がる一方です。なぜなら早いうちに成果を出した人はより重要な仕事を任されてさらに成長速度が上がっていくからです。一度ついたこの序列を逆転するには並大抵の努力では困難です。不可能ではありませんが相当努力しないと難しいでしょう。

の箇所、同意はできるのだが手放しには同意できない。なぜなら、同期入社でも早い段階から準委任契約の商材扱いにされてしまうと、成果を出したくても客観的な数字は契約時点で固定されてしまっているので、何をしても(ポジティブな意味での)成果がなことになってしまう。

そういうのを間近に見てしまったこともあり、成果を出せるのは環境に依存するところが大きいと思うのだ。

2. 「高い成果を出すためには時間は絶対に必要」について

正しいやり方で、たくさん時間を使う。という箇所には大いに同意できるのだが、

これが成果を出すために必要なことです。正しいやり方はどうすればわかるの?と疑問に感じる人もいると思います。その答えは正解を知っている人に聞きましょう。正解を知っている人とは、すでに高い成果を出している人です。身近にそういう人がいたら、その人の話をよく聞いてスポンジのように吸収しましょう。身近にそういう人がいないなら、成果を出している人をインターネットで探して、そういう人の書籍を読んだりセミナーを受講したりしましょう。とくに読書は簡単なのに費用対効果が抜群なのでおすすめです。

の箇所、前述の「準委任契約の商材扱い」から逃れていたメンバーに聞いてみたら、「いつものとおりやっているだけ」との回答が。エンジニアとして優秀な若手だったのはそうなんだけど、彼が携わった業務を棚卸してみると、準委任契約の商材になっていたら絶対にできないような施策を行っていたことが分かった。

成果とはそういうところに集まる、ということなんだろう。

3. 「大事なのは結果であってプロセスではない」について

意味もなく休日出勤して頑張っている姿をアピールするなど論外なのはまったく同意。

結果であってプロセスではないのは、ある意味残酷ではあるけれど真理だと思う。

4. 「全ての人が圧倒的な成果を出す必要はない」について

自分はプライベートを重視したいから仕事の成果は30くらいでいい。今までは80の成果を出してきたけど子供が生まれたのでしばらくは50くらいの成果にしたい。こういう選択が自分で選べる会社が良い会社です。逆にこうした選択ができずに、常に80以上の成果を出せ!と会社に強制されるようだと、どこかのタイミングで必ず生きるのが辛くなります。

の箇所、昔話をする。

ある日、当時の経営層がアメーバ経営を導入しだしたとき、アメーバの単位を「準委任契約の商材とそれ以外」でグルーピングしてきたことがあった。

評価軸の一つに「アメーバとしての売上」があるのだが、業務の契約時点で売上が決まっている準委任契約の商材しかいないアメーバだと、何をしても浮き沈みすることはないので、逆説的に何もしないことが最適解になってしまう。一方、何かやればそれが売上という結果に反映される「それ以外のメンバーで構成されたアメーバ」とは、当然のことながら勝負になるわけもない。

しかしながら、制度上アメーバ間の競争は常に発生するので、前述の引用のように、自分ではどうしようもない環境起因の結果のせいで、会社での労働が生きづらいものと感じていた。

これは自分にとっては辞めた会社の話であってすでに過去の話なので、もうどうでもいいのだが、残った人たちはこのいびつな評価体制をどう思っているのだろうか。

5. 「睡眠時間だけは削ってはいけない」について

これについては完全に同意。1日8時間くらいは寝ていたいw。

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

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            

最近のトラックバック

無料ブログはココログ