Gobble up pudding

プログラミングの記事がメインのブログです。

MENU

Spring Boot 例外処理/エラーハンドリングメモ

f:id:fa11enprince:20190306032659j:plain

Spring Bootでは例外処理はthrowしてしまえばわりとよしなにやってくれるが… それでも考えるべきことはある。 場合によっては例外処理をあまり使わず、オブジェクトの戻り値による処理をすることもある。 と、去年の1月からSpring Bootを使い始めてSpringの良さがわかり、Springの深いところに潜り始めた。 気になったところをメモします。 気が向いたら更新しようかと思います。 雑にメモしていたので、気づいた誤りを修正しました。

全てここを参考にさせていただいています。 いろいろ学びが多く、とても良い資料です。

例外処理のパターン@Controller編

基本的には

  1. @Controllerに個別で@ExceptionHandler
  2. @ControllerAdvice@ExceptionHandler
  3. Spring BootのError Controller

3番目は知らなかった 1番目と2番目はよく使うと思う

@Slf4j
@ControllerAdvice("com.example.controllers.foo")
public class GlobalControllerAdvice {
    @ExceptionHandler(Exception.class)
    public String handleException(Exception e, HttpServletResponse response, Model model) {
        response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
        log.error("Error occurred.", e);
        model.addAttribute("errorMessage", e.getMessage());
        
        return "foo/error";
    }
}

プレゼンテーション層、404エラーなど全ての例外を補足できない&status codeがセットされないという問題がある

ErrorControllerを使う

Spring MVCでは次の順に呼ばれるとのこと

  1. ExceptionHandlerExceptionResolver(@ExceptionHandler)
  2. ResponseStatusExceptionResolver(@ResponseStatus)
  3. DefaultHandlerExceptionResolver(Spring MVCで起きた例外を処理)

ErrorControllerはこれらが適用された後に呼ばれる

例外処理のパターン@RestController編

  1. @RestControllerに個別で@ExceptionHandler
  2. @RestControllerAdvice@ExceptionHandler
  3. @ResponseStatusを付与した例外をThrow

ExceptionHandlerを使う場合

@Data
public class ErrorResponse {
    private String keyName;
    private String keyValue;
    private String message;

    public ErrorResponse(String keyName, String keyValue, String message) {
        this.keyName = keyName;
        this.keyValue = keyValue;
        this.message = message;
    }
    public ResponseEntity<ErrorResponse> createResponse(HttpStatus status) {
        return new ResponseEntity<ErrorResponse>(this, status);
    }
    public static ResponseEntity<ErrorResponse> createResponse(BadRequestException e) {
        return new ResponseEntity<ErrorResponse>(
                new ErrorResponse(e.getKeyName(), e.getKeyValue(), e.getMessage())
                    ,HttpStatus.BAD_REQUEST);
    }
}

自前例外(CreateResponseの引数になる)

@Data
public class BadRequestException extends Exception {

    private String keyName;
    private String keyValue;
    private String message;

    public BadRequestException(String keyName, String keyValue, String message) {
        this.keyName = keyName;
        this.keyValue = keyValue;
        this.message= message;
    }

}

例外ハンドラ

@RestControllerAdvice
public class BadRequestExceptionHandler {

    @ExceptionHandler(BadRequestException.class)
    public ResponseEntity<ErrorResponse> getException(HttpServletRequest req, BadRequestException e) {
        return ErrorResponse.createResponse(e);
    }
    
}

なお、 SpringBootの@RestControllerで例外処理をする - Qiita によると、 既に用意してあるhandlerを継承したほうが良いかもしれない。

@RestControllerAdvice
public class BazExceptionHandler extends ResponseEntityExceptionHandler {

    // 自分で定義したMyExceptionをキャッチする
    @ExceptionHandler(MyException.class)
    public ResponseEntity<?> handleMyException(MyException ex, WebRequest request) {
        return super.handleExceptionInternal(ex, "handleMyException", null, HttpStatus.BAD_REQUEST, request);
    }
...snip...

ちなみにですが、 ResponseEntity<?>ResponseEntity<Object>ResponseEntity<? extends Object>とだいたい一緒です。。 つまり何らかのクラスってことです。 細かいことは境界ワイルドカード型(bounded wildcard type)で調べるとわかるかと思います。 Springつかうとこれじゃないと返せないケースがあります。

その他リンク

https://www.baeldung.com/exception-handling-for-rest-with-spring https://www.slideshare.net/shintanimoto/spring-boot10 https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html

Windowsサービスで共有フォルダにアクセスするときのTips

f:id:fa11enprince:20161015172759j:plain サービスからネットワーク越しの共有フォルダを覗きたいことがあるが
¥¥example.machine.local¥folder
のようなUNC(Universal Naming Convention)パスはプログラムからは使えない。
なのでネットワークドライブの割り当てをしたいと思うはず。

GUI(Explorer)からネットワークドライブの割り当てをしていてもWindowsサービス側では認識されない。

サービスで起動された実行ファイルから、ネットワークドライブが使えないのは何故? | ユニリタブログ

ユーザがデスクトップにログインした状態のときにエクスプローラなどで作成したネットワークドライブを使用している場合、その接続認証はユーザと紐付いているので、そのネットワークドライブは、ユーザが対話形式のログオンをしている状態でないと使用することができません。そのため、実行ファイルを呼び出しているサービスのアカウントにAdministrators権限があったとしても、対話形式のログオンをしている状態でない限り、デスクトップにログインした状態で作成したネットワークドライブを使うことはできません。

サービスで起動された実行ファイルから、ネットワークドライブにあるファイルを利用するには? | ユニリタブログ

ユーザが対話形式のログオンをしていないときに、サービスで起動された実行ファイルからネットワークドライブ上のファイルを扱うには、エクスプローラでネットワークドライブを設定する代わりに、net use コマンドを使用してネットワークドライブを設定します。

要は、ネットワークの接続が切れたときに再びnet useをやり直さないとつながらないが、Explorer上から設定してもダメということです。

windows - Map a network drive to be used by a service - Stack Overflow

解決策1.

nssmを使ってるならばサービスにするプログラムをbatでラップしてnet useする (ただし、これは接続断したらサービス再起動しないとダメ) 通常の用途ではこれで十分と思われる。

解決策2.

サービスの中のプログラムの中でnet useを実行する

解決策3.

タスクスケジューラ等でnet useを定期的にする

if not exist X: (
    X: \\example.machine.local\share
)

解決策4.

シンボリックリンクを使う

ただ、注意点がある

ファイル共有とシンボリックリンクの利用について – Ask the Network & AD Support Team

fsutilにて設定を変更する必要がある

measurement_pcは任意の名前にしてもらってよいです

管理者権限で実行 ネットワークドライブを消して、

net use X: /delete

シンボリックリンクを作成します。

mklink /D C:¥share \\example.machine.local\share

Spring BootのScheduledTaskの検証

f:id:fa11enprince:20160613112501j:plain @EnableSchedulingを用いて
Spring BootのScheduling Tasksについて調査しました。

Spring BootにはSpring Batchという仕組みがあるのですが、やや大仰で使いにくいです。
Web系システムでの利用だと単純なスケジューリングのTaskで十分なことが多いと思います。
そんなわけで@EnableSchedulingのcronの機能について調査しました。

  1. 5秒以内で終わるTask
  2. 5秒以上かかるTask(10秒かかる)
  3. 5秒以上かかるTask(10秒かかる)でFuture利用

ソースコード

全部入りはここに置いてます。 本当はコメント化で対処したくなかったのですが、mainメソッドやCommandLineRunnerApplicationRunnerがあると同時に動いてわけがわからなくなるので、コメント化で対処してしまっています。
引数でやればいいじゃんという話もありますが、面倒でした。

ひとまずmainのクラスです

@SpringBootApplication
public class ScheduledTaskApplication implements ApplicationRunner {

    private static final Logger logger = LoggerFactory.getLogger(ScheduledTaskApplication.class);
    
    public static void main(String[] args) {
        SpringApplication.run(ScheduledTaskApplication.class, args);
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        logger.info("{} invoked.", ReflectionUtil.getClassAndMethodName());
    }

}

今回のケースでは別にApplicationRunnerは不要でmainだけでよいのですが、ログを書きたかったので、 わざわざimplementsしているのはそのためです。 Javaラーにありがちですが、importは省略しています。

5秒以内で終わるTaskのケース

ScheduledTask.java

@EnableScheduling
@Component
public class ScheduledTask {
    /**
    * 5秒ごとに実行されるTask
    * Springから自動的に呼び出される
    * ちなみにメソッドの名前は何でもよい
    */
    @Scheduled(cron = "*/5 * * * * *")
    public void run() {
        logger.info("{} invoked.", ReflectionUtil.getClassAndMethodName());
        lightTask();
    }

    /**
    * 軽いTask
    */
    public void lightTask() {
        logger.info("{} start.", ReflectionUtil.getClassAndMethodName());
        logger.info("light task processing ...");
        // TODO something...
        logger.info("{} end.", ReflectionUtil.getClassAndMethodName());
    }
}

これで動かしてみます
実行すると当たり前ですが…

2019-02-10 04:47:08.745[main][INFO ][c.e.s.ScheduledTaskApplication          ] :ScheduledTaskApplication#run invoked.
2019-02-10 04:47:10.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 04:47:10.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#lightTask start.
2019-02-10 04:47:10.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :light task processing ...
2019-02-10 04:47:10.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#lightTask end.
2019-02-10 04:47:15.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 04:47:15.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#lightTask start.
2019-02-10 04:47:15.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :light task processing ...
2019-02-10 04:47:15.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#lightTask end.

こんな感じになります。

5秒以上かかるTask(10秒かかる場合)のケース

ScheduledTask.java

@EnableScheduling
@Component
public class ScheduledTask {
    /**
    * 5秒ごとに実行されるTask
    * Springから自動的に呼び出される
    * ちなみにメソッドの名前は何でもよい
    */
    @Scheduled(cron = "*/5 * * * * *")
    public void run() {
        logger.info("{} invoked.", ReflectionUtil.getClassAndMethodName());
        heavyTask();
    }

    /**
    * 重いTask
    */
    public void heavyTask() {
        logger.info("{} start.", ReflectionUtil.getClassAndMethodName());
        try {
            logger.info("heavy task processing ...");
            TimeUnit.SECONDS.sleep(10);
            // TODO something...
        } catch (InterruptedException e) {
            logger.error(e.getMessage(), e);
        }
        logger.info("{} end.", ReflectionUtil.getClassAndMethodName());
    }
}
2019-02-10 04:50:26.033[main][INFO ][c.e.s.ScheduledTaskApplication          ] :ScheduledTaskApplication#run invoked.
2019-02-10 04:50:30.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 04:50:30.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTask start.
2019-02-10 04:50:30.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :heavy task processing ...
2019-02-10 04:50:40.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTask end.
2019-02-10 04:50:45.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 04:50:45.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTask start.
2019-02-10 04:50:45.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :heavy task processing ...
2019-02-10 04:50:55.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTask end.
2019-02-10 04:51:00.006[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 04:51:00.006[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTask start.
2019-02-10 04:51:00.006[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :heavy task processing ...
2019-02-10 04:51:10.007[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTask end.

となり、ログをよく見ると、実行を待ち合わせてくれます。 本処理が10秒かかる場合、次回の起動は終わるまで待ってくれます。

5秒以上かかるTask(10秒かかる場合)でFuture利用のケース

ということで、別スレッドで本処理を起動してみました。
まぁ、別スレッドということなんで、処理は当然、待ち合わせなくなるので、
Springが決まった時間に起動をかける当たり前の挙動になります。
とはいえ、スレッドの多重起動が起きないように終わるまで次回の処理を起動しないように制御はしています。
こちらのメリットはプログラム自体が異常停止しているかそうでないか、
ログを見ると一目瞭然ということでしょうか
ちなみにFutureTaskは別クラスにする必要は特にないんですが、GitHubにあげるソースコードで見にくくなるので、
とりあえず分けてます。
ScheduledTask.java

@EnableScheduling
@Component
public class ScheduledTask {

    private static final Logger logger = LoggerFactory.getLogger(ScheduledTask.class);
    private ExecutorService executorService = Executors.newSingleThreadExecutor();
    private Future<?> future = null;
    @Autowired
    FutureTask futureTask;

    /**
    * 重いTask (Future利用)
    */
    public void heavyTaskWithFuture() {
        logger.info("{} start.", ReflectionUtil.getClassAndMethodName());
        if (future == null || future.isDone()) {
            future = executorService.submit(futureTask);  // 別スレッドで実行
        }
        logger.info("{} end.", ReflectionUtil.getClassAndMethodName());
    }
}

別スレッドで呼び出されるTask FutureTask.java

@Component
public class FutureTask implements Callable<Boolean> {
    private static final Logger logger = LoggerFactory.getLogger(ScheduledTask.class);

    @Override
    public Boolean call() throws Exception {
        logger.info("{} start.", ReflectionUtil.getClassAndMethodName());
        try {
            logger.info("future heavy task processing ...");
            TimeUnit.SECONDS.sleep(10);
            // TODO something...
        } catch (InterruptedException e) {
            logger.error(e.getMessage(), e);
            return false;
        }
        logger.info("{} end.", ReflectionUtil.getClassAndMethodName());
        return true;
    }
}

これを実行すると…、

2019-02-10 05:00:24.189[main][INFO ][c.e.s.ScheduledTaskApplication          ] :ScheduledTaskApplication#run invoked.
2019-02-10 05:00:25.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 05:00:25.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture start.
2019-02-10 05:00:25.003[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture end.
2019-02-10 05:00:25.003[pool-1-thread-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :FutureTask#call start.
2019-02-10 05:00:25.003[pool-1-thread-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :future heavy task processing ...
2019-02-10 05:00:30.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 05:00:30.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture start.
2019-02-10 05:00:30.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture end.
2019-02-10 05:00:35.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 05:00:35.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture start.
2019-02-10 05:00:35.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture end.
2019-02-10 05:00:35.003[pool-1-thread-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :FutureTask#call end.
2019-02-10 05:00:40.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 05:00:40.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture start.
2019-02-10 05:00:40.001[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture end.
2019-02-10 05:00:40.002[pool-1-thread-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :FutureTask#call start.
2019-02-10 05:00:40.002[pool-1-thread-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :future heavy task processing ...
2019-02-10 05:00:45.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#run invoked.
2019-02-10 05:00:45.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture start.
2019-02-10 05:00:45.002[scheduling-1][INFO ][c.e.scheduledtask.tasks.ScheduledTask   ] :ScheduledTask#heavyTaskWithFuture end.

ちょっとわかりにくいですが、必ず5分に1回呼び出されています。

とりあえず、自分でお手軽バッチを作るなら3のパターンかなと思った次第です。
にしても、Javaのマルチスレッドプログラミングはマジで便利…。 C++だとめんどくさすぎる。
ちなみにRx系は複雑度が高すぎな気がしていまいち好きになれない…。

補足

Spring Bootですが1プロジェクトに別にいくつもエントリーポイントがあってもよい。 コマンドラインからの起動の時は同時に動きます。

番外編

そもそも例えば10秒に1回スケジュールしたいときに、
cron = "*/10 * * * * *""0/10 * * * * *"と書いたときに一緒な気がするけど、
本当に一緒か?と思い調べてみました。
公式は前者で一応書いているような気がする…
CronSequenceGenerator (Spring Framework 5.2.7.RELEASE API)   ということで…

検証コード

cronの文字列をパースしているのはCronSequenceGeneratorで、 next()を呼び出すと次の時刻を返してくれるっぽい。

import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;

import org.springframework.scheduling.support.CronSequenceGenerator;

public class App {
    public static void main(String[] args) {
        System.out.println("\"*/10 * * * * *\"\n"
                + "\"0/10 * * * * *\"は同じなのかテスト");
        {
            Date date1 = stringToDate("2019-08-14 10:00:00");
            System.out.println(date1);
            Date d1 = test1(date1);
            System.out.println(d1);
            Date d2 = test2(date1);
            System.out.println(d2);
            System.out.println(d1.equals(d2));
        }
        System.out.println("-------------------");
        {
            Date date1 = stringToDate("2019-08-14 10:00:05");
            System.out.println(date1);
            Date d1 = test1(date1);
            System.out.println(d1);
            Date d2 = test2(date1);
            System.out.println(d2);
            System.out.println(d1.equals(d2));
        }
        System.out.println("-------------------");
        {
            Date date1 = stringToDate("2019-08-14 10:00:09");
            System.out.println(date1);
            Date d1 = test1(date1);
            System.out.println(d1);
            Date d2 = test2(date1);
            System.out.println(d2);
            System.out.println(d1.equals(d2));
        }
        System.out.println("-------------------");
        {
            Date date1 = stringToDate("2019-08-14 10:00:10");
            System.out.println(date1);
            Date d1 = test1(date1);
            System.out.println(d1);
            Date d2 = test2(date1);
            System.out.println(d2);
            System.out.println(d1.equals(d2));
        }
        System.out.println("-------------------");
        {
            Date date1 = stringToDate("2019-08-14 10:00:11");
            System.out.println(date1);
            Date d1 = test1(date1);
            System.out.println(d1);
            Date d2 = test2(date1);
            System.out.println(d2);
            System.out.println(d1.equals(d2));
        }
        System.out.println("-------------------");
        {
            Date date1 = stringToDate("2019-08-14 10:00:41");
            System.out.println(date1);
            Date d1 = test1(date1);
            System.out.println(d1);
            Date d2 = test2(date1);
            System.out.println(d2);
            System.out.println(d1.equals(d2));
        }
        System.out.println("両者は同じなので\"*/10 * * * * *\"を使ったほうがよさそう");
    }
    
    private static Date test1(Date date) {
        final String CronSched = "*/10 * * * * *";
        CronSequenceGenerator csg = new CronSequenceGenerator(CronSched);
        return csg.next(date);
    }
    
    private static Date test2(Date date) {
        final String CronSched = "0/10 * * * * *";
        CronSequenceGenerator csg = new CronSequenceGenerator(CronSched);
        return csg.next(date);
    }
    
    private static Date stringToDate(final String dateStr) {
        LocalDateTime ldt = LocalDateTime.parse(dateStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
        return Date.from(ZonedDateTime.of(ldt, ZoneId.systemDefault()).toInstant());
    }
}

結果一緒でした。前者で書くほうが良さそう。ちなみに普通のcrontabはどうなのかは知りません…。

参考リンク

Spring & Spring Boot

Getting Started | Scheduling Tasks
Integration

ApplicationRunnerとCommandLineRunnerについて

Spring Boot: ApplicationRunner and CommandLineRunner - DZone Java
Spring Bootでコマンドラインアプリを作る時の注意点 - Qiita

Future関連

言語によってちょっと違うFuture/Promiseをまとめてみた(1) - Qiita

Spring BootのThymeleafとTypeScriptを組み合わせてみたかったのでやってみた

Web開発では最近はReact, Vue, Angularを使ってSPAが主流ですが、
とはいっても、レガシーなjQuery UIとBootstrapと組み合わせてサーバ側でレンダリングしたいこともままありますよね。 そんなわけで、僕の場合、特にjQuery UIを使いたい。
というのが一番にあって、かつTypeScriptを使いたい。
しかしながらフロントエンドフレームワーク使うまでもない。
といったところでWebpackと組み合わせて使ってみよう!ということで作りました。 サーバサイドはSpring Boot2です。別にこれじゃなくてもよいのだけれど、
最近一番慣れているフレームワークがコレなのです。
最近PHP未経験なので使いたい病にかかってるのでそっちでやればよかったかもしれない。

おおまかなプロジェクト構成はこんな感じです。 Gulpを使ったほうが実はシンプルになるのかとも考えましたが、 最近Gulp使わないっすよね。Angularでは使われてるっぽいですが。 直近までAngularを使っていたせいかAngularチックな構成になってしまい。
Angularでいいんじゃ…状態

下記をすべて置いたものをGitHubにあげています。

大まかなプロジェクト構成

src/
  main/
     java/
     client/
        ts/
           commons/
           controllers/
           models/
           services/
           pages/index.js (エントリーポイント2つ目)
           index.js (エントリーポイント1つ目)
     resources/
        public/
        static/
        resoruces/
          template/
             pages/index.html (エントリーポイント2つ目を読み込む)
             index.html(エントリーポイント1つ目を読み込む)

webpack.config.jsですが、開発用と本番用を分けたかったので、分けてます。
webpackに詳しくない人はここをやっておくとよいと思います。
Getting Started | webpack
ただ、後で少し解説しています。

webpack.common.js

const path = require('path');
const CleanWebpackPlugin = require('clean-webpack-plugin');
module.exports = {
    entry: {
        'index': path.resolve(__dirname, 'ts/index.ts'),
        'pages/page2': path.resolve(__dirname, 'ts/pages/page2.ts'),
        // ページが増えたらここに追記
    },
    output: {
        path: path.resolve(__dirname, '../resources/static'),
        filename: '[name].js',
        publicPath: '/' // webpack-dev-server等が使うディレクトリ この場合、../resources/staticが/となる
    },
    module: {
        rules: [
            // ts -> ES2015 -> babel -> ES5
            {
                test: /\.ts$/,
                exclude: /node_modules/,
                loader: ['babel-loader'],
            },
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: ["source-map-loader"],
                enforce: "pre",
            },
            {
                test: /\.css$/,
                use: [
                    'style-loader',
                    'css-loader',
                ],
            },
            {
                test: /\.(png|svg|jpg|gif)$/,
                use: [{
                    loader: 'file-loader',
                    options: {
                        name: '[name].[ext]',
                        outputPath: 'assets/images/',
                    },
                }, ],
            },
            {
                test: /\.(woff|woff2|eot|ttf|otf)$/,
                use: [
                    'file-loader'
                ]
            }
        ]
    },
    resolve: {
        extensions: [
            '.ts', '.js'
        ],
    },
    plugins: [
        new CleanWebpackPlugin('static', {
            root: path.resolve(__dirname, '../resources/static'),
            verbose: true,
            dry: false,
        }),
    ],
}

わかる人にはここだけでふーんだと思いますが、これだけだと意味不明な人も多いはずなので説明します。

説明

代表的な依存モジュール

  • date-fns
  • jQuery
  • jQuery UI
  • popper.js
  • jQuery cookie
  • Bootstrap

開発支援系

  • Babel
  • TypeScript
  • clean-webpack-plugin
  • css-loader
  • style-loader
  • file-loader
  • source-map-loader
  • url-loader
  • webpack
  • webpack-bundle-analyzer
  • webpack-merge

本来は必要がないもの

  • html-loader
  • html-webpack-plugin
  • webpack-dev-server

なおBabelを入れているのはIE対応等でpolyfillするため
polyfillしないとIEでPromiseやES6のArray.prototype系のメソッドが使えない
momentでなくてdate-fnsにしているのはバンドルサイズの肥大化を避けるためである

webpack-dev-serverはSpring BootのようなWeb側がなくても
独立して開発できるようにしようとしたものだが、
SPAを前提としていないこともあり、画像/CSSのパス等の調整に難があり、
それを妥協してもいいなら一応使えるようにはなっている。

開発方法

従来型のMPAで作成する。 エントリーポイントはSpring Bootのディレクトリ構成と対にし、index.tsを作成する
ここで必要なjQuery, Bootstrap等を読み込み、controllerを読み込む。
その際、webpack.common.jsにentryを追加する

なお、importするモジュールは次のように分けられたディレクトリのいずれかに配置する

- commons
  共通系のclass, functionを置く
- controllers
  Spring Bootで例えるならController相当のclassを置く(.ts)
- models
  Spring Bootで例えるならのEntity相当のclassを置く(.ts)
- services
  APIアクセスを記述する  
  $.Deferredを返すようにする(ほぼPromiseベース)

なお、index.js自体にはonclick, $(document).ready()相当の処理しか書かず、
あとはcontrollerに任せる ※AngularとかMVVM系のライブラリの影響を当時受けてこんなめんどくさい分け方にしてたようです。

その他

npm run build-dev, npm run watchしたときにeclipse側で変更が読み取れないので、
面倒だが、都度static配下をrefreshする → ※eclipseの Preferences > General > Workspace > Reflesh using native hooks or polling をすれば即時反映されそうです。

TypeScriptについて

tsconfig.jsonについて
targetはトランスパイルした結果のバージョンのこと。
ECMAScriptのバージョンでES2015(ES6)にしている。
ES5にまで一気に落とせるのだが、これはなぜかというと、あとでBabelでpolyfillするためである。 moduleはコード生成モジュールのことでimportの挙動に影響を与える。
ひとまずES2015とする。

その他についてはここを見るとよいかもしれません。

Webpack関連

基礎知識編

Webpackはビルドツールであるが、gulpと違い、コードでタスクを書くようなことはできない。 基本はentryでエントリーポイントを決め、
outputで出力先を決める MPAの構成にしたい場合は、gulpでないとできなそうだが、webpackで可能である。 entryを複数にしてoutputfilenameにて[name].jsとすると
entrykeyの値(=PATH)がそのまま出力先になる(ディレクトリ階層のあるPATHであればそれもそのまま作成される)
なお、outputのPublicPathはwebpack-dev-serverなどを使ったときに、 サーバの公開先のrootのパスになる。

__dirnameについて
これはnodeの変数で、現在コマンドを動かしているディレクトリになる。
絶対パスで指定しないとダメな場合に活用できる。

babel-loaderについて

JavaScriptをトランスパイルするもの。 babel-loaderの7からTypeScriptもトランスパイル可能。

source-map-loaderについて

source-mapを作成するもの。 これはデバッグ実行したときにトランスパイル済みコードではデバッグが厳しいので、トランスパイル前のコードを表示してくれるもの

CSS関連

style-loaderはスタイルシートをJSからlinkタグに展開してhtml内に注入してくれるもの css-loaderはCSSをバンドルするための機能

画像関連

file-loaderはビルド・バンドルしたものが参照している画像等を適切な場所に配置してくれるもの。 フロントだけの開発の場合はPATHがなかなか合わず、dev-serverと組み合わせないとうまくいかない。 url-loaderというのもあり、こちらは画像/CSSをbase64にして埋め込んでくれて、
これによりURLやPATHを気にしなくてよくなるが、ファイルサイズが肥大化するというデメリットがある。

resolveについて

resolveで指定した拡張子はimport時に拡張子が不要となる。
通常はjstsだけで良いと思われる。
Reactな人はjsxとかもあったほうが良い。
もちろんcssとかも指定可能。

あとがき

以上こんな感じで作ってみました。 てゆうか既にそんなもの、OSSで転がってるよ。っていうのがあるかもしれません。
ただ、MPA前提のはあまりみかけない。
そしてあえてレガシーなjQuery UIを使う…というのも新規で作る場合に選択肢には上がりにくいし。 ただ、便利な部品が多いんですよね。
SPAにするといろいろ問題が起こるんですよね。特に要員的な問題で…。 書いてみて思ったのですがやはりまだまだ知識不足…。

参考リンク

最新版で学ぶwebpack 5入門 - Bootstrapをバンドルする方法 - ICS MEDIA
webpack~Bootstrap4移行ガイド
webpack-dev-serverを使ってみる - のぐそんブログ
webpack-dev-serverの基本的な使い方とポイント - dackdive's blog
webpackでhtmlファイルも出力する - emahiro/b.log
npmとwebpack4でビルド - jQueryからの次のステップ - Qiita
http://js.studio-kingdom.com/typescript/tutorials/react_and_webpack
TypeScript With Babel: A Beautiful Marriage
TypeScriptでasync/await (webpackビルド) - Qiita
フロントエンド知らない私のwebpack入門 その1 - Qiita
webpackでCSSやSASSを使う - 30歳からのプログラミング
【意訳】Webpackの混乱ポイント - Qiita
今時のフロントエンド開発2017 (3. webpack編) - Qiita

MS公式のTypeScript + Babelスターター

GitHub - microsoft/TypeScript-Babel-Starter: A sample setup using Babel CLI to build TypeScript code, and using TypeScript for type-checking.

Bootstrapのサンプル

https://glacial-webwork.com/2018/05/31/bootstrap4_beginer/