Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

CompletableFutureの何が嬉しいか、そしてReactorへ……

DBにクエリを2つ投げて合成する処理

例えばユーザーIDから所有するリソースを検索してその情報を返すAPI Stream<Resource> find(long userId) を実装するとします。Resourceは大量に帰ってくる可能性があるので、List<Resource>ではなくStream<Resource>として扱います。実装はどうなるでしょうか:

// blocking API (wrong)
try (UserDao userDao = daoFactory.createUserDao();
    ResourceDao resourceDao = daoFactory.createResourceDao();) {
  Stream<Long> resourceIds = userDao.search(userId); // ページング処理を隠蔽
  return resourceIds.map(resourceId -> {
    Resource resource = resourceDao.find(resourceId);
    return resource;
  }); // Stream<Result>
}

これは正常に動作しません。メソッドを抜ける前にtry-with-finallyブロックがDAOを閉じてしまうからです。 呼び出し元がStream<Resource>を閉じてくれることを期待して、以下のようなコードになるでしょう:

// blocking API
UserDao userDao = daoFactory.createUserDao();
ResourceDao resourceDao = daoFactory.createResourceDao();
Stream<Long> resourceIds = userDao.search(userId); // ページング処理を隠蔽

// Streamが閉じられたらDAOも閉じる
resourceIds.onClose(userDao::close);
resourceIds.onClose(resourceDao::close);

return resourceIds.map(resourceId -> {
  Resource resource = resourceDao.find(resourceId);
  return resource;
}); // Stream<Result>

このように処理を遅延するコードはFutureでは書けません。Java8で登場したCompletableFutureを使うことになります。例えばUserResourceがひとつだけ結びつく状態なら以下のように書けます:

// CompletableFuture
UserDao userDao = daoFactory.createUserDao();
ResourceDao resourceDao = daoFactory.createResourceDao();
CompletableFuture<Long> future = userDao.search(userId).whenComplete( ... );

return future.thenApply(resourceId -> {
  Resource resource = resourceDao.find(resourceId);
  return resource;
}).whenComplete( ... ); // CompletableFuture<Result>

が、Streamのように複数の値を返すモノに使うのは複雑な工夫が必要ですし、やりようによってはすべての値を一度にオンメモリに乗せてしまいます。 私の知る限りではReactorフレームワークやRxJavaの採用がこの問題へのスマートな解になります。例えばFlux#using()を使うと以下のように書けます:

// Reactor
Flux<Long> resourceIds = Flux.using(daoFactory::createUserDao, userDao -> {
  return userDao.search(userId);
}, UserDao::close);

return resourceIds.flatMap(resourceId -> {
  Mono<Resource> resource = Mono.using(daoFactory::createResourceDao, resourceDao -> {
    resourceDao.find(resourceId);
  }, ResourceDao::close);
  return resource; // Flux<Resource>
});

関連記事

blog.kengo-toda.jp

Project Reactorでページング処理を書くにはFlux#expand()を使う

Bing検索で見つけるのが難しかったのでメモ。Project Reactorでページング処理を書く方法について。

例えばこういうAPIがあったときに、どう実装するか?

class Foo { ... }

class FooPage {
  @NonNull
  Foo[] getEntities();
  Optional<Integer> getNextPageNumber();
}

interface FooRepository {
  /** @param page 0-indexed page number */
  @NonNull
  Mono<FooPage> loadPage(int page);
}

class FooService {
  @Inject // use constructor injection instead in prod.
  FooRepository repository;

  @NonNull
  Flux<Foo> loadAll() {
   // TODO
  }
}

以下のようにFlux#expand()を利用する必要があります。引数には前回ロード時の値が入っているので、そこから次回のリクエストに使用するパラメータを算出します。大抵はサーバのレスポンスに次のページ番号が入っていたり、検索パラメータとして使用すべきトークンやIDが入っているはずなので、それを引き回す形になるでしょう。

  Flux<Foo> loadAll() {
    Flux<FooPage> fluxForPage = repository.loadPage(0).expand(prevPage -> {
      return prevPage.getNextPage().map(repository::loadPage).orElse(Mono.empty());
    });
    Flux<Foo> fluxForEntity = fluxForPage.flatMap(page -> Flux.fromArray(page.getEntities()));
    return fluxForEnttiy;
  }

Reactorはリソースの開放に using() を使うのもわかりにくかったですが、ページング処理もJavadocGitHubを検索しても出てこないのでちょっと苦労しました。

SLF4Jの1.8は安定版が出ないので2.0を使おうという話

前回の投稿で、SLF4Jには現在1.7、1.8、2.0の3バージョンがあるという話をしました。

これについてSLF4Jのuser向けメーリングリストで質問をしたところ、1.8の開発中にFluent Logging APIを追加するために2.0へとメジャーバージョンを上げたという旨の回答を得られました。つまり1.8は今後開発されず、安定版も出ないことになります。

[slf4j-user] Will version 1.8 be released in future?

よって現時点では1.8を利用する積極的な理由はあまりないと思われます。ライブラリ開発者としては、JPMS(Jigsaw)あるいはFluent Logging APIが必要なら2.0、そうでないなら1.7を選択することが良いのではないでしょうか。

Javaライブラリを配布する際のログ周りにおける配慮と実践 2020

この記事は、2013年に書いた記事を現状に合わせてアップデートするものです。結論から言うと、当時から id:miyakawa_taku さんがおっしゃっていた「APIは依存に含めて良い」を支持するものです。あるいは無難にバージョン 1.7.30 を使っておきましょう。

blog.kengo-toda.jp

slf4j-apiに1.7, 1.8, 2.0の3バージョンが生まれた

現在slf4j-apiには3つのバージョンが存在します。現在のSLF4Jエコシステムを考える上では、これらの違いを抑える必要があります:

1.7.x
Java 1.5から利用できるバージョンです。安定版にこだわるなら未だこのバージョンを使う必要があります。
1.8.x
JPMS(a.k.a. jigsaw)を採用しServiceLoaderを使ってBindingを呼び出すようになったバージョンです。Java 1.6が必要です。alpha0が出てから3年以上経ちますが、未だbeta4です*1。作業用ブランチも残っていないので、1.8は安定しないまま2.0開発に移ったものと思われます。
2.0.x
このバージョンからはJava 8が必要です。Fluent Logging APIが実装され、ログの書き方の選択肢が増えました。新しい実装はinterfaceのdefault methodを使って実装されているため、slf4j-api単体で変更が完結しており、binding側は変更なく利用できます。

複数バージョンの存在を踏まえ、ライブラリ配布者は何を気にするべきか

前出の情報を紐解くと、3バージョン間に眠る2つの差が見えてきます:

  1. APIの差。slf4j-api 2.0.x には従来存在しなかったメソッドが増えている。これに依存したライブラリのユーザは、slf4j-api 2.0.xを利用しなければ実行時に NoSuchMethodError が出るかもしれない。
  2. binding選択手法の差。slf4j-api 1.7.x と1.8.x以降では実装を決定するための手法が異なるため、apiとbindingのバージョンは揃える必要がある。

このうち2については、アプリケーションをビルドする開発者に責任があります。実行時にCLASSPATHにどのバージョンのAPIとbindingを含めるか、彼らに決定権があるからです。のでライブラリ提供者としては、彼らに選択肢と必要な情報を提供しつつ、1に取り組む必要があります。

考慮すべきはライブラリでFluent Logging APIを使っているかどうか、です。このAPIに依存していないなら、実行時にslf4j-apiのどのバージョンを利用しても問題ありません。

もし使っているなら、その旨をユーザに伝えて実行時にもslf4j-api 2.0.xを使ってもらう必要があります。そしてそのための最もsemanticな方法が、compileスコープで依存することです。ライブラリが2.0.xに依存しているにも関わらずに古いバージョンで依存が上書きされてしまった場合、ビルドツールによっては警告を発してくれます。Gradleの場合はもっと細かな依存指定も可能です。

よって、現時点では slf4j-api には compile スコープで依存することが望ましいと思います。bindingは引き続きprovidedあるいはtestスコープが望ましいでしょう。

どのバージョンを使うべきか

安定版にこだわるなら1.7.xを、JPMSを使いたいなら1.8.xか2.0.xを、Fluent Logging APIを使いたいなら2.0.xを選ぶことになるでしょう。

1.8.xと2.0.xはJPMSに準拠しており、モジュールを利用したビルドを行っているプロジェクトでも安心して使うことができます。ただどちらも安定版が出ていない点には注意が必要です。

2020-08-07 追記: 1.8を選択する積極的な理由がないことを以下の記事に記載しました。 SLF4Jの1.8は安定版が出ないので2.0を使おうという話 - Kengo's blog

マイナーケース:ライブラリとしても実行可能ファイルとしても配布する場合

私がメンテに参加しているSpotBugsは、spotbugs-gradle-pluginやspotbugs-maven-pluginなどから利用されるライブラリとしての性質と、CUIGUIで実行される実行ファイルとしての性質とを持ち合わせています。内部的にはDistribution Pluginで配布用パッケージを作成しています。

この場合、Maven Centralにアップロードする pom.xml にはSLF4J bindingを依存として追加しない一方で、distribution pluginの対象にはSLF4J bindingを追加する必要があります。

runtimeOnly 依存を使うことが素直な解決になるでしょう。configurations.runtimeClasspath を配布用ファイルに入れる形になります。

ただSpotBugsの場合はEclipse用依存を別に管理している(Eclipse用のruntimeClasspathにはSLF4J bindingを入れたくない)ので、以下のようにconfigurationを自前で定義しています

plugins {
    id 'distribution'
}
configurations {
  slf4jBinding
}
dependencies {
  implementation 'org.slf4j:slf4j-api:1.8.0-beta4'
  logBinding ('org.apache.logging.log4j:log4j-slf4j18-impl:2.13.3') {
    exclude group: 'org.slf4j'
  }
}
distributions {
  main {
    contents {
      from ([configurations.compileClasspath, configurations.slf4jBinding]) {
        into 'lib'
        include '**/*.jar'
      }
    }
  }
}

*1:newsページにはbeta5が公開された旨が記載されていますが、Maven Centralには来ていませんし、タグも残っていません

研究などの学術的目的で問い合わせや協力依頼をいただく際の私の対応方針

たまに研究への協力依頼などをいただくのですが、それに対するポリシーをこちらにまとめます。説明用です。

連絡先

Twitterかメールを推奨しています。メールアドレスは私のウェブサイトで紹介しています。

サーベイに対する協力

常識的なボリュームのサーベイであれば協力させていただきます。URLと回答所要時間を共有いただければそれで充分です。

その他の協力

SpotBugsの使い方やbytecode manipulationなどについて、突っ込んだ質問をいただくことがあります。こうしたクローズドな質問はStackoverflowやGitHubで行っているFOSS活動とは一線を画するものですし、対応コストも高いので、基本的にはお受けしません。まずは対応するFOSSコミュニティ、あるいは周囲の指導担当者からの協力を得るようにしてください。ウェブサイトやメーリングリストGitHubプロジェクトなどで質問を行えることが多いはずです。

どうしても必要と思われる場合は、所属機関のメールアドレス(.ac.jpドメインなど)から所属と研究目的、得たい協力について説明するメールを送ってください。この際、指導担当者もCCに入れてください。指導担当者はあなたの指導に何らかのポリシーを持っているはずで、それを逸脱する助力をすることを避けるためです。

GitHub Actions 最近のやらかし一覧

FOSS開発で細かいやらかしを積み上げてきたのでまとめる。

テストの失敗原因レポートをartifactとしてアップロードしそこねる

actions/upload-artifactを使ってテストレポートをartifactとしてアップロードする際、以下の書き方だと失敗する。

# bad
    - run: |
      ./gradlew test --no-daemon --stacktrace
    - uses: actions/upload-artifact@v2
      with:
        name: reports
        path: build/reports

これはテストが失敗した時点で後続のstepsが実行されなくなるため。明示的に失敗時でもアップロードされるように指示する必要がある。

# bad
    - run: |
      ./gradlew test --no-daemon --stacktrace
    - uses: actions/upload-artifact@v2
      if: always() # this config is necessary to upload reports in case of build failure
      with:
        name: reports
        path: build/reports

forkからのPRではGITHUB_TOKENを除くsecretsを参照できない

secretsの存在を前提にしているコードがあると、forkからPRをもらったときにビルドが通らなくなる。

# bad
  - name: Decrypt file
    env:
      GPG_SECRET_PASSPHRASE: ${{ secrets.GPG_SECRET_PASSPHRASE }}
    run: |
      gpg --quiet --batch --yes --decrypt --passphrase="$GPG_SECRET_PASSPHRASE" --output decrypted encrypted
      # -> gpg: missing argument for option "--passphrase="

後述する方法でsecretsが空かどうか確認する必要がある。

if では $VAR で環境変数を参照できない

if で書くのはbashじゃくてexpressionなので、runに書くのと同じノリでやると失敗する。

# bad
    - name: Run SonarQube Scanner
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_LOGIN: ${{ secrets.SONAR_LOGIN }}
      if: ${{ $SONAR_LOGIN != "" }}
      run: |
        ./gradlew sonarqube -Dsonar.login=$SONAR_LOGIN --no-daemon

正しくはenvコンテキストを参照する(なお${{ .. }} で囲むのは必須ではない)か、

# good
    - name: Run SonarQube Scanner
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_LOGIN: ${{ secrets.SONAR_LOGIN }}
      if: env.SONAR_LOGIN != ""
      run: |
        ./gradlew sonarqube -Dsonar.login=$SONAR_LOGIN --no-daemon

bashで統一して読みやすくしたいなら run の中で判定する。

# good
    - name: Run SonarQube Scanner
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_LOGIN: ${{ secrets.SONAR_LOGIN }}
      run: |
        if [ "$SONAR_LOGIN" != "" ]; then
          ./gradlew sonarqube -Dsonar.login=$SONAR_LOGIN --no-daemon
        fi

gradleで環境変数があるときだけ特定タスクを実行する場合は、こういう書き方もできるらしい。シンプル。

    - name: Run SonarQube Scanner
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        SONAR_LOGIN: ${{ secrets.SONAR_LOGIN }}
      run: |
        ./gradlew spotlessCheck build smoketest ${SONAR_LOGIN:+sonarqube} --no-daemon -Dsonar.login=$SONAR_LOGIN

最近キャッチアップしているもの 2020-05

いろいろ手を出しすぎてごちゃごちゃしてきているので、頭の中を整理する目的でここに書き出す。

reproducible build

Mavenのメーリスで話題に出ることがあり知った。特別新しい概念ではないけどある種のunlearningであり、ビルド職人は見といて損ないやつ。

reproducible-builds.org

端的に言うと、誰がどこでビルドしても同じアーティファクトができるようにしようという話。実はJavaのビルドツールでアーティファクト(主に.jarファイル)を作ると、そのビルド結果は常にバイナリ等価とは限らない。ビルドした日付がMETA-INFに入ったり、同梱されたファイルはすべて同じなのにZIPに突っ込む順番が違ったりと、ビルドした時刻や環境によって異なる結果が生まれることがある。詳細はDZoneの記事を参照。

なぜこれが着目されているかは公式サイトに書かれているが、つまるところ「アーティファクトソースコードの対応」を検証する術として期待されている。
従来は「本当にこれが公式に配布されているファイルかどうか」はchecksumや署名で確認できていたが、それ以前にバージョン v1.0.0VCSv1.0.0 タグから本当にビルドされたのかを検証することができなかった。ので例えばビルドとリリースのプロセスを攻撃することで、悪意あるコードが含まれた v1.0.0 がリリースされる可能性を検証するコストが高かった(CHANGELOGやRelease Notesではなくバイトコードを読む必要がある)。これがreproducible-buildにより、手元で v1.0.0 をチェックアウトしてビルドした結果と配布物を突き合わせることで攻撃の可能性をまず検証できるようになる。

これがunlearningだと考える理由は、ビルドツールのデフォルト設定だと達成されないから。つまりコミュニティで可搬性のあるプラクティスと考えられてきた常識を疑い変えていくフェーズに今はある。まぁ落ち着いて考えればビルドした日付をアーティファクトに埋める必要性など皆無なわけだが、昔は「このJenkinsジョブが作った.jarだ」ということを明確化するためにファイル指紋に加えてユーザ名やらホスト名やらいろいろMETA-INFに組み込んでいたような気がする。

なおMavenだとプラグインが提供されているので、これをプロジェクトに適用すればよい。Gradleのもあるけど活発ではないので、カバーされていない問題もあるかもしれない。Gradle公式には特にまとまった情報はなく、依存先のバージョン固定に関するドキュメントにさらっと書いてあるくらい。

visual testing (GUI regression testing)

Seleniumを使ったGUIテストについては従来からやってきたが、主なテスト対象はアプリの挙動だった。これに加えてUIの崩れについてもテスト対象とする動きがある。JS界隈・CSS界隈は動きが速く、それでいて多様な環境で動作しなければならないということで、互換性維持の難度がもともと高い。これがUIとなると解像度についても考える必要性があり、余計に複雑化する。例えば最新のブラウザでベンダプレフィックスがサポートされなくなったり、flexboxの挙動が変わったりしたときに、レガシーブラウザでの挙動を変えること無く新しいコードに実装を置き換えていく必要があるが、これを手動でやっていると大変。

visual testing自体は7年前に試行錯誤しているブログがあるくらい仕組みは簡単で、スクリーンショットを撮っておいて保存、次回実行時に比較するというもの。なおまだ名前が定着していないらしく、visual testingとかGUI regression testingとか、みんな好きに呼んでいる。SaaSベンダーやFOSSプロジェクトの比較をするならawesome-regression-testingが役に立つ。

Seleniumを使ったGUIテストが安定かつ高速に回せる環境がすでにあれば、導入自体は難しくない。ブラウザやOSのベータ版でテストを回す仕組みとか、失敗したテストだけを再実行する仕組みとか、テストを複数ノードに分散して実行する仕組みとかがあれば強みを活かしやすい。percyのペーパーによるとUIの変化が確認されたケースの96%はapproveされたようなので、visual testingが失敗したら無条件にマージさせない的なPR運用ではなく、開発者に判断材料を提供する仕組みとして運用する必要性がありそう。

ここまで書いてみて、なぜここ最近でSaaS/PaaS界隈が盛り上がってきたのかがよくわかっていないことに気づいた。BrowserStackのリリースノートによると実デバイスによる機能を提供したのが2019年2月AWS Device Farmに至ってはSeleniumテストの実行をサポートしたのが2020年1月ということで、最近の動きであることは間違いなさそう。自分の直面している課題に最適なのは疑い無いので構わないが。

cross-functional team

従来の「組織の役割を単純化して相互連携によって業務を進める組織設計」の課題を解決する手法としての「単一チームに複数の役割をつめこむ組織設計」と認識している。チームができる意思決定を増やし、組織の壁を超える頻度を減らし、ビジネスのagilityを上げることを目的とする。縦割り(silos)の弊害を解消するために全員をひとまとめにしようというアイデアと考えるとわかりやすいが、チームリーダーの責務が大きく変わるので導入は慎重にしたほうがいい。

cross-functional teamの背景にはcontinuous deployment, DevOps, servant management, capability modelといったトレンドがあると理解している。「マネジメントが理想を描いてメンバーを従える」独裁型と違い、「ミッションを明確化し、専門家がミッション達成のために必要なもの全てを提供するために腐心する」調整型のマネジメントになる。従来型の組織ではチームの長はメンバーと専門性を共有していることが多かったが、cross-functionalチームではそうではないので、独裁のしようがない。これがcross-functional teamとservant managementそしてcapability modelが切り離せないと自分が感じる理由。ただ今まさにaccelerateを読んでいるので、この辺の意見は今後変わるかもしれない。

まだよくわかってないのがチームの小ささ(two-pizzas team)とバス係数の両立。cross-functionalとは言えどもチームは充分小さく活発に議論ができなければならないはずで、例えばDX Criteriaでは5-12名の規模を目安としている。バス係数を考慮し各roleを2名以上とすると、どんなに頑張っても入れられるrole数は2−4個に制限されるはずで、専門性の細分化が進むシステム開発において企画から運用まですべてのプロセスで必要になるすべての専門性をチームに入れることは不可能だとわかる。のでcross-functional teamを採用する組織においても、ある程度の縦割りは残るだろう。その「残し方」として著名なのはSREだが、他にもセキュリティやUXなど組織横断的に統制をかけてやるべき活動については専門のチームが残ると予想する。その際はcross-functional teamとは異なる解決策が必要で、それがSREではerror budgetになる。silosの壁を壊すには「同じ問題を解決する1つのチームなんだ!」的精神論ではなくて、仕組みまで落とし込むことが肝要。

ではどういったroleをcross-functional teamに入れられるかというと、すぐに思いつくのがテスト周りの責務。テストの知見を持ったエンジニアを企画段階から参画させやすくなり、手戻りを減らし保守性を向上すると期待できる。testabilityに配慮したコードかどうかをPRレビューで見てもらえるし、組織としてPOVがひとつ増えることのメリットが大きいはず。Opsも同様。

ちょっと脱線するけどテストはビルドエンジニアのキャリアパスとしてもわりとアリだと思っていて、自動テストの重要性がどんどん上がっている昨今、テストをどうCD pipelineに組み込むか・高速に終えるか・安く実行するかというビルドエンジニアならではの貢献ポイントが多々あるように見受けられる。もちろん別々のroleとしてチームに配置してもいいんだけど、GoogleのTEの例にあるように、1人が知識を持ち合わせてその掛け算で問題解決を図るのは十分可能なレベルだと思う。

Product Management

Strategic Management, Visionary Leadership, Project Management (PjM)とやってきて、いざProduct Management (PdM)に首を突っ込んでいるのだが。今のところは「プロダクトを形作る組織の全体像を把握して、課題に優先度を付けて解決する」という解釈しか持てていない。

すえなみさんのツイートに共感した。

PjMとPdMに限らず、最近はアジャイルウォーターフォールにも差が対して無いんじゃないかみたいな気持ちになってきていて、学習の振り子が振り切った感じがする。そのうち逆側に振れると思うけど(比較対象表とか書き出すやつ)。