mike-neckのブログ

Java or Groovy or Swift or Golang

eclipseがメインのIDEに指定されているプロジェクトでIntelliJを使って開発する

職場のプロジェクトがメインで指定するIDEeclipseですが、IntelliJ IDEAが大好きなのでIntelliJ IDEAで仕事しています。

するとどうしても問題が出てきます。

  • importの順番が異なるために無駄なdiffが出てくる
  • コードスタイルが異なるのでレビューしづらい
  • インスペクションのレベルが違うのでレビューしづらい/チェックしづらい

importの順番

importの順番は気合で直します(嘘)

eclipseからコードスタイルフォーマットを出力してIntelliJで取り込んでもImportの順番は出力できないようです。

したがって下のQiitaの記事を参考に Code Style > Java > Impors > Import Layout にてImportの順番をeclipseのそれに合わせます。

qiita.com

しかし、時折ですが、eclipseで書いたコードのimport順がおかしな場合もあります。そのようなイレギュラーケースは気合で直していたりします。

コードスタイル

上記の通りeclipseからコードフォーマット設定をxmlで出力して、取り込むことで対応します。しかしeclipseのデフォルト設定で開発している場合などはxmlの情報が少なくなり、かなり変なスタイルが取り込まれることもあったり、そもそもコードスタイルをxmlで出力していないということもあります(.settings/org.eclipse.jdt.core.prefs で管理している場合など)。

この場合はeclipseのフォーマッタをターミナルから起動して修正します。

$ ~/path/to/Eclipse.app/Contents/MacOS/eclipse -application org.eclipse.jdt.core.JavaCodeFormatter -verbose -config ~/path/to/setting/org.eclipse.jdt.core.prefs ~/path/to/project/src/main/java/com/example/Foo.java

なお、以前はこれをgit statusと組み合わせてgit commitのフックで実行していましたが、IntelliJのchangelistが壊れるのでやめて、次のスクリプトを利用してpushの前に実行するようにしています。

#!/usr/bin/env bash

project=~/path/to/project
eclipse=~/path/to/Eclipse.app/Contents/MacOS/eclipse

cd ${project}

head=`git log --oneline --decorate | grep HEAD | awk '{printf $1}'`
last=`git log --oneline -decorate` | grep origin | awk '{printf $1}'`
java_files=`git diff --name-only ${head} ${last} | awk -v dir=${project} '{print dir"/"$1}' awk -F\| 'system("test -f " $1)==0 { print $1 }'`

${eclipse} -application org.eclipse.jdt.core.JavaCodeFormatter -verbose -config ~/path/to/setting/org.eclipse.jdt.core.prefs ${java_files}

git add ${java_files}
git commit -m 'apply eclipse formatter'

コードインスペクション

これが一番困っています。eclipseIntelliJで警告になるレベルが異なる(IntelliJでは設定可能)ようです。下手に警告がちらつくと重要な警告を発見できなかったり、レビューに集中できなかったりと弊害が大きいです。

いろいろと方法を考えたのですが、とりあえずecjでコンパイルするのが一番よさそうです。

apply plugin: 'java'
configurations {
  eclipse
}
dependencies {
  eclipse 'org.eclipse.jdt.core.compiler:ecj:4.4'
  // 他省略
}

task ecj(type: JavaExec) {
    main = 'org.eclipse.jdt.internal.compiler.batch.Main'
    classpath configurations.eclipse.asPath
    args '-encoding', 'utf8'
    args '-source', '8'
    args '-d', "$buildDir/ecj"
    args '-target', '8'
    args '-cp', configurations.compileClasspath.asPath
    args sourceSets.main.allJava
}

このようなタスクを記述した ecj.gradle ファイルを作って、 ./gradlew -b ecj.gradle ecj を起動することで警告を探してeclipseに合わせたコードを記述します。

出力例

15. WARNING in /path/to/project/src/main/java/org/mikeneck/util/Last.java (at line 143)
        .<Last<T>>map(Candidate::new)
                      ^^^^^^^^^^^^^^
Type safety: The constructor Candidate(Object) belongs to the raw type Candidate. References to generic type Candidate<T> should be parameterized
----------
16. WARNING in /path/to/project/src/main/java/org/mikeneck/util/Last.java (at line 147)
        @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Unsupported @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
----------

例えば、上の出力例ではIntelliJでは Optional をフィールドまたはパラメーターに利用すると警告が出てくるので @SuppressWarnings("OptionalUsedAsFieldOrParameterType") をつけますが、eclipseでは OptionalUsedAsFieldOrParameterType に対応していないので警告になっています(Optional をフィールドまたはパラメーターに使っても警告が出ないのだろうか…詳しい人教えて…)。またeclipseでは推論可能な型パラメーターを省略すると警告が出てしまうようです(Foo<T> のコンストラクタを Foo::new と書くと警告が出るが、 Foo<T>::new と書くと警告が出ない。一方IntelliJでは推論可能な型パラメーターの省略を推奨している)。おそらく言語仕様で曖昧に解釈できる箇所をecjでは安全側に倒しているというところでしょうか…。

以上

sdkmanでJavaのインストール

結構前のことですが、 sdkmanJavaをインストールできるようになったとのことで、Javaの更新をsdkmanでやるようにしてみた。

sdk list java

まず、どのバージョンのJavaが利用できるのか確認してみます。

$ sdk list java

================================================================================
Available Java Versions
================================================================================
     8u111                                                                         
     7u79                                                                          
     6u65                                                                          

================================================================================
+ - local version
* - installed
> - currently in use
================================================================================

sdk install java 7u79

Java7をインストールしてみます。

$ sdk install 7u79

Oracle requires that you agree with the Oracle Binary Code License Agreement
prior to installation. The license agreement can be found at:

  http://www.oracle.com/technetwork/java/javase/terms/license/index.html

Do you agree to the terms of this agreement? (Y/n): 

まず、ライセンスアグリーメントが求められます。ここは y でよいです。

Downloading: java 7u79

In progress...

######################################################################## 100.0%
Binary validation passed...
We will be needing super powers...
Password:

Repackaging java 7u79...
Attaching to the DMG...
Mounting DMG as Volume...
Volume(s) mounted successfully
Installing PKG inside DMG...
installer: Package name is JDK 7 Update 79
installer: Installing at base path /
installer: The install was successful.
Copy JDK Home to temp folder...
Preparing archive...
Unmounting volume...
"/Volumes/JDK 7 Update 79" unmounted successfully.
Done repackaging...
Cleaning up cookie...

Installing: java 7u79
Done installing!

Do you want java 7u79 to be set as default? (Y/n): n

Java7はあまり使うものではないのでデフォルトは n にしておきます。


注意 - Javaのインストール先と JAVA_HOME の場所

sdkJavaをインストールする場合は以下の点に注意が必要です(Macでのみ確認)。

  • Javaのインストール先がいつもどおりの /Library/Java/JavaVirtualMachines に加えて /Users/my-home/.sdkman/candidates/java にもコピーがインストールされます。
  • sdkスクリプトによって JAVA_HOME/Library/Java/JavaVirtualMachines ではなく、 /Users/my-home/.sdkman/candidates/java の方に向きます。
    • したがって、 JAVA_HOME/usr/libexec/java_home を用いて設定/切り替えしていた場合などは JAVA_HOMEPATH が指しているパスが異なるという気持ち悪い状態になるかもしれません。

lombokで生成されたgetterにアノテーションを付与する方法と問題

lombokの既存バグにハマったので、その一時的な回避策のメモ。

なお、この情報は以下の環境でおこなった。

  • Java - 1.8.0_102
  • lombok - 1.16.12

なお、issueはこちら。

github.com


lombokで生成されるgetter(あるいはsetter)にアノテーションを付与する方法

GetteronMethod に特殊な記述法でついてほしいアノテーションを渡す。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Bool {
  @Getter(onMethod = @__({ @XmlAttribute }))
  private boolean value;
}

問題 - onMethod で渡すアノテーションにプロパティを設定するとコンパイルで落ちる

lombokのonXのドキュメントを読むかぎり、 onMethod に渡すアノテーションにプロパティが設定されていてもうまくコンパイルされるようである。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Int {
  @Getter(onMethod = @__({ @XmlAttribute(name = "int") }))
  private int value;
}

ところが、これをコンパイルすると次のようなエラーが発生してコンパイルが失敗する。

/path/to/project/src/main/java/foo/bar/Int.java:13: エラー: シンボルを見つけられません
    @Getter(onMethod = @__({ @XmlAttribute(name = "int") }))
                        ^
  シンボル:   クラス __
  場所: クラス Int
/path/to/project/src/main/java/foo/bar/Int.java:13: エラー: 注釈@<any?>に重複y?>'があります。
    @Getter(onMethod = @__({@XmlAttribute(name = "int")}))
                                          ^

回避策 - getterを手で書く

回避策がlombokを入れた意味があまりなくなってしまう方法で非常に残念である。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Int {
  private int value;
  @XmlAttribute(name = "int")
  public int getValue() {
    return value;
  }
}

JUnit5入門(6) - JUnit5とGuiceでDIが必要なオブジェクトを構築してテストする

前回の続き

これまでの内容をまとめて、DIが必要なオブジェクトをテストするやり方(のひとつ)をやってみる。DIにはGuiceを用いる。


環境

JDK|1.8.0_102 junit-jupiter-api|5.0.0-M3 junit-platform-engine|1.0.0-M3 guice|4.1.0


テスト対象のインターフェース

次のようなインターフェース/実装クラスをテストする。

インターフェース
public interface DateService {

  Locale getLocale();

  String date(int year, int month, int day);

  String today();

  String time(int hour, int minutes);

  String now();

  ZoneId getZone();
}
実装クラス
public class DateServiceImpl implements DateService {

  private final ZoneId zone;
  private final DateTimeFormatter dateFormat;
  private final DateTimeFormatter timeFormat;
  private final Locale locale;

  @Inject
  public DateServiceImpl(
      ZoneId zone
      , @Date DateTimeFormatter dateFormat
      , @Time DateTimeFormatter timeFormat
      , Locale locale
  ) {
    this.zone = zone;
    this.dateFormat = dateFormat.withLocale(locale);
    this.timeFormat = timeFormat.withLocale(locale);
    this.locale = locale;
  }

  @Override
  public Locale getLocale() {
    return locale;
  }
  @Override
  public String date(int year, int month, int day) {
    return LocalDate.of(year, month, day).format(dateFormat);
  }
  @Override
  public String today() {
    return LocalDate.now(zone).format(dateFormat);
  }
  @Override
  public String time(int hour, int minutes) {
    return LocalTime.of(hour, minutes).format(timeFormat);
  }
  @Override
  public String now() {
    return LocalDateTime.now(zone).format(timeFormat);
  }
  @Override
  public ZoneId getZone() {
    return zone;
  }
}

@Date@Timeバインディングアノテーション


テスト

guiceでオブジェクトを構築する ExtensionDateServiceGuiceExtension と名前をつけるとして、テストは次のような感じにする。

@DisplayName("DateServiceのテスト - 実装はDateServiceImpl")
public class DateServiceTest {
  @Nested
  @DisplayName("ロケールはen日付はMMM/d/uu,時刻はhh:mm,America/Los_Angeles")
  @Lang("en")
  @Date.Format("MMM/d/uu")
  @Time.Format("hh:mm")
  @Zone("America/Los_Angeles")
  @ExtendWith({ DateServiceGuiceExtension.class })
  class LosAngelesTest {
    @Test
    @DisplayName("dateの最初の3文字は英字")
    void first3CharactersIsAlphabet(DateService service) {
      final String today = service.today();
      assertTrue(today.substring(0, 3).matches("\\p{Alpha}{3}"));
    }
    @Test
    @DisplayName("時刻は必ず2文字ある")
    void hourHasTwoCharacters(DateService service) {
      final String now = service.now();
      assertEquals(2, now.split(":")[0].length());
    }
    @Test
    @DisplayName("タイムゾーンはAsia/Tokyoではない")
    void timeZoneIsNotAsiaTokyo(DateService service) {
      final ZoneId zone = service.getZone();
      assertNotEquals(of("Asia/Tokyo"), zone);
    }
  }
  @Nested
  @DisplayName("ロケール指定なし(デフォルト)/Dateの指定なし(uuuu/MM/dd)/Timeの指定なし(HH:mm:ss.SSS)/Zoneの指定なし(UTC)")
  @ExtendWith({ DateServiceGuiceExtension.class })
  class DefaultInjection {
    @Test
    @DisplayName("日付のフォーマットの確認")
    void validateDateFormat(DateService service) {
      assertAll(
          () -> assertEquals("2017/01/01", service.date(2017, 1, 1))
          , () -> assertEquals("2016/11/11", service.date(2016, 11, 11))
      );
    }
    @Test
    @DisplayName("時刻のフォーマットの確認")
    void validateTimeFormat(DateService service) {
      assertAll(
          () -> assertEquals("04:00:00.000", service.time(4, 0))
          , () -> assertEquals("13:13:00.000", service.time(13, 13))
      );
    }
  }
}

Extension の実装

ExtensionBeforeAllCallbackAfterAllCallbackParameterResolver を実装します。

  • BeforeAllCallback にてテストクラスの情報から必要な Injector を生成します。
  • ParameterResolver でテストが必要としているオブジェクトを解決します。
  • AfterAllCallback にて Injector を破棄します。
BeforeAllCallback/AfterAllCallback の実装
public abstract class GuiceExtension implements
    ParameterResolver
    , BeforeAllCallback
    , AfterAllCallback
{
  private static final Namespace GUICE = Namespace.create(com.example.guice.GuiceExtension.class);
  private static Store getStore(ExtensionContext context) {
    return context.getStore(GUICE);
  }

  abstract protected Module prepareModule(
      @NotNull Class<?> testClass
      , @NotNull Set<String> tags
      , @SuppressWarnings("OptionalUsedAsFieldOrParameterType")
        @NotNull 
            Optional<AnnotatedElement> annotations
  );

  @Override
  public void beforeAll(ContainerExtensionContext context) throws Exception {
    final Store store = getStore(context);
    final Optional<AnnotatedElement> annotations = context.getElement();
    context.getTestClass()
        .map(c -> prepareModule(c, context.getTags(), annotations))
        .map(Guice::createInjector)
        .ifPresent(i -> store.put(Injector.class, i));
  }

  @Override
  public void afterAll(ContainerExtensionContext context) throws Exception {
    getStore(context).remove(Injector.class, Injector.class);
  }
}

このクラスを継承するクラスは、テストクラスに与えられたメタ情報(タグ/アノテーション)情報を元に、 Module を作ります。このクラスは Module から Injector を作りコンテキストに保持します。JUnit5のドキュメントによると Extensionインスタンスはテスト実行中に1つしか作られないので、テストの状態は Extension のフィールドには持たせずに Context が保持する Store に保持させるようにします。

ParameterResolver の実装

Store からテストクラスをキーにして Injector を取得し、さらにパラメーターの型、パラメーターに付与されたバインディングアノテーションをキーにしてインスタンスを取得します。

@Override
public boolean supports(
    ParameterContext parameterContext
    , ExtensionContext extensionContext
) throws ParameterResolutionException {
  final Parameter parameter = parameterContext.getParameter();
  final Class<?> paramType = parameter.getType();
  final Stream<? extends Key<?>> keyStream = Arrays
      .stream(parameter.getAnnotations())
      .map(a -> Key.get(paramType, a));
  return extensionContext.getTestClass()
      .map(c -> getStore(extensionContext).get(c, Injector.class))
      .map(Injector::getAllBindings)
      // a -> (a,a)
      .map(Pair.createPair(Function.identity()))
      // (a -> Bool) -> (b -> Bool) -> (a,b) -> Bool
      .filter(Pair.orFilterPair(
          b -> b.containsKey(Key.get(paramType))
          , b -> keyStream.anyMatch(b::containsKey)
      )).isPresent();
}
@Override
public Object resolve(
    ParameterContext parameterContext
    , ExtensionContext extensionContext
) throws ParameterResolutionException {
  final Parameter parameter = parameterContext.getParameter();
  final Class<?> paramType = parameter.getType();
  final Annotation[] annotations = parameter.getAnnotations();
  return extensionContext.getTestClass()
      .map(c -> getStore(extensionContext).get(c, Injector.class))
      .map(Injector::getAllBindings)
      // a -> (a, b)
      .map(Pair.createPair(b -> b.get(Key.get(paramType))))
      // (a, b) -> (b -> Maybe b) -> (a, Maybe b)
      .map(Pair.mapPair(Optional::ofNullable))
      // (a, Maybe b) -> (b -> c) -> (a, Maybe c)
      .map(Pair.mapPair(o -> o.map(Binding::getProvider)))
      // (a, Maybe c) -> (c -> Maybe t) -> (a, Maybe (Maybe t))
      .map(Pair.mapPair(o -> o.map(p -> (Object) p.get())))
      .map(Pair.mapPair(o -> o.map(Optional::of)))
      // (a, Maybe (Maybe t)) -> (a -> Maybe t -> Maybe t) -> Maybe t
      .flatMap(Pair.transformPair((m, o) -> o.orElseGet(
          // returns Optional<Object>
          findBindingByClassAndAnnotation(paramType, annotations, m))))
      .orElseThrow(() -> new ParameterResolutionException(
          String.format(
              "System cannot find binding for type: [%s] parameter index: %d"
              , parameter.getType()
              , parameterContext.getIndex())
      ));
}

これでテストを実行すると次のようになります。

f:id:mike_neck:20170103205333p:plain


なお、サンプルコードはこちらにあります。

github.com