権限設定を実装するため、Javaで使える認可ライブラリについて調べた

アカウントまたはグループに権限設定して、もろもろの属性に応じたアクセス制御を行うことになった。

  1. システム管理者が参照・作成・更新・削除といった権限を含むロールを作成
  2. システム管理者がトップレベルのリソース(ルートディレクトリ的なもの)を作成
  3. システム管理者がトップレベルのリソースごとに管理者を指定(アカウントに管理者ロールを指定するイメージ)
  4. 各トップレベルリソースの管理者が、そのリソースに参加するアカウントや、複数アカウントが参加するグループにロールを割り当て
  5. リソース作成権限のあるロールを持ったアカウントがリソース(サブディレクトリやファイル的なもの)を作成

のような仕様になりそうで、スクラッチで実装するのは難しいと判断。

アプリケーションはJava+Spring Bootで実装しており、Javaで認可処理を行うためのOSSライブラリがないか調査したのでメモ。

Spring Security

おなじみSpring Security

hasRole などによるロール判定と同じように、 Spring EL式、および SecurityExpressionOperationshasAuthority などによる権限判定が可能。知らなかった...

hasAuthority の使い方も hasRole と同様、チェックしたい権限名を文字列で指定する。

ロールと権限の違いだが、先のJavadocによると、デフォルトではロールの場合は接頭語として ROLE_ が付くが、権限の場合は付かないだけの模様。これらのメソッドが GrantedAuthority をチェックしていることを考えると、 hasRolehasAuthority の特化メソッドにも思える。

以下、実装例の記載があるブログ記事。

www.kimullaa.com

ロールの接頭語を変更していない場合、 hasRole("USER")hasAuthority("ROLE_USER") と置き換えることも可能。リファレンス中でも、hasAuthority の引数として、 SCOPE_contacts のように SCOPE_ を接頭語としている文字列と、ROLE_USER のようにロールを指定している部分がある。

Apache Shiro

これまたおなじみApache Shiro。大本のJSecurityがApacheに移管されたのが2008年らしい。

Spring連携も可能、Apacheなのでライセンスは当然Apache License 2.0

レルムなどはクラスとして実装する模様。

OpenAM

github.com

こちらもかなり以前から聞くやつ。

qiita.com

上記の記事によると、どうやら「ForgeRock AM」としてクローズドソース化したものと分かれており、日本ではOpenAMコンソーシアムが中心となってメンテナンスされている模様。GitHubのホストが openam-jp だったり、日本語READMEがあるのもそのあたりの経緯からか。

OpenStandiaによると、レルムによるグループ制御やXACML (eXtensible Access Control Mark-Up Language)ベースのポリシー管理によるアクセス制御が可能とのこと。

ネックとしては、Maven CentralなどのパブリックなMavenリポジトリで公開されていないこと、またライセンスがCDDLGPL非互換なことか。

Casbin/jCasbin

casbin.org

Goで実装された認可ライブラリ。認可に特化しており、認証やユーザーのパスワード管理などは行わない。

様々なプログラミング言語に移植されており、Java移植版がjCasbinMavenリポジトリでも公開されている。

ライセンスはApache License 2.0

また、ミドルウェアとして、Spring BootやApachi Shiroと統合し、それらの認可をCasbinに移譲できる模様(それぞれ2~3種類あるが)。Spring Bootを利用しているのであれば、casbin-spring-boot-starterを使うのが手っ取り早いか。

アクセス制御モデルは、PERM(Policy, Effect, Request, Matchers)に基づいた設定ファイルに保存する。それとは別にポリシーを保存しておき、それぞれ読み込んで Enforcer を生成する模様。

ポリシー保存先に応じたアダプターが実装されており、Javaでは組み込みのCSVファイル以外に、JDBCHibernate、MyBatis、DynamoDB、Redisなど利用可能。

特徴的な点として、アクセス制御モデルとポリシーのテストができるオンラインエディターが用意されている。

番外: Keycloak

www.keycloak.org

認証および認可を行う、Red HatがサポートしているOSS

getting-started によると、Java実行環境(OpenJDK)、Docker、k8s、またRed HatなのでPodmanでもサーバーとして起動できる模様。また、Vue.jsからも利用できるとのこと。

軽く調べてみた限り、どうもライブラリとしてアプリケーションに組み込んだりはできず、サーバーとして起動しておいてAPIを叩いたりするものらしい。

JavaJavaScript用のアダプターはあるようだが、今回は既存のアプリケーションに組み込む前提なので、対象外とした。

振り返り

Spring Securityで権限判定ができることが分かったのが大きい。Spring Bootが支配的な最近のJava環境では、Spring Security+AOPで実装できるのはありがたい。

文字列ベースでは厳しい複雑な権限制御が必要になるのであれば、Apache ShiroやjCasbinに認可実装を委譲することもできそう。

また、今回は既存のアプリケーションを拡張する必要があるため、ライブラリとして導入できるものを調べたが、Amazon Cognitoのようなマネージドサービスに認証/認可を任せてしまうのがいいのだろう。

なお、権限設定の設計については、以下のページが参考になった。

kenfdev.hateblo.jp

毎回忘れる海外の全文検索SaaSサービスの名前「Algolia」と、ざっくり全文検索サービス紹介

GitLabの全文検索に使われていたりFirebaseの全文検索に推奨されているSaaS全文検索サービスの名前を毎回忘れるのでメモ。

Algolia

です。

2021/3/28現在では、公式ページの利用会社ロゴにSlackがあるなぁ。

使ってみたとかではなく、全文検索の話題の時に「あれ、なんて名前だっけ、あれあれ」と毎回忘れているので、本当に単なる備忘録。

ちょっと古いけど、使ってみた系はこのあたりかな?

qiita.com

qiita.com

全文検索エンジン/システム/サービスあれこれ

さすがに名前を書いておくだけでは薄すぎるので、ざっくりJavaで使える全文検索エンジンやシステム、サービスから、使ったり調べたことがあるものを書き出してみる。

日本語で全文検索しようと思うと、形態素解析器どうするかという問題もあるが、それは置いておく。

Apache Lucene

lucene.apache.org

Java製の検索エンジンライブラリ。名前の通り、Apacheのトップレベルプロジェクト。後述のApache SolrやElasticsearchも、バックエンドではLuceneを使っている。

インデックスを生成し、インデックスの内容を確認するには Luke というGUIツールを使用する。以前は別途ダウンロードする必要があったが、Lucene 8.1以降はLuceneのモジュールになった模様

Luceneを直接使ったことがあるか、トラブルシューティングがしんどかった思い出。

Apache Solr

solr.apache.org

Lucene検索エンジンとして使用する、全文検索システム。サーバーとして起動して、API全文検索することができる。

ずっとLuceneのサブプロジェクトだったが、2021年2月17日、ついにApacheトップレベルプロジェクトに昇格した

トラブルシューティング時に、Solr Adminからインデックスの状態を確認したり、クエリ実行できるのが大変ありがたかった。ライブラリとシステムなので単純比較できるものではないが、Luceneよりだいぶ確認・検証しやすくなった。

Elasticsearch

www.elastic.co

これまたLuceneを検索エンジンとして使用する全文検索システム。

昔のSolrではクラスターを構築するのが大変で、スケールアウトが難しかった(自分は固定台数でしか構築経験がない、その後のバージョンアップでSolrCloudなどの機能が拡充されたので、今は解消されているかも)。

Elasticsearchは後発の強みとして、あらかじめ分散環境で動作するよう設計されている。

また、サーバーを用意して導入する以外に、Elastic Cloudを使えばAWSGCP、Azure上でマネージドサービスとして利用することもできる。

Fess

fess.codelibs.org

CodeLibs Projectが提供している全文検索システム。全文検索にはElasticsearchを使用している。

Fessを使うメリットは何かというと、Webクローラが利用できる点かと思う。

自社のWebページを全文検索したい場合、サイトの情報を全文検索エンジンに登録する必要がある。静的ページを都度登録するだけでも面倒だし、動的ページがあればなかなか手動で行うのは難しい。

ElasticsearchであればElastic Site Searchを使うこともできるが有料のようなので、無料でWebサイトの全文検索をしたいのであれば、Fessを候補としてもいいかもしれない。*1

その他

AWSやAzureでも、マネージドな全文検索サービスを提供している。

振り返り

とりあえず記事にしたので、「Algolia」はもう忘れないだろう、たぶん。

*1:なお、書いている本人はFessを使ったことがない

typescript-generatorにプリミティブ型を必須プロパティ(non null)にするオプションが追加されていた

以前書いた記事の段階では、 typescript-generatorrequiredAnnotations を指定すると、プリミティブ型のフィールド/GetterについてもOptional Properties(null設定可能)扱いとなっていた。

2021年3月の更新で、プリミティブ型をまとめて必須(非null)にできるオプションが追加されていたのでメモ。

環境

typescript-generator 2.30.840

追加されたオプション

primitivePropertiesRequired という boolean 型のパラメーターが追加されている。プロパティの説明はこちら

optionalProperties がデフォルトの useSpecifiedAnnotations の場合、 requiredAnnotations を指定していなければ各プロパティは必須になるため、 primitivePropertiesRequiredrequiredAnnotations との併用前提と思われる。

Gradleであれば、以下のように generateTypeScript タスクにパラメーターを追加するだけでいい。

generateTypeScript {
  // 中略, requiredAnnotations 指定済み

  primitivePropertiesRequired = true
}

使用例

以前の記事から設定およびクラスを流用。

build.gradle の変更箇所は以下。

buildscript {
  // 中略
  dependencies {
    // バージョンを 2.30.840 に変更
    classpath 'cz.habarta.typescript-generator:typescript-generator-gradle-plugin:2.30.840'
  }
}

generateTypeScript {
  // 中略  
  primitivePropertiesRequired = true
}

出力対象のクラスは前回のリクエストクラスをそのまま流用。

package hepokon365.request;
@lombok.Data
public class ExampleRequest implements java.io.Serializable {
    @javax.validation.constraints.NotNull
    private String stringValue;
    private boolean booleanValue;
    private hepokon365.enums.ExampleEnum enumValue;
}

出力結果は、以下のように、従来はオプションプロパティだった booleanValue が、プリミティブ型のため必須プロパティとなる。

export namespace Endpoint {
  export interface ExampleRequest {
    stringValue: string;
    booleanValue: boolean;
    enumValue?: ExampleEnum | null;
  }
}

振り返り

プリミティブ型に @javax.validation.constraints.NotNull などのnullチェックアノテーションをつけると、IDEから警告が出たりするため、運用として boolean なら Booleanint なら Integer などのラッパークラスにしてアノテーションを付与することで対応していた。

とはいえ、プリミティブ型のままであれば自明なことを、プラグインの仕様に合わせるため曲げてコーディングしていたことは否めないため、この変更はかなりうれしい。

typescript-generatorプラグインでJavadocからTSDocを生成する

以前調査したtypescript-generator、その後もパラメーター調整しながら使用しているが、困ったのがヒアドキュメント。

Java側ではJavadocでクラスやプロパティの説明を書いているが、生成したTypeScriptのインターフェース定義ファイルにはデフォルトだと出力されないため、フロント実装時にJava側の定義を見ないと使いづらい。

Swaggerアノテーション@ApiModel をクラスに、 @ApiModelProperty をプロパティに付与すると、TypeScript定義ファイルに出力されるが、JavadocとSwaggerアノテーション、両方書くのは面倒。

JavadocからSwagger UIを生成することはSwagger Docletでできそうだが、Swaggerアノテーションを生成するわけではないし、Javadoc書かずにSwaggerアノテーションだけ書くのも何だし、と思っていたら、typescript-generator自体が、xml-docletを使ってJavadocからTSDocを生成する機能を持っていたのでメモ。

環境

OpenJDK 8 8.275.01, typescript-generator 2.27.744, xml-doclet 1.0.5

TSDoc生成処理の概要

Javadoc · vojtechhabarta/typescript-generator Wiki · GitHub に設定方法の記載がある。

まず、xml-docletを使ってXML形式のJavadocを出力し、それをtypescript-generatorに読み込ませれば、パースしてTSDocとして出力してくれる。

設定

Gradleへのxml-docletの導入

まずはxml-docletの導入から。

プロジェクト管理ツールにMavenを使っている場合は、xml-docletのREADME、およびtypescript-generatorのWikiを見ればいいが、Gradleの場合の例がないのでやや面倒。

Javadocツールはドックレットを切り替えることで、出力形式を変更できる。GradleでJavadocタスクにカスタムドックレットを指定する方法はこちら

  1. javadoc タスクを有効化するため、 java プラグインを有効化
  2. configurations でドックレット名を指定
  3. dependencies でドックレットの依存性を追加
    • この時、 implementation など指定するところをドックレット名にする
  4. type: Javadoc となるタスクを追加

java プラグインはもともと入っていたため、以下の設定を追加。

configurations {
  xmlDoclet
}

dependencies {
  xmlDoclet 'com.github.markusbernhardt:xml-doclet:1.0.5'
}

final def tsOutputDir = 'build/typescript-generator'
final def tsXmlDocletDir = "${tsOutputDir}/xml-doclet"

task xmlDoclet(type: Javadoc) {
  source = sourceSets.main.allJava
  destinationDir = (tsXmlDocletDir as File).absoluteFile
  title = null // 「-doctitleは無効なフラグです」でエラーとなるため抑制
  options.noTimestamp = false // 既に廃止済みのオプションがデフォルトで付与され、エラーとなるため抑制
  options.encoding = 'UTF-8' // XMLの出力文字コード
  options.showAll() // publicなフィールド・メソッド以外のJavadocも出力
  options.doclet = 'com.github.markusbernhardt.xmldoclet.XmlDoclet'
  options.docletpath = configurations.xmlDoclet.files as List
}

xml-docletは3年前で更新が止まっており、デフォルトだとOpenJDKのjavadocツールと非互換が出ていたため、一部のオプションを指定して抑制している。

また、 showAll() しているのは、getterがあるがJavadocがない場合、フィールドのJavadocを元にTSDocを出力してくれるため。Lombokを使っている場合に有効。

出力ファイル名のデフォルトは javadoc.xml 。前述の設定で gradle xmlDoclet を実行すると、 build/typescript-generator/xml-doclet/javadoc.xml が出力される。

ちなみに、処理の詳細としては、 build/tmp/xmlDoclet/javadoc.options にファイルが出力され、 javadoc @build/tmp/xmlDoclet/javadoc.options のようにJavadocがオプションファイル指定で実行される模様。

typescript-generatorにjavadoc.xmlを読み込ませる

generateTypeScript タスクの javadocXmlFiles に、読み込ませるXMLファイルのパスをFileのListとして設定するだけで、TSDocを出力してくれる。

また、 generateTypeScript.dependsOnXML出力タスクを追加しておくと gradle generateTypeScript で合わせて実行できる。

generateTypeScript {
  ...
  javadocXmlFiles = ["${tsXmlDocletDir}/javadoc.xml" as File]
}

generateTypeScript.dependsOn compileJava, xmlDoclet

build.gradleの例

typescript-generatorの設定を含めた build.gradle は、以下のようになる。いまだに apply plugin なのがちょっと残念。

apply plugin: 'java'
apply plugin: 'cz.habarta.typescript-generator'

buildscript {
  dependencies {
    classpath 'cz.habarta.typescript-generator:typescript-generator-gradle-plugin:2.27.744'
  }
}

configurations {
  xmlDoclet
}

dependencies {
  xmlDoclet 'com.github.markusbernhardt:xml-doclet:1.0.5'
}

final def tsOutputDir = 'build/typescript-generator'
final def tsXmlDocletDir = "${tsOutputDir}/xml-doclet"

generateTypeScript {
  ...
  javadocXmlFiles = ["${tsXmlDocletDir}/javadoc.xml" as File]
}

task xmlDoclet(type: Javadoc) {
  source = sourceSets.main.allJava
  destinationDir = (tsXmlDocletDir as File).absoluteFile
  title = null
  options.noTimestamp = false
  options.encoding = 'UTF-8'
  options.showAll()
  options.doclet = 'com.github.markusbernhardt.xmldoclet.XmlDoclet'
  options.doclet = 'com.github.markusbernhardt.xmldoclet.XmlDoclet'
}

generateTypeScript.dependsOn compileJava, xmlDoclet

出力例

以下のクラスを用意。

/** インターフェース */
public interface ExampleInterface {
    /**
     * インターフェースのメソッド
     *
     * @return 文字列
     */
    String getInterfaceMethod();
}

/** クラス */
@lombok.Data
public class ExampleClass implements ExampleInterface {
    /** クラスのフィールド */
    private String classField;
    private String interfaceMethod;
}

/** 列挙型 */
public enum ExampleEnum {
  /** 1 */
  ONE,
  /** 2 */
  TWO,
  /** 3 */
  THREE;
}

これに対して、前述の build.gradlegenerateTypeScript タスクを以下のように変更して、 gradle generateTypeScript してみる。

generateTypeScript {
  jsonLibrary = 'jackson2'
  classPatterns = [各クラスのパッケージ]
  outputKind = 'module'
  outputFileType = 'implementationFile'
  outputFile = "${tsOutputDir}/example.ts"
  sortDeclarations = true
  stringQuotes = 'singleQuotes'
  indentString = '  '
  javadocXmlFiles = ["${tsXmlDocletDir}/javadoc.xml" as File]
}

結果は以下のようになる(ヘッダのコメント部分は省略)。

/**
 * クラス
 */
export interface ExampleClass extends ExampleInterface {
  /**
   * クラスのフィールド
   */
  classField: string;
}

/**
 * インターフェース
 */
export interface ExampleInterface {
  /**
   * インターフェースのメソッド
   * @return 文字列
   */
  interfaceMethod: string;
}

/**
 * 列挙型
 * 
 * Values:
 * - `ONE` - 1
 * - `TWO` - 2
 * - `THREE` - 3
 */
export type ExampleEnum = 'ONE' | 'TWO' | 'THREE';

XMLをパースしているためか、元のJavadocワンライナーで記述しても、TSDocの出力は複数行になっているなど、多少形式に違いはあるが、概要以外に @return なども出力される。

EnumはデフォルトではUnionにマッピングされるため、typeのTSDocとして各値の説明が出力される。

generateTypeScript のタスク定義に mapEnum = 'asEnum' を追加し、Enumマッピングすると、以下のように、各値にもTSDocが追加される。

/**
 * 列挙型
 * 
 * Values:
 * - `ONE` - 1
 * - `TWO` - 2
 * - `THREE` - 3
 */
export const enum ExampleEnum {
  /**
   * 1
   */
  ONE = 'ONE',
  /**
   * 2
   */
  TWO = 'TWO',
  /**
   * 3
   */
  THREE = 'THREE',
}

振り返り

Java側のコードに手を入れることなく、Gradleタスクを追加するだけでJavadocからTSDocへの変換を行えるようになった。

ただ、xml-docletが更新停止状態で、JDK11以降の新しいJavadoc APIに対応していない模様。

github.com

typescript-generatorの開発者の方がPull Request出したりしているが動きなし。

github.com

先々まで使い続けることはできないかもしれないのが難点か。

フィールドやメソッドに付与されたRetentionPolicy.CLASSのアノテーションをJavassistで実行時に判定する

lombok.NonNull やKotlinの valコンパイルすると付与される org.jetbrains.annotations.NotNull など、 RetentionPolicy.CLASSアノテーションを実行時に判定したい場面があった。

RetentionPolicy.CLASS の場合は、 RetentionPolicy.RUNTIME のようにリフレクションでは実行時に情報を取得できないが、クラスファイルには書き出されている。

クラスファイルに情報があるならJavassistで読み込めないかと思って試してみたら、フィールドやメソッドに付与された場合は何とか判定できたのでメモ。

環境

OpenJDK 8.282, Javassist 3.27.0-GA。

実装

アノテーション

RetentionPolicy.CLASSRetentionPolicy.RUNTIMEアノテーションをそれぞれ宣言。

/**
 * RetentionPolicy.CLASS  のアノテーション
 */
@Target({
        ElementType.FIELD,
        ElementType.METHOD,
        ElementType.TYPE_PARAMETER,
        ElementType.TYPE_USE,
})
@Retention(RetentionPolicy.CLASS)
public @interface ClassAnnotation {
}

/**
 * RetentionPolicy.RUNTIME のアノテーション
 */
@Target({
        ElementType.FIELD,
        ElementType.METHOD,
        ElementType.TYPE_PARAMETER,
        ElementType.TYPE_USE,
})
@Retention(RetentionPolicy.RUNTIME)
public @interface RuntimeAnnotation {
}

アノテーションを付与したクラス

先に作成した2つのアノテーションを、フィールド、メソッド、また ElementType.TYPE_PARAMETERElementType.TYPE_USE で総称型の型パラメーターやメソッドの戻り値などに付与しておく。

/**
 * アノテーションを付与したクラス
 */
@lombok.Getter
public class AnnotatedClass {

    @ClassAnnotation
    private String fieldClass;
    @RuntimeAnnotation
    private String fieldRuntime;

    private List<@ClassAnnotation String> fieldGenericsTypeParameterClass;
    private List<@RuntimeAnnotation String> fieldGenericsTypeParameterRuntime;

    private @ClassAnnotation String fieldTypeUseClass;
    private @RuntimeAnnotation String fieldTypeUseRuntime;

    @ClassAnnotation
    public String getMethodClass() {
        return "MethodRetentionPolicyClass";
    }
    @RuntimeAnnotation
    public String getMethodRuntime() {
        return "MethodRetentionPolicyRuntime";
    }

    public List<@ClassAnnotation String> getMethodGenericsTypeParameterClass() {
        return Collections.emptyList();
    }
    public List<@RuntimeAnnotation String> getMethodGenericsTypeParameterRuntime() {
        return Collections.emptyList();
    }

    public <@ClassAnnotation T> T getMethodTypeParameterClass() {
        return null;
    }
    public <@RuntimeAnnotation T> T getMethodTypeParameterRuntime() {
        return null;
    }

    public @ClassAnnotation String getMethodTypeUseClass() {
        return "getMethodTypeUseClass";
    }
    public @RuntimeAnnotation String getMethodTypeUseRuntime() {
        return "getMethodTypeUseRuntime";
    }

}

動作確認

JUnit5によるユニットテストで確認。対比のため通常のリフレクションによる取得も記述。

class AnnotatedClassTest {

    private final Class<AnnotatedClass> cls = AnnotatedClass.class;
    private final Class<ClassAnnotation> classAnnotation = ClassAnnotation.class;
    private final Class<RuntimeAnnotation> runtimeAnnotation = RuntimeAnnotation.class;

    private void assertAnnotatedType(
            AnnotatedElement annotatedElement,
            Class<? extends Annotation> typeParameterAnnotation,
            boolean validAnnotation) {
        assertEquals(annotatedElement.isAnnotationPresent(typeParameterAnnotation), validAnnotation);
    }

    private void assertGenericsFieldTypeParameter(
            Field field,
            Class<? extends Annotation> typeParameterAnnotation,
            boolean validAnnotation) {
        AnnotatedParameterizedType parameterizedType = (AnnotatedParameterizedType) field.getAnnotatedType();
        AnnotatedType annotatedType = parameterizedType.getAnnotatedActualTypeArguments()[0];
        assertAnnotatedType(annotatedType, typeParameterAnnotation, validAnnotation);
    }

    private void assertFieldTypeUse(
            Field field,
            Class<? extends Annotation> typeParameterAnnotation,
            boolean validAnnotation) {
        AnnotatedType annotatedType = field.getAnnotatedType();
        assertAnnotatedType(annotatedType, typeParameterAnnotation, validAnnotation);
    }

    private void assertMethodGenericsTypeParameter(
            Method method,
            Class<? extends Annotation> typeParameterAnnotation,
            boolean validAnnotation) {
        AnnotatedParameterizedType parameterizedType = (AnnotatedParameterizedType) method.getAnnotatedReturnType();
        AnnotatedType annotatedType = parameterizedType.getAnnotatedActualTypeArguments()[0];
        assertAnnotatedType(annotatedType, typeParameterAnnotation, validAnnotation);
    }

    private void assertMethodTypeParameter(
            Method method,
            Class<? extends Annotation> typeParameterAnnotation,
            boolean validAnnotation) {
        TypeVariable<Method> typeVariable = method.getTypeParameters()[0];
        assertAnnotatedType(typeVariable, typeParameterAnnotation, validAnnotation);
    }

    private void assertMethodTypeUse(
            Method method,
            Class<? extends Annotation> typeParameterAnnotation,
            boolean validAnnotation) {
        AnnotatedType annotatedType = method.getAnnotatedReturnType();
        assertAnnotatedType(annotatedType, typeParameterAnnotation, validAnnotation);
    }

    @Test
    void reflectionTest() throws Exception {
        // field
        assertNull(cls.getDeclaredField("fieldClass").getAnnotation(classAnnotation));
        assertNotNull(cls.getDeclaredField("fieldRuntime").getAnnotation(runtimeAnnotation));

        // field generics type parameter
        assertGenericsFieldTypeParameter(cls.getDeclaredField("fieldGenericsTypeParameterClass"), classAnnotation, false);
        assertGenericsFieldTypeParameter(cls.getDeclaredField("fieldGenericsTypeParameterRuntime"), runtimeAnnotation, true);

        // field type use
        assertFieldTypeUse(cls.getDeclaredField("fieldTypeUseClass"), classAnnotation, false);
        assertFieldTypeUse(cls.getDeclaredField("fieldTypeUseRuntime"), runtimeAnnotation, true);

        // method
        assertNull(cls.getMethod("getMethodClass").getAnnotation(classAnnotation));
        assertNotNull(cls.getMethod("getMethodRuntime").getAnnotation(runtimeAnnotation));

        // method generics type parameter
        assertMethodGenericsTypeParameter(cls.getMethod("getMethodGenericsTypeParameterClass"), classAnnotation, false);
        assertMethodGenericsTypeParameter(cls.getMethod("getMethodGenericsTypeParameterRuntime"), runtimeAnnotation, true);

        // method type parameter
        assertMethodTypeParameter(cls.getMethod("getMethodTypeParameterClass"), classAnnotation, false);
        assertMethodTypeParameter(cls.getMethod("getMethodTypeParameterRuntime"), runtimeAnnotation, true);

        // method type use
        assertMethodTypeUse(cls.getMethod("getMethodTypeUseClass"), classAnnotation, false);
        assertMethodTypeUse(cls.getMethod("getMethodTypeUseRuntime"), runtimeAnnotation, true);
    }

    @Test
    void javassistTest() throws Exception {
        final ClassPool classPool = ClassPool.getDefault();
        final CtClass cc = classPool.get(cls.getName());

        // field
        assertNotNull(cc.getDeclaredField("fieldClass").getAnnotation(classAnnotation));
        assertNotNull(cc.getDeclaredField("fieldRuntime").getAnnotation(runtimeAnnotation));

        // field generics type parameter
        // https://www.javassist.org/html/javassist/bytecode/TypeAnnotationsAttribute.html
        FieldInfo fieldInfo;

        fieldInfo = cc.getDeclaredField("fieldGenericsTypeParameterClass").getFieldInfo();
        assertNotNull(fieldInfo.getAttribute(TypeAnnotationsAttribute.invisibleTag));
        assertNull(fieldInfo.getAttribute(TypeAnnotationsAttribute.visibleTag));

        fieldInfo = cc.getDeclaredField("fieldGenericsTypeParameterRuntime").getFieldInfo();
        assertNull(fieldInfo.getAttribute(TypeAnnotationsAttribute.invisibleTag));
        assertNotNull(fieldInfo.getAttribute(TypeAnnotationsAttribute.visibleTag));


        // field type use
        assertNotNull(cc.getDeclaredField("fieldTypeUseClass").getAnnotation(classAnnotation));
        assertNotNull(cc.getDeclaredField("fieldTypeUseRuntime").getAnnotation(runtimeAnnotation));

        // method
        // getMethod の第2引数は JVM の method descriptor
        final String stringDescriptor = "()Ljava/lang/String;";
        assertNotNull(cc.getMethod("getMethodClass", stringDescriptor).getAnnotation(classAnnotation));
        assertNotNull(cc.getMethod("getMethodRuntime", stringDescriptor).getAnnotation(runtimeAnnotation));

        // method generics type parameter
        final String listDescriptor = "()Ljava/util/List;";
        MethodInfo methodInfo;

        methodInfo = cc.getMethod("getMethodGenericsTypeParameterClass", listDescriptor).getMethodInfo();
        assertNotNull(methodInfo.getAttribute(TypeAnnotationsAttribute.invisibleTag));
        assertNull(methodInfo.getAttribute(TypeAnnotationsAttribute.visibleTag));

        methodInfo = cc.getMethod("getMethodGenericsTypeParameterRuntime", listDescriptor).getMethodInfo();
        assertNull(methodInfo.getAttribute(TypeAnnotationsAttribute.invisibleTag));
        assertNotNull(methodInfo.getAttribute(TypeAnnotationsAttribute.visibleTag));

        // method type parameter
        // 総称型の情報はコンパイルされると消えるため、method descriptorはObjectを指定
        final String objectDescriptor = "()Ljava/lang/Object;";

        methodInfo = cc.getMethod("getMethodTypeParameterClass", objectDescriptor).getMethodInfo();
        assertNotNull(methodInfo.getAttribute(TypeAnnotationsAttribute.invisibleTag));
        assertNull(methodInfo.getAttribute(TypeAnnotationsAttribute.visibleTag));

        methodInfo = cc.getMethod("getMethodTypeParameterRuntime", objectDescriptor).getMethodInfo();
        assertNull(methodInfo.getAttribute(TypeAnnotationsAttribute.invisibleTag));
        assertNotNull(methodInfo.getAttribute(TypeAnnotationsAttribute.visibleTag));

        // method type use
        methodInfo = cc.getMethod("getMethodTypeUseClass", stringDescriptor).getMethodInfo();
        assertNotNull(methodInfo.getAttribute(TypeAnnotationsAttribute.invisibleTag));
        assertNull(methodInfo.getAttribute(TypeAnnotationsAttribute.visibleTag));

        methodInfo = cc.getMethod("getMethodTypeUseRuntime", stringDescriptor).getMethodInfo();
        assertNull(methodInfo.getAttribute(TypeAnnotationsAttribute.invisibleTag));
        assertNotNull(methodInfo.getAttribute(TypeAnnotationsAttribute.visibleTag));
    }

}

結果

フィールドやメソッドに付与されたアノテーションについては、通常のリフレクションでは判定できない RetentionPolicy.CLASSアノテーションも、 CtFieldCtMethodgetAnnotation で判定できる。

戻り値は Object になるが、キャスト可能。パラメーターを持つアノテーションの場合に、パラメーターの値を取得できるかは未確認。

コード内では CtMethod#getMethod を使っているが、戻り値や引数の情報をmethod descriptorとして渡さないといけないので、通常は CtMethod#getMethods で配列で取得し、メソッド名で判定することになりそう。

一方、型パラメーターやメソッドの戻り値に付与されたアノテーションの場合、 CtField#getFieldInfoCtMethod#getMethodInfo で取得したクラスから、 getAttribute(TypeAnnotationsAttribute.invisibleTag)RetentionPolicy.CLASSアノテーションが、また getAttribute(TypeAnnotationsAttribute.visibleTag)RetentionPolicy.RUNTIMEアノテーションが付与されていることは判定できるが、アノテーションの型は取れなかった。

ただ、Javassistの挙動をきちんと把握していないので、うまくやれば別の方法で取れるかもかもしれない。 CtField#getTypeCtMethod#getParameterTypes あたりが怪しいが。

getAttribute の戻り値は javassist.bytecode.TypeAnnotationsAttribute だが、メソッドの戻り値に付与した場合、IDEデバッグ機能でインスタンス内を見てみると、 constPool/items/objects[0] の配列内にクラス名が文字列で保存されているため、どうにかできそうではある。

当初の目的であった、 lombok.NonNullorg.jetbrains.annotations.NotNull の判定はできそう。

振り返り

コード書いた後に、別件を調べていてふと Javaアノテーションメモ(Hishidama's Java annotation Memo) を見たら、 Javassistメモ(Hishidama's Javassist Memo)RetentionPolicy.CLASSJavassistで実行時に読み込めることが書いてあった。さすがです。

なぜ RetentionPolicy.CLASSアノテーションのデフォルトなんだろう。 RetentionPolicy.SOURCERetentionPolicy.RUNTIME の2択だったらわかりやすかったのになぁ。

Android版Google Chromeで、タブがグループ化されてまとめられるのを解除する

AndroidGoogle Chromeのタブが、最近のアップデートで勝手にグループ化されるようになった。

タップ数が増え、タブの切り替えが面倒になったので戻そうと思っても、設定にそれらしい項目がない。

ちょっと調べたところ、標準設定ではないが、戻す方法があったのでメモ。

2021/5/29 追記

Google Chrome v91より、以下の設定で使用しているフラグの一部が廃止され、タブのグリッドレイアウトは変更できなくなった模様。

また、それに伴いこの記事の設定を行っても、タブのグループ化を抑制できなくなった。

ひとまず、グループ化せずにタブを開く方法は見つかったので記事にした。

hepokon365.hatenablog.com

環境

AndroidGoogle Chrome v88.0.4324.152

問題

新しいタブを開くと、グループ化される。リンクを開くときにロングタップしても「新しいタブをグループで開く」となっており、グループ化せずに開くことができない。

f:id:hepokon365:20210211144604j:plainf:id:hepokon365:20210211144621j:plainf:id:hepokon365:20210211144728j:plain

対応

Google Chromeヘルプサイトにスレッドがあった。

How to remove the "Group Tabs" setting for Android? - Google Chrome Community

設定変更手順は以下。

  1. Google Chromeを開き、「検索語句またはウェブアドレスを入力」に chrome://flags を入力して開く
  2. 実験的機能のフラグ管理画面が開くので、画面上部の検索窓に「tab groups」を入力して検索
  3. 「Tab Groups」、「Tab Groups Continuation」、「Tab Groups UI Improvements」の3件と、それぞれに「Default」が選択されたセレクトボックスが表示されるので、タップしてすべて「Disabled」にし、右下の「Relaunch」ボタンをタップ
    f:id:hepokon365:20210211150612j:plain
    赤枠で囲った部分が対象
    • 後述するが、グリッドレイアウトも解除される模様。グリッドレイアウトを有効にしておきたい場合は、合わせて「Tab Grid Layout」を「Enabled」にしておく
  4. Google Chromeが再起動するが、設定は反映されないGoogle Chromeを完全に停止し、再起動すると反映される

無効化した3項目のうち、「Tab Groups」だけを無効化すればタブのグループ化を無効化できそうだが、何度か設定変更を試していると、反映がうまくいったりいかなかったりした。

おそらくGoogle Chromeが完全に停止していなかったためだと思うが、念のためすべて無効化している。

タブのグリッドレイアウトの有効化

前述の設定をすると、タブのグループ化は解除できたが、今度はタブがグリッドレイアウトではなく、以前のように横並びで表示されるようになってしまった。

f:id:hepokon365:20210211151416j:plain

タグのグリッドレイアウトのフラグが「Default」の状態では、タブグループを無効化するとグリッドレイアウトも無効になる模様。グリッドレイアウトはタブグループの一機能扱いなのかな?

タグのグリッドレイアウトの有効化は、タブグループの解除と同様にGoogle Chromechrome://flags 画面を開き、「tab grid」で検索して出てくる「Tab Grid Layout」を「Enabled」に変更。後も同様に、「Relaunch」ボタンをタップしてGoogle Chromeを完全に停止し、再起動する。

f:id:hepokon365:20210211155418j:plain

最終的に、タブグループを無効化しつつ、グリッドレイアウトを有効にした場合の chrome://flags は以下のようになる。

f:id:hepokon365:20210211155532j:plain

振り返り

このフラグ設定も、いつまで使えるか分からないので、UI/UXにかかわる機能は、普通に設定できるようにしてほしいなあ。

とりあえず、参考にしたスレッドにUpvoteしておいた。

JavaのユニットテストでLocalStackを使っているときに「Cannot find docker executable」が出たときの対応方法

JavaユニットテストLocalStack Java Utils v0.1.22を使ってAWSサービスのテストをしていたが、Windows + Docker Desktopの環境で Cannot find docker executable. というエラーが発生するようになった。

CLIからの docker コマンドは通るのに、Javaユニットテストでは失敗しており、結構ハマったので対応方法をメモ。

環境

Windows 10 64bit Pro, Docker Desktop v2.3.0.5 および v3.1.0, LocalStack Java Utils v0.1.22。

Docker DesktopはChocolateyを使い、 choco install docker-desktop でインストールした。

Docker Desktopは起動しており、 dockerdocker-compose コマンドでのDockerイメージ操作やコンテナ起動は問題なく行える前提。

問題

@RunWith(LocalstackTestRunner.class) や、 cloud.localstack.docker.Container.createLocalstackContainer でLocalStackコンテナを起動しようとすると、以下の例外が発生し、コンテナ起動に失敗する。

Cannot find docker executable.
java.lang.IllegalStateException: Cannot find docker executable.
    at cloud.localstack.docker.DockerExe.lambda$getDockerExeLocation$1(DockerExe.java:46)
    at java.util.Optional.orElseThrow(Optional.java:290)
    at cloud.localstack.docker.DockerExe.getDockerExeLocation(DockerExe.java:46)
    at cloud.localstack.docker.DockerExe.<init>(DockerExe.java:37)
    at cloud.localstack.docker.command.Command.<init>(Command.java:11)
    at cloud.localstack.docker.command.PullCommand.<init>(PullCommand.java:18)
    at cloud.localstack.docker.Container.createLocalstackContainer(Container.java:53)

原因

LocalStack Java Utilsの設定に記載があるが、 docker.exe の保存パスに制限がある模様。

NOTE: These utilities assume docker is installed in one of the default locations (C:\program files\docker\docker\resources\bin\docker.exe, C:\program files\docker\docker\resources\docker.exe, usr/local/bin/docker or usr/bin/docker). If your docker executable is in a different location, then use the DOCKER_LOCATION environment variable to specify it.

コマンドプロンプトwhere docker すると、以下のパスに保存されていた。

>where docker
C:\ProgramData\DockerDesktop\version-bin\docker
C:\ProgramData\DockerDesktop\version-bin\docker.exe

対応

環境変数 DOCKER_LOCATIONdocker.exe のパスを指定する。今回の場合、 C:\ProgramData\DockerDesktop\version-bin\docker.exe

IDECLIからテストを実行している場合は、それらを再起動するなどで環境変数を再読み込みさせると、例外は発生しなくなった。

LocalStack Java Utils v0.2系について

LocalStack Java Utilsはv0.2系が出ており、バージョンをv0.2.7にしてみたところ、発生しなかった。

LocalStack Java UtilsのGitリポジトリにタグ付けされていないので、ソース確認はしていないが、 docker.exe にPATHが通っていれば動作するようになったのかな?

振り返り

もともと動いていたのに、Docker Desktopのバージョンを上げたら動かなくなったので、 docker.exe のパスが変わったのかな?

余談だが、 LocalStack に対して、LocalStack Java Utilsのクラス内では Localstack なの、なんでだろう...