Posts for: #Spring Boot

url エンコーディングと uri の仕様

1時に寝ようとして、寝てたか起きてたかわからない時間を過ごして7時に起きた。

WebClient と query string のエンコーディング

以前にも WebClient の基本 について少し書いた。data={"x": 1, "y": 2} のような json 文字列を query string でリクエストしようとしたときに少しはまったので書いておく。java 標準ライブラリの URLEncoder を使ってエンコードするとスペースが + になる。これは html の仕様として正しいが、uri の仕様としては不正になる。そのため +%20 に置き換える必要がある。

private String encode(String data) throws UnsupportedEncodingException {
    // NOTE: the URI doesn't allow '+' character
    return URLEncoder.encode(data, StandardCharsets.UTF_8).replace("+", "%20");
}

このロジックで {"x": 1, "y": 2} を url エンコードすると次の文字列になる。

%7B%22x%22%3A%201%2C%20%22y%22%3A%202%7D

あらかじめ url エンコーディングした文字列を渡すと、今度は WebClient が %%25 にさらにエンコーディングしてしまう。Spring WebClient Requests with Parameters 6.Encoding Mode によると、次の4つのエンコーディングモードをカスタマイズできる。デフォルトは TEMPLATE_AND_VALUES らしい。

  • TEMPLATE_AND_VALUES: Pre-encode the URI template and strictly encode URI variables when expanded
  • VALUES_ONLY: Do not encode the URI template, but strictly encode URI variables after expanding them into the template
  • URI_COMPONENTS: Encode URI component value after expending URI variables
  • NONE: No encoding will be applied

もとの url エンコード済みの文字列が次のようなものになってしまう。

%257B%2522x%2522%253A%25...

既存の実装をあまり変えたくもなくてやや力技で実装した。局所的な変更だからまぁいっか。

webClient.get().uri(uriBuilder -> {
  var uriObj = uriBuilder.path(getControllerBasePath() + path).queryParams(query).build(pathParams);
  if (encodedData != null) {
      var uri = uriObj.toString();
      var connector = uri.contains("?") ? "&" : "?";
      uriObj = URI.create(String.format("%s%sdata=%s", uri, connector, encodedData));
  }
  return uriObj;
}).retrieve()

よいエラーメッセージ、わるいエラーメッセージ

タイトルに惹かれてちょっと期待外れ。art というと日本人は芸術と高度なものを期待しがちだけど、the art of だと技術の体系といった意味合いもあるのでちょとしたノウハウを解説する技術ブログのようなものでも誤っていない。

ユーザー体験をよくするためのエラーメッセージのコツとして次の3つを提案している。

  • 何が起きたのか、なぜ起きたのかを説明する
  • 次のステップを提案する
  • 適切なトーンで書く

この3つの具体例としてどのようなものかを説明している。私にとってはそう目新しいものではないが、エラーメッセージにトーンという概念はなかったので最近の流行りなのかなと思った。

spring boot におけるプロキシ実装

0時に寝て7時に起きた。

spring boot における簡易的なミドルウェアの実装

bff (backend for frontend) の web api サーバーから別の web api サーバーのエンドポイントを呼び出す処理を書いていてただプロキシする処理のためだけに controller を書くのも面倒だなと気付いた。controller よりも低いレイヤで raw request を扱うにはどうしたらいいかを調べてみたら spring の Filter を使うのが最も簡単そうにみえた。その spring の Filter の1つである OncePerRequestFilter を使って簡易的なプロキシの処理を実装してみた。OncePerRequestFilter はリクエストに対して1度だけ呼ばれることが保証された Filter になる。これらのドキュメントがどこにあるかわからなかったので baeldung のチュートリアル What Is OncePerRequestFilter? をみた方が早いかもしれない。

Filter が複数あるときは実行順序を指定したいときは Order というアノテーションで制御できる。

@Component
@Order(30)
public class MyFilter extends OncePerRequestFilter {
  ...
}

OncePerRequestFilter の doFilterInternal メソッドをオーバーライドすれば任意のミドルウェアっぽい処理を実装できる。

    @Override
    protected void doFilterInternal(
        HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
        // ここに任意の前処理を実装する
        filterChain.doFilter(request, response);
        // ここに任意の後処理を実装する
    }

あとは HttpServletRequest と HttpServletResponse を直接操作してリクエストから必要な情報を取り出して、クライアントでリクエストして返ってきたレスポンスをそのまま返してあげればよい。簡単に実装したものが次になる。クライアントには 以前に少し調べたことを書いた WebClient を使っている。WebClient じゃなければ、もう少しシンプルに書ける気もする。

@Component
@Order(30)
public class RequestForwardFilter extends OncePerRequestFilter {
    @Autowired
    private List<RequestForwardMatcher> matchers;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        var matcher = this.getMatcher(request);
        if (matcher.isEmpty()) {
            filterChain.doFilter(request, response);
            return;
        }
        writeResponse(response, this.forwardRequest(request, matcher.get().getWebClient()));
    }

    private Optional<byte[]> forwardRequest(HttpServletRequest request, WebClient webClient) throws IOException {
        var method = HttpMethod.resolve(request.getMethod().toUpperCase());
        var originalUri = request.getRequestURI();
        var uri = this.getUri(originalUri, request.getQueryString());
        var spec = this.getSpec(method, webClient, uri, request.getReader());
        return spec.retrieve().bodyToMono(byte[].class).blockOptional();
    }

    private void writeResponse(HttpServletResponse response, Optional<byte[]> bytes) throws IOException {
        if (bytes.isEmpty()) {
            return;
        }
        var stream = response.getOutputStream();
        stream.write(bytes.get());
        stream.flush();
        stream.close();
    }

    private byte[] getRequestBody(BufferedReader reader) {
        return reader.lines().collect(Collectors.joining()).getBytes();
    }

    private String getUri(String originalUri, String queryString) {
        var path = originalUri.replace("/api", "");
        if (StringUtils.hasLength(queryString)) {
            return String.format("%s?%s", path, queryString);
        }
        return path;
    }

    private WebClient.RequestHeadersSpec<?> getSpec(
            HttpMethod method, WebClient webClient, String uri, BufferedReader reader) {
        switch (method) {
            case GET:
                return webClient.get().uri(uri);
            case POST:
                return webClient.post()
                        .uri(uri)
                        .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                        .bodyValue(this.getRequestBody(reader));
            case PUT:
                return webClient.put()
                        .uri(uri)
                        .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                        .bodyValue(this.getRequestBody(reader));
            case DELETE:
                return webClient.delete().uri(uri);
            default:
                throw new RuntimeException(String.format("Unsupported HTTP method: %s", method));
        }
    }

    private Optional<RequestForwardMatcher> getMatcher(HttpServletRequest request) {
        for (var matcher : this.matchers) {
            if (matcher.match(request)) {
                return Optional.of(matcher);
            }
        }
        return Optional.empty();
    }
}

matcher のサンプル実装。

public class RequestForwardMatcher {
    private static final String ASTERISK = "*";

    private final Pattern pattern;
    private final List<String> methods;
    private final WebClient webClient;

    public RequestForwardMatcher(String pattern, List<String> methods, WebClient webClient) {
        this.pattern = Pattern.compile(pattern);
        this.methods = methods;
        this.webClient = webClient;
    }

    private boolean matchMethod(String method) {
        if (this.methods.contains(ASTERISK)) {
            return true;
        }
        return this.methods.contains(method);
    }

    public boolean match(HttpServletRequest request) {
        if (!matchMethod(request.getMethod())) {
            return false;
        }
        return this.pattern.matcher(request.getRequestURI()).matches();
    }

    public WebClient getWebClient() {
        return this.webClient;
    }
}

リアクティブプログラミングと WebClient

0時に寝て6時に起きた。金曜日は非稼働日だけど、今週は月曜日が祝日だったから普通に働いてた。

spring-webflux とプロキシ

たまたま api client 周りを触っている。それらは spring の WebClient で実装されている。WebClient は spring-webflux プロジェクトが提供している http リクエストを扱うためのクライアントでリアクティブプログラミングを用いた設計になっている。リアクティブという言葉がピンとこなければ非同期フレームワークを用いた http クライアントと言い換えても大枠ではあっているのではないかと思う。spring-webflux プロジェクトそのものはノンブロッキング、バックプレッシャーといった機能をサポートする web アプリケーションフレームワークを提供するもの。

Reactor というコアライブラリを使って spring-webflux のフレームワークは実装されている。このデータ構造の1つに MonoFlux が出てくる。初見の開発者はこの名前のデータ構造がよくわからんというところから始まる。私がそうだった。ドキュメントの説明によると、Mono は0から1、Flux は0からNまでのデータ列の概念を扱うという。おそらく json のようなレスポンスを返す場合は Mono を使い、ストリームを返すレスポンスは Flux を使えばいいんじゃないかと思う。

Reactor is the reactive library of choice for Spring WebFlux. It provides the Mono and Flux API types to work on data sequences of 0..1 (Mono) and 0..N (Flux) through a rich set of operators aligned with the ReactiveX vocabulary of operators. Reactor is a Reactive Streams library and, therefore, all of its operators support non-blocking back pressure. Reactor has a strong focus on server-side Java. It is developed in close collaboration with Spring.

WebFlux requires Reactor as a core dependency but it is interoperable with other reactive libraries via Reactive Streams. As a general rule, a WebFlux API accepts a plain Publisher as input, adapts it to a Reactor type internally, uses that, and returns either a Flux or a Mono as output. So, you can pass any Publisher as input and you can apply operations on the output, but you need to adapt the output for use with another reactive library. Whenever feasible (for example, annotated controllers), WebFlux adapts transparently to the use of RxJava or another reactive library. See Reactive Libraries for more details.

https://docs.spring.io/spring-framework/docs/current/reference/html/web-reactive.html#webflux-reactive-api

いまローカルの開発環境では vpn 接続をしてプロキシ経由でアクセスするサーバーがいる。WebClient のプロキシ経由で通信できないという問題があることを同僚から教えてもらった。プロキシ経由でアクセスしようとすると認可エラーになってしまう。なにかしらプロキシ経由の接続に問題がある。

Caused by: io.netty.handler.proxy.HttpProxyHandler$HttpProxyConnectException: http, none, /10.100.101.10:8080 => /192.168.201.35:18980, status: 403 Forbidden

同僚は WebClient のデフォルトでは http の CONNECT メソッドを使って通信しようとするが、それを squid がサポートしていないか、設定を変更しないとダメなんじゃないかと話していた。その内容が正しいかどうか、私は未検証だけどデフォルト設定では通信できないことがわかった。ここで WebClient の設定の1つに ClientHttpConnector があり、任意の http client ライブラリに置き換えられる。ソースをみると次の4つの ClientHttpConnector が使えるらしい。デフォルトが ReactorClientHttpConnector になる。

  private ClientHttpConnector initConnector() {
    if (reactorClientPresent) {
      return new ReactorClientHttpConnector();
    }
    else if (jettyClientPresent) {
      return new JettyClientHttpConnector();
    }
    else if (httpComponentsClientPresent) {
      return new HttpComponentsClientHttpConnector();
    }
    else {
      return new JdkClientHttpConnector();
    }
  }

試しに jetty-reactive-httpclient を使って JettyClientHttpConnector に置き換えてみたところ、プロキシサーバー経由のアクセスができるようになった。

public WebClient create(String proxyIp, int proxyPort) {
    var httpClient = new HttpClient();
    httpClient.setFollowRedirects(true);
    httpClient.getProxyConfiguration().getProxies().add(new HttpProxy(proxyIp, proxyPort));
    var connector = new JettyClientHttpConnector(httpClient);
    return WebClient.builder().clientConnector(connector).build();
}

データベースを介したテストではまった話し

1時に寝て7時に起きた。帰りにふらっと仲のよい焼き鳥屋さんに寄ったらちょっとしたハプニングがあって長居してしまった。他に来ていたお客さんのカップルが別れ話を始め、こじれてややこしい状況になって、この騒動が一段落しないと席を立てない空気になってしまって終わるのを待ってた。マスターの知り合いらしくて、そのお客さんが帰ってから当事者たちの背景を聞いたりしてた。人生いろいろあるよなぁ。

spring の Transactional アノテーション

spring フレームワークには Transactional というアノテーションがある。SpringBootTest を使ったテストのときに使うと、テストメソッドの終了時に自動的にデータベースへの書き込みがロールバックされて便利なことを テストコードのリファクタリング をしていたときに気付いた。

スレッドプールを使ってマルチスレッドで並行実行する処理を書いてそのテストを書いてみたら意図した結果にならない。なんでだろう?と2時間ほどはまってデバッグしていた。テストデータの書き込みが、実際にはデータベースにコミットされていないので、テストを実行しているスレッド以外のワーカースレッドからデータベースにアクセスしてもテストデータを参照できないからだと気付いた。データベースのトランザクションに細工すると、こういうはまりどころがあるなぁと気付いて Transactional を使わずに普通にテストを書いた。その分、自分でテストメソッドが呼ばれてコミットされたテストデータを削除する必要がある。調べていたときに他にも副作用がいろいろあるよという記事もみつけた。

お花見の場所探し

0時に寝て5時過ぎに起きた。

spring framework の脆弱性対応

起きてタイムライン眺めてたら spring framework の脆弱性の公式アナウンスが出ていたのですぐに準備してオフィス行って7時前から脆弱性対応の作業をしてた。

大学の研究室にいた頃、root staff と呼ばれるシステム管理者をやっていた。研究室のネットワークをすべて freebsd で自分たちで構築していたので os の脆弱性が公表されると研究室のすべての os のパッチ適用をやっていた。具体的にはパッチの当たった kernel をビルドして再起動するといった作業。

それを2年間やっていたせいか、脆弱性情報が公開されるとすぐに対応する癖みたいなものがついた。7時前から作業して検証して7時11分に PR を作成した。レビューアは誰も作業を始めてないけど。金曜日は非稼働日なので私が作業しなくてもよいのだけど、ここまでやったら安心して他の作業に移ることができた。

生田川公園の桜

地元のコミュニティでオミクロン株の感染が落ち着いてきたのでリアルお花見をしたいねという話題があがっている。私自身、お花見に毎年参加するような人間でもないけれど、たしかにコロナ禍になってからはお花見やってないだろうし、個人的にも数年はお花見やってないからやってもいいかなという気持ちにはなった。近場だと 生田川公園 という場所があり、特筆するほど桜がとてもきれいという場所ではないが、一応は桜があって、お花見するスペースもあって、形としては成り立つようなところ。お仕事を終えてから自転車で開花状況を見に行った。19時頃に行って寒くても何組かはお花見している集団はいた。開花状況は7-8割といったところかな。今週末から来週にかけてぐらいが見頃だと思う。

spring boot の環境とログ設定

0時に寝て4時に起きて6時に起きた。

spring のプロファイル設定

spring の Profiles の仕組みを使って環境ごとの設定を作る。デプロイは k8s で管理しているため、spring boot の Externalized Configuration の仕組みを使って、環境変数から application.yml に定義された設定を書き換える。k8s は kustomize で管理していて prod, test, dev の3つの環境で任意の設定を記述できる。

問題はログ出力の設定を環境ごとに変えたい。具体的には datadog に連携されるログは構造化ログ (json lines) を、ローカルの開発ではコンソールログをみたい。Log4j Spring Boot Support によると、1つの設定ファイルに複数のプロファイル設定を記述できるようにもみえるけど、実際にやってみたらうまく動かなかった。xml ではなく yml を使っているせいかもしれないし、私の記述方法が誤っているのかもしれない。いずれにしても yml で複数のプロファイルを設定しているサンプルをみつけられなかった。

そこで Different Log4j2 Configurations per Spring Profile をみて、環境ごとにログ設定ファイルも分割することにした。application.yml には次のように記述する。

spring:
  profiles:
    active: dev

logging:
  config: classpath:log4j2-${spring.profiles.active}.yml

ローカル開発向けの lgo4j2-dev.yml は次のようになる。

Configuration:
  status: warn
  name: YAMLConfig
  appenders:
    Console:
      name: STDOUT
      target: SYSTEM_OUT
      PatternLayout:
        Pattern: "%d{yyyy-MM-dd HH:mm:ss.SSS}[%t]%-5level %logger{36} - %msg%n"

k8s のマニフェストで環境変数を次のように定義すれば prod というプロファイルが設定される。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-service
spec:
  template:
    spec:
      containers:
      - name: my-service
        env:
        - name: spring.profiles.active
          value: "prod"

クラウド環境向けの log4j2-prod.yml は次のようになる。

Configuration:
  status: warn
  name: YAMLConfig
  appenders:
    Console:
      name: STDOUT
      target: SYSTEM_OUT
      EcsLayout:
        serviceName: my-service
        serviceNodeName: null
        includeMarkers: true
        KeyValuePair:
        - key: type
          value: app

テストコードのリファクタリング

0時に寝て6時に起きた。今日は7時半から23時過ぎまで集中してコードを書いてた。最近は19-20時には帰って、晩ご飯食べて、ドラクエタクトやったり漫画読んだりだらだらしている。そんな暇あったら積ん読の本読めって感じだ。

テストコードのリファクタリング

業務機能の開発をするにあたって、既存のテストコードをみていて、@BeforeEach というテストメソッド単位に呼ばれるメソッドでテストデータの削除と postgresql の sequence のリセット処理をしていた。こんなの共通処理ですべてのテーブルの truncate と sequence のリセット処理をすればいいやんとか思って、いろいろ調べて2つのリファクタリングの PR を作成した。先日 JUnit5 の拡張 を調べたばかりだから、テストの共通化のノウハウが溜まっている。Testcontainers Postgres Module と連携して、postgresql コンテナに接続して sequence のリセット処理を汎用のテスト拡張として実装した。テストを実装する開発者は、次のように @ExtendWith(DatabaseInitializer.class) をアノテーションに付与すれば、自分で sequence のリセット処理を @BeforeEach のメソッドに実装する必要がなくなる。

@SpringBootTest
@Transactional
@ExtendWith(SetupDatabaseContainer.class)
@ExtendWith(DatabaseInitializer.class)
class MyTest {
    ...
}

この作業の過程で spring boot の @Transactional はデフォルトでテストメソッドの実行後にロールバックする機能が提供されていて、いままで @BeforeEach のメソッドで明示的にテーブルのデータを削除する必要はなかったんやと気付いた。じゃあ、なぜ削除するコードを書いてたかと言うと、テストの外部で初期データを作成する仕組みがあるから、初期データを削除する目的でそうしていたことが判明した。そして、一部のコードはそこで作った外部の初期データに依存して実装されていた。テストコードの一部が外部のデータに依存しつつ、テストメソッドでは外部のデータに依存しないように削除のコードが書いてある。書いていて何を言っているのかわからないと思うけど、私も調べてて訳がわからんくて、PR に「いまの状況はかなりややこしい」と前置きしつつ、無駄なコードや仕組みを取り除くための修正を行った。本当は機能開発やらないといけないのにテストコードのリファクタリングするのに大きな時間をかけるわけにはいかないだろうという意図で、半日掛けてリファクタリングして23時過ぎまで作業して、既存のテストコードも含めて全部直した。このリファクタリングで数十のテストケースの約300行ぐらいの初期化コードをなくせた。

spring boot の xml 変換の仕組み

0時に寝て吐き気がして3時に起きて、断続的に仮眠をとってみたけど、それでも気分悪くて5時から起きてた。昨日の晩ご飯食べて寝てから吐き気が出てきた。なにかの食べ合わせなのだろうか。コロッケとその後にチョコレート食べたのが悪かったのか。普通にオフィスへ行ってお仕事してたら直った。

spring boot の xml 変換

いまお手伝いしているお仕事で spring boot で SOAP の xml 通信しているサービスがある。任意の文字列を受け取って任意の文字列を返すような仕組みで設計されていて、xml の変換処理を jackson を使ってアプリケーションコードで書いていた。

これをやるならミドルウェアでやるべきだなと思って spring boot のドキュメントを調べてみた。Error Handling のように例外が発生したときの処理をフックする ResponseEntityExceptionHandler のようなミドルウェアに近い仕組みはあるが、通常のレスポンスに対して行う処理はなかった。代わりに HttpMessageConverters という、レスポンスを変換する仕組み自体は操作できないが、変換する変換器は置き換えたり拡張したりできるようになっている。レスポンスのデータフォーマットのカスタマイズをしたい場合は HttpMessageConverters で行うというのが spring boot 的なやり方にみえる。

さらに調べていると Write an XML REST Servicejackson-dataformat-xml がクラスパスにあれば jackson の ObjectMapper を使って xml に変換するよと書いてあって、試しにレスポンスのオブジェクトを返したら自動的に xml に変換されるという振る舞いを確認できた。つまり、アプリケーションコードで xml の変換処理を自前で実装しなくてもほぼ同じことを spring boot のデフォルトの仕組みでやってくれるというわけだ。jackson の ObjectMapper のカスタマイズがしたいときもいくつかやり方がある。例えば、 @Configuration をもつ Config オブジェクトで次のような bean を生成すれば任意の設定にカスタマイズした ObjectMapper が使われるようになる。

@Bean
public Jackson2ObjectMapperBuilderCustomizer configureObjectMapper() {
    return builder -> {
        builder.serializationInclusion(JsonInclude.Include.NON_EMPTY);
    };
}