ぺんぎんらぼ

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です。たまに絵を描きます。

お笑いとマンガ好きなしょぼしょぼWeb系エンジニアの日記です

Lombok - アノテーション一覧

定型的で冗長なコードの実装を省くことができるLombok
@Getterや@Setter、@Dataアノテーションぐらいしか使ったことがない人も多いと思いますが、これら以外にも便利なアノテーションが用意されています。

個々のアノテーションの詳細な説明は別記事で取り上げるとして、ここではLombokで用意されているアノテーションを一覧します。

アノテーション 指定場所 作用
@Getter クラス
フィールド
デフォルトのゲッターメソッドを自動生成します。
@Setter クラス
フィールド
デフォルトのセッターメソッドを自動生成します。
@NoArgsConstructor クラス 引数なしのコンストラクタを自動生成します。
@RequiredArgsConstructor クラス finalフィールドを引数で初期化するコンストラクタ、ファクトリメソッドを自動生成します。
@AllArgsConstructor クラス すべてのフィールドを引数で初期化するコンストラクタ、ファクトリメソッドを自動生成します。
@EqualsAndHashCode クラス すべてのフィールドを使用してequalsメソッドとhashCodeメソッドを自動生成します。
@ToString クラス すべてのフィールドを1つの文字列表現に変換するtoStringメソッドを自動生成します。
@Data クラス クラスに@Getter、@Setter、@RequiredArgsConstructor、@EqualsAndHashCode、@ToStringを指定したことと同じ作用になります。
@Value クラス 不変クラスを作成します。クラスと全てのフィールドがfinalで修飾され、@Getter、@RequiredArgsConstructor、@EqualsAndHashCode、@ToStringを指定したことと同じ作用になります。
@Builder クラス すべてのフィールドを引数で初期化するコンストラクタと、ビルダーパターンによる値の初期化のためのメソッド、ビルダークラスを自動生成します。
@With フィールド 引数の値でフィールドを初期化した新しいオブジェクトを返すメソッドを自動生成します。不変クラスのセッターのようなメソッドを生成する機能です。
@SneakyThrows メソッド メソッド内で発生する検査例外を非検査例外であるRuntimeExceptionにラップしてリスローするコードを自動生成します。
@Synchronized メソッド メソッド全体をsynchronizedブロックで囲むコードと、synchronizedで使用するロックオブジェクトの宣言を自動生成します。
@NonNull フィールド
引数
指定したフィールド、引数にnullを指定すると、NullPointerExceptionをスローするコードを自動生成します。
@Cleanup 変数 I/O系などのclose処理が必要な変数に指定することで、finallyでclose処理を呼び出すtryブロックで囲むコードを自動生成します。
@Log クラス Java標準Loggerのロガーインスタンスを保持するフィールドを自動生成します。
@CommonsLog クラス Apache Commons Loggingのロガーインスタンスを保持するフィールドを自動生成します。
@Flogger クラス Fluent Loggerのロガーインスタンスを保持するフィールドを自動生成します。
@JBossLog クラス JBoss Loggingのロガーインスタンスを保持するフィールドを自動生成します。
@Log4j クラス Apache log4jのロガーインスタンスを保持するフィールドを自動生成します。
@Log4j2 クラス Apache Log4j 2のロガーインスタンスを保持するフィールドを自動生成します。
@Slf4j クラス SLF4Jのロガーインスタンスを保持するフィールドを自動生成します。
@XSlf4j クラス SLF4Jの拡張ロガー(XLogger)インスタンスを保持するフィールドを自動生成します。
@CustomLog クラス Lombokの設定ファイルで指定されたロガーインスタンスを保持するフィールドを自動生成します。

@Getter

デフォルトのゲッターメソッドを自動生成します。

実装例

@Getter
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public long getId() {
        return this.id;
    }

    public String getDepartment() {
        return this.department;
    }
}

@Setter

デフォルトのセッターメソッドを自動生成します。
finalフィールドのセッターは生成されません。

実装例

@Setter
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public void setDepartment(final String department) {
        this.department = department;
    }
}

@NoArgsConstructor

引数なしのコンストラクタを自動生成します。

実装例

@NoArgsConstructor
public class Employee {
    private long id;
    private String department;
}

展開イメージ

public class Employee {
    private long id;
    private String department;

    public Employee() {
    }
}

@RequiredArgsConstructor

finalフィールドを引数で初期化するコンストラクタ、ファクトリメソッドを自動生成します。

実装例

@RequiredArgsConstructor
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public Employee(final long id) {
        this.id = id;
    }
}

@AllArgsConstructor

すべてのフィールドを引数で初期化するコンストラクタ、ファクトリメソッドを自動生成します。

実装例

@AllArgsConstructor
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public Employee(final long id, final String department) {
        this.id = id;
        this.department = department;
    }
}

@EqualsAndHashCode

すべてのフィールドを使用してequalsメソッドとhashCodeメソッドを自動生成します。

実装例

@EqualsAndHashCode
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    @java.lang.Override
    public boolean equals(final java.lang.Object o) {
        if (o == this) return true;
        if (!(o instanceof Employee)) return false;
        final Employee other = (Employee) o;
        if (!other.canEqual((java.lang.Object) this)) return false;
        if (this.id != other.id) return false;
        final java.lang.Object this$department = this.department;
        final java.lang.Object other$department = other.department;
        if (this$department == null ? other$department != null : !this$department.equals(other$department)) return false;
        return true;
    }

    protected boolean canEqual(final java.lang.Object other) {
        return other instanceof Employee;
    }

    @java.lang.Override
    public int hashCode() {
        final int PRIME = 59;
        int result = 1;
        final long $id = this.id;
        result = result * PRIME + (int) ($id >>> 32 ^ $id);
        final java.lang.Object $department = this.department;
        result = result * PRIME + ($department == null ? 43 : $department.hashCode());
        return result;
    }
}

@ToString

すべてのフィールドを1つの文字列表現に変換するtoStringメソッドを自動生成します。

実装例

@ToString
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    @java.lang.Override
    public java.lang.String toString() {
        return "Employee(id=" + this.id + ", department=" + this.department + ")";
    }
}

@Data

クラスに@Getter、@Setter、@RequiredArgsConstructor、@EqualsAndHashCode、@ToStringを指定したことと同じ作用になります。

実装例

@Data
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public Employee(final long id) {
        this.id = id;
    }

    public long getId() {
        return this.id;
    }

    public String getDepartment() {
        return this.department;
    }

    public void setDepartment(final String department) {
        this.department = department;
    }

    @java.lang.Override
    public boolean equals(final java.lang.Object o) {
        if (o == this) return true;
        if (!(o instanceof Employee)) return false;
        final Employee other = (Employee) o;
        if (!other.canEqual((java.lang.Object) this)) return false;
        if (this.getId() != other.getId()) return false;
        final java.lang.Object this$department = this.getDepartment();
        final java.lang.Object other$department = other.getDepartment();
        if (this$department == null ? other$department != null : !this$department.equals(other$department)) return false;
        return true;
    }

    protected boolean canEqual(final java.lang.Object other) {
        return other instanceof Employee;
    }

    @java.lang.Override
    public int hashCode() {
        final int PRIME = 59;
        int result = 1;
        final long $id = this.getId();
        result = result * PRIME + (int) ($id >>> 32 ^ $id);
        final java.lang.Object $department = this.getDepartment();
        result = result * PRIME + ($department == null ? 43 : $department.hashCode());
        return result;
    }

    @java.lang.Override
    public java.lang.String toString() {
        return "Employee(id=" + this.getId() + ", department=" + this.getDepartment() + ")";
    }
}

@Value

不変クラスを作成します。クラスと全てのフィールドがfinalで修飾され、@Getter、@AllArgsConstructor、@EqualsAndHashCode、@ToStringを指定したことと同じ作用になります。

実装例

@Value
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public final class Employee {
    private final long id;
    private final String department;

    public Employee(final long id, final String department) {
        this.id = id;
        this.department = department;
    }

    public long getId() {
        return this.id;
    }

    public String getDepartment() {
        return this.department;
    }

    @java.lang.Override
    public boolean equals(final java.lang.Object o) {
        if (o == this) return true;
        if (!(o instanceof Employee)) return false;
        final Employee other = (Employee) o;
        if (this.getId() != other.getId()) return false;
        final java.lang.Object this$department = this.getDepartment();
        final java.lang.Object other$department = other.getDepartment();
        if (this$department == null ? other$department != null : !this$department.equals(other$department)) return false;
        return true;
    }

    @java.lang.Override
    public int hashCode() {
        final int PRIME = 59;
        int result = 1;
        final long $id = this.getId();
        result = result * PRIME + (int) ($id >>> 32 ^ $id);
        final java.lang.Object $department = this.getDepartment();
        result = result * PRIME + ($department == null ? 43 : $department.hashCode());
        return result;
    }

    @java.lang.Override
    public java.lang.String toString() {
        return "Employee(id=" + this.getId() + ", department=" + this.getDepartment() + ")";
    }
}

@Builder

すべてのフィールドを引数で初期化するコンストラクタと、ビルダーパターンによる値の初期化のためのメソッド、ビルダークラスを自動生成します。

実装例

@Builder
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    Employee(final long id, final String department) {
        this.id = id;
        this.department = department;
    }


    public static class EmployeeBuilder {
        private long id;
        private String department;

        EmployeeBuilder() {
        }

        public Employee.EmployeeBuilder id(final long id) {
            this.id = id;
            return this;
        }

        public Employee.EmployeeBuilder department(final String department) {
            this.department = department;
            return this;
        }

        public Employee build() {
            return new Employee(this.id, this.department);
        }

        @java.lang.Override
        public java.lang.String toString() {
            return "Employee.EmployeeBuilder(id=" + this.id + ", department=" + this.department + ")";
        }
    }

    public static Employee.EmployeeBuilder builder() {
        return new Employee.EmployeeBuilder();
    }
}

@With

引数の値でフィールドを初期化した新しいオブジェクトを返すメソッドを自動生成します。不変クラスのセッターのようなメソッドを生成する機能です。

実装例

@With
@AllArgsConstructor
public class Employee {
    private final long id;
    private String department;
}

展開イメージ

public class Employee {
    private final long id;
    private String department;

    public Employee withId(final long id) {
        return this.id == id ? this : new Employee(id, this.department);
    }

    public Employee withDepartment(final String department) {
        return this.department == department ? this : new Employee(this.id, department);
    }

    public Employee(final long id, final String department) {
        this.id = id;
        this.department = department;
    }
}

@SneakyThrows

メソッド内で発生する検査例外を非検査例外であるRuntimeExceptionにラップしてリスローするコードを自動生成します。

実装例

public class EmployeeBusiness {
    @SneakyThrows
    public static byte[] toUTF8(String source) {
        return source.getBytes("utf-8");
    }
}

展開イメージ

public class EmployeeBusiness {
    public static byte[] toUTF8(String source) {
        try {
            return source.getBytes("utf-8");
        } catch (final java.lang.Throwable $ex) {
            throw lombok.Lombok.sneakyThrow($ex);
        }
    }
}

@Synchronized

メソッド全体をsynchronizedブロックで囲むコードと、synchronizedで使用するロックオブジェクトの宣言を自動生成します。

実装例

public class EmployeeBusiness {
    private static List<String> cacheList;

    @Synchronized
    public static List<String> getCache() {
        if (cacheList == null) {
            cacheList = new ArrayList<>();
        }
        return cacheList;
    }
}

展開イメージ

public class EmployeeBusiness {
    private static final java.lang.Object $LOCK = new java.lang.Object[0];

    private static List<String> cacheList;

    public static List<String> getCache() {
        synchronized (EmployeeBusiness.$LOCK) {
            if (cacheList == null) {
                cacheList = new ArrayList<>();
            }
            return cacheList;
        }
    }
}

@NonNull

指定したフィールド、引数にnullを指定すると、NullPointerExceptionをスローするコードを自動生成します。

実装例

public class EmployeeBusiness {
    public static byte[] toUTF8(@NonNull String source) {
        return source.getBytes(StandardCharsets.UTF_8);
    }
}

展開イメージ

public class EmployeeBusiness {
    public static byte[] toUTF8(@NonNull String source) {
        if (source == null) {
            throw new java.lang.NullPointerException("source is marked non-null but is null");
        }
        return source.getBytes(StandardCharsets.UTF_8);
    }
}

@Cleanup

I/O系などのclose処理が必要な変数に指定することで、finallyでclose処理を呼び出すtryブロックで囲むコードを自動生成します。

実装例

public class EmployeeBusiness {
    public static void writeBytes(Path path, byte[] bytes) throws IOException {
        @Cleanup
        OutputStream out = Files.newOutputStream(path);
        out.write(bytes);
    }
}

展開イメージ

public class EmployeeBusiness {
    public static void writeBytes(Path path, byte[] bytes) throws IOException {
        OutputStream out = Files.newOutputStream(path);
        try {
            out.write(bytes);
        } finally {
            if (java.util.Collections.singletonList(out).get(0) != null) {
                out.close();
            }
        }
    }
}

@Log

Java標準Loggerのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@Log
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final java.util.logging.Logger log = java.util.logging.Logger.getLogger(EmployeeBusiness.class.getName());
}

@CommonsLog

Apache Commons Loggingのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@CommonsLog
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.apache.commons.logging.Log log = org.apache.commons.logging.LogFactory.getLog(EmployeeBusiness.class);
}

@Flogger

Fluent Loggerのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@Flogger
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final com.google.common.flogger.FluentLogger log = com.google.common.flogger.FluentLogger.forEnclosingClass();
}

@JBossLog

JBoss Loggingのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@JBossLog
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.jboss.logging.Logger log = org.jboss.logging.Logger.getLogger(EmployeeBusiness.class);
}

@Log4j

Apache log4jのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@Log4j
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.apache.log4j.Logger log = org.apache.log4j.Logger.getLogger(EmployeeBusiness.class);
}

@Log4j2

Apache Log4j 2のロガーインスタンスを保持するフィールドを自動生成します。

実装例

public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.apache.logging.log4j.Logger log = org.apache.logging.log4j.LogManager.getLogger(EmployeeBusiness.class);
}

@Slf4j

SLF4Jのロガーインスタンスを保持するフィールドを自動生成します。

実装例

@Slf4j
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(EmployeeBusiness.class);
}

@XSlf4j

SLF4Jの拡張ロガー(XLogger)インスタンスを保持するフィールドを自動生成します。

実装例

@XSlf4j
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final org.slf4j.ext.XLogger log = org.slf4j.ext.XLoggerFactory.getXLogger(EmployeeBusiness.class);
}

@CustomLog

Lombokの設定ファイルで指定されたロガーインスタンスを保持するフィールドを自動生成します。

実装例

lombok.configのlombok.log.custom.declarationにロガーを取得するメソッドを記述します。

lombok.log.custom.declaration=penguin.log.LoggerFactory.getLogger(NAME)
@CustomLog
public class EmployeeBusiness {
}

展開イメージ

public class EmployeeBusiness {
    private static final penguin.log.LoggerFactory log = penguin.log.LoggerFactory.getLogger(EmployeeBusiness.class.getName());
}
hide index [:contents]

なぜequalsとhashCodeの両方をオーバーライドする必要があるのか

Javaで、データを格納するためのBeanクラスを作成したときに、ほとんどの場合、getter、setterも併せて実装します。
それとは別に、コンストラクタやequalsメソッド、hashCodeメソッドも実装するかもしれません。
今回は、このequalsメソッド、hashCodeメソッドのお話です。

はじめに、皆さん、このequalsメソッドとhashCodeメソッドの処理内容ってご存じでしょうか?

equalsメソッド

equalsメソッドは値の同値性を検証するものです。

String型オブジェクトで、同一の文字列であることを検証するときにequalsメソッドを使いますよね。

「==」演算子オブジェクトの同一性を検証するもので、「equals」メソッドは値の同値性を検証するものです。なので、Stringの文字列が同値であることを検証するのであれば、equalsメソッドを使用する必要があります。

        String s1 = new String("matsuki");
        String s2 = new String("matsuki");

        System.out.println(s1 == s2);      // 別オブジェクトなのでfalseになる
        System.out.println(s1.equals(s2)); // 同一の値なのでtrueになる

hashCodeメソッド

hashCodeメソッドはオブジェクトのハッシュ値を求めるものです。
ハッシュ値とは、オブジェクトの内容を基に算出された数値です。

ハッシュ値は次のような性質があります。

  • 元となる値が同一である場合、ハッシュ値少なからず同一の値になる
  • 元となる値が不一致である場合、ハッシュ値同じ値になるかもしれないし、別の値になるかもしれない

この特性から、断定的に言えることは、ハッシュ値が不一致の場合、元の値も不一致である、ということです。

hashCodeメソッドを直接使うことはあまりありませんが、Javaでは、2つのオブジェクトの同値性を検証するときに、hashCodeメソッドとequalsメソッドの2つが使われるケースがあります。

HashSetの「Hash」はハッシュ値の「Hash」

では、hashCodeメソッドとequalsメソッドが必要になるケースをJavaの標準ライブラリであるHashSetクラスを例に説明します。

HashSetクラスは、オブジェクトとともに、オブジェクトごとのハッシュ値を保持しています。

f:id:penguinlabo:20210511232457p:plain

オブジェクトの内容が同一の場合は、ハッシュ値必ず同一になりますが、見ての通り、オブジェクトの値が同一でない場合も、ハッシュ値同一になる場合があります

このHashSetに、新しいオブジェクトを追加するとします。HashSetは、値の重複は許さないので、初めに追加するオブジェクトと同じ値を持つオブジェクトが格納済みか検索します。

f:id:penguinlabo:20210511232931p:plain

検索方法としては、追加しようとしているオブジェクトのハッシュ値をhashCodeメソッドで取得します。
HashSetに格納されているオブジェクトは、ハッシュ値をすでに持っているので、追加するオブジェクトのハッシュ値とHashSetに格納されているハッシュ値を比較します。
ポイントは、hashCodeメソッドの呼び出しは、追加しようとしているオブジェクトで1回だけ呼び出されるということです。ハッシュ値の計算は1回だけです。

f:id:penguinlabo:20210511233246p:plain

ハッシュ値が一致しても値が一致するとは限らないので、ハッシュ値が一致したオブジェクトと追加しようとするオブジェクトの同値性をequalsメソッドで検証します。

なぜ、はじめにハッシュ値を比較するのか

この比較方法を見て、「ハッシュ値の比較なんてせず、はじめからequalsメソッドで比較すればいいのに」と思う人もいるかもしれません。

しかし、equalsメソッドはオブジェクトの中身のすべてが同一であることを検証するので、実行コストがかかる処理です。
それに対し、ハッシュ値の比較は、int値を比較するだけなので、極めて実行コストが低いのです。

実行コストの低い比較で、ある程度、ふるいにかけておいて、一致する可能性があるものを絞り込んだうえでequalsメソッドで同値性の検証をすることで、効率的に処理をしているのです。

試しに100万個のオブジェクトが格納されているHashSetに、新たなオブジェクトを追加するパフォーマンスを測定してみました。

hashCodeメソッドなし hashCodeメソッドあり
414ミリ秒 170ミリ秒

hashCodeメソッドがない場合は、ある場合と比較して2倍以上の処理時間がかかっています。
ただし、hashCodeがないからと言って、処理結果が変わるようなことはなく、処理自体は正しく行われます。

まとめ

equalsメソッドは、オブジェクトの同値性を検証するもの。オブジェクトの値が同一かどうかを検証するのであれば、equalsメソッドだけで実現できますが、比較にハッシュ値を利用する処理であれば、hashCodeメソッドを実装することでパフォーマンスの向上が見込めます。

値の同値性を求めるオブジェクトの場合は、equalsメソッドとhashCodeメソッドの両方を実装しておいたほうが良いです。
eclipseなどのIDEでコードの自動生成ができますし、lombokコンパイル時に動的に生成させることもできます。

Spring BootでテストするWebアプリケーション② - Controllerクラスのテスト

Spring Bootシリーズ。今回はWeb三層アプリケーションのControllerクラスのテストです。

テスト対象のControllerクラス

今回は、次の3つのメソッドが実装されているControllerクラスをテストします。

package penguin.web.controller;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import penguin.web.controller.form.ProfileForm;
import penguin.web.service.ProfileService;

@Controller
@RequestMapping("profile")
public class ProfileController {

    @Autowired
    private ProfileService profileService;

    @GetMapping("list")
    public String list(Model model) {
        List<ProfileForm> profileList = profileService.getProfileList()
            .stream()
            .map(e -> new ProfileForm(e.getName(), e.getBirthday(),
                    ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now())))
            .collect(Collectors.toList());
        model.addAttribute("profiles", profileList);

        return "profile/list";
    }

    @GetMapping("add")
    public String add(ProfileForm profileForm) {
        return "profile/add";
    }

    @PostMapping("add")
    public String add(@Validated ProfileForm profileForm, BindingResult result) {
        if (result.hasErrors()) {
            return "profile/add";
        }

        profileService.addProfile(profileForm.getName(), profileForm.getBirthday());

        return "redirect:/profile/list";
    }
}

Controllerクラスのテストクラス

テストクラスは次のようになります。
このテストクラスは、テストの実装方法を説明するための実装のみなので、テストケースとしては不足しています。

package penguin.web.controller;

import java.time.LocalDate;
import java.util.Collections;
import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import penguin.web.controller.form.ProfileForm;
import penguin.web.service.ProfileService;
import penguin.web.service.dto.ProfileDto;

@SpringBootTest
@AutoConfigureMockMvc
class ProfileControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProfileService profileService;

    @Test
    @DisplayName("プロフィールが0件の場合、Modelに0件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること")
    void testGetListNoData() throws Exception {
        Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList());

        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/list"))
            .andReturn();

        Map<String, Object> model = result.getModelAndView().getModel();

        Assertions.assertTrue(model.containsKey("profiles"));
        Assertions.assertNotNull(model.get("profiles"));
        Assertions.assertTrue(model.get("profiles") instanceof List<?>);
        List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
        Assertions.assertTrue(profileList.isEmpty());
    }

    @Test
    @DisplayName("プロフィールが1件の場合、Modelに1件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること")
    void testGetListOneData() throws Exception {
        Mockito.when(profileService.getProfileList())
                .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1))));

        try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) {
            LocalDate nowDate = LocalDate.of(2020, 7, 31);
            mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);

            MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.view().name("profile/list"))
                .andReturn();

            Map<String, Object> model = result.getModelAndView().getModel();

            Assertions.assertTrue(model.containsKey("profiles"));
            Assertions.assertNotNull(model.get("profiles"));
            Assertions.assertTrue(model.get("profiles") instanceof List<?>);
            List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
            Assertions.assertEquals(1, profileList.size());
            ProfileForm profileForm = profileList.get(0);
            Assertions.assertEquals("matsuki", profileForm.getName());
            Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday());
            Assertions.assertEquals(22, profileForm.getAge());
        }
    }

    @Test
    @DisplayName("プロフィール追加画面に遷移すること")
    void testGetAdd() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/profile/add"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"));
    }

    @Test
    @DisplayName("プロフィール追加で正当な値が入力された場合、プロフィールの追加処理が呼び出され、プロフィール一覧画面に遷移するること")
    void testPostAdd() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", "1998-01-01"))
            .andExpect(MockMvcResultMatchers.status().isFound())
            .andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list"));

        Mockito.verify(profileService, Mockito.times(1))
            .addProfile("penguin", LocalDate.of(1998, 1, 1));
    }

    @Test
    @DisplayName("プロフィール追加で生年月日が未入力の場合、生年月日にエラーがバインドされ、プロフィール追加画面に遷移するること")
    void testPostAddError() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", ""))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"))
            .andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm"))
            .andExpect(MockMvcResultMatchers.model().errorCount(1))
            .andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull"));
    }
}

テストクラスの実装内容

以下のようにクラスに2つのアノテーションが指定されています。

@SpringBootTest
@AutoConfigureMockMvc
class ProfileControllerTest {
@SpringBootTest

このアノテーションにより、Spring Bootのコンフィグレーションを自動検出して、テストに必要な設定がされます。

@AutoConfigureMockMvc

Controllerクラスはブラウザからのリクエストを受け付けるクラスです。
このアノテーションにより、テスト実行時にリクエストを発行するためのモックオブジェクト「MockMvc」が自動構成されます。

テストクラスのフィールド定義

このテストクラスには次の2つのフィールドが定義されています。

    @Autowired
    private MockMvc mockMvc;

クラスに指定した@AutoConfigureMockMvcアノテーションにより自動構成されたMockMvcオブジェクトをインジェクトします。
このオブジェクトを使って、各テストメソッドでControllerにリクエストを発行することになります。

    @MockBean
    private ProfileService profileService;

テスト対象のControllerクラスでインジェクトしているProfileServiceクラスを@MockBeanアノテーションを指定してフィールド定義しています。
@MockBeanアノテーションを指定することで、DIコンテナのProfileServiceクラスがモックオブジェクトになります。
モックオブジェクトのメソッドは呼び出しても何の処理もせず復帰します。復帰値は、オブジェクトの場合はnull、数値のプリミティブ型の場合は0、boolean型の場合はfalseを返すようになります。

テストメソッドの実装内容

個々のテストメソッドを確認していきます。

はじめに一番、単純なaddメソッドのテストメソッド実装から見ていきましょう。

テスト対象のメソッドは次の内容です。 画面表示するテンプレートのファイルパスを返すだけのメソッドです。

    @GetMapping("add")
    public String add(ProfileForm profileForm) {
        return "profile/add";
    }

この実装から、テストで検証するポイントを考えてみましょう。
次のような検証項目が考えられます。

  • URLは/profile/addであること
  • 受け付けるHTTPメソッドはGETであること
  • テンプレート名としてprofile/addを返却すること

これらの検証項目を検証するテストメソッドは次の内容です。

    @Test
    @DisplayName("プロフィール追加画面に遷移すること")
    void testGetAdd() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/profile/add"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"));
    }

では、テストメソッドの実装内容を分解して確認します。

        mockMvc.perform(MockMvcRequestBuilders.get("/profile/add"))

MockMvcを使って、/profile/addに対してGETリクエスを発行します。

            .andExpect(MockMvcResultMatchers.status().isOk())

リクエストした結果のHTTPステータスがOK(200)であることを確認します。

            .andExpect(MockMvcResultMatchers.view().name("profile/add"));

テンプレート名としてprofile/addが返却されることを確認します。

このように、MockMvcのperformメソッドでリクエストを発行し、andExpectメソッドでリクエストした結果を検証する流れとなります。

次に、プロフィールの追加画面でaddボタンをクリックしたときに呼び出されるaddメソッドのテストメソッド実装を見ていきましょう。

テスト対象のメソッドは次の内容です。 処理内容は大きく2つのルートに分かれます。

  • 入力エラーがある場合
    • テンプレート名としてprofile/addを返却して、再びプロフィールの追加画面を表示する。
  • 入力エラーがない場合
    • ProfileServiceクラスのaddProfileメソッドを呼び出してプロフィールを登録する。
    • テンプレート名としてredirect:/profile/listを返却して、プロフィール一覧画面にリダイレクトする。
    @PostMapping("add")
    public String add(@Validated ProfileForm profileForm, BindingResult result) {
        if (result.hasErrors()) {
            return "profile/add";
        }

        profileService.addProfile(profileForm.getName(), profileForm.getBirthday());

        return "redirect:/profile/list";
    }

テストメソッドは、入力エラーがある場合とない場合で分けて実装します。

入力エラーがないケースのテストメソッドは次の内容です。

    @Test
    @DisplayName("プロフィール追加で正当な値が入力された場合、プロフィールの追加処理が呼び出され、プロフィール一覧画面に遷移するること")
    void testPostAdd() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", "1998-01-01"))
            .andExpect(MockMvcResultMatchers.status().isFound())
            .andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list"));

        Mockito.verify(profileService, Mockito.times(1))
            .addProfile("penguin", LocalDate.of(1998, 1, 1));
    }

では、テストメソッドの実装内容を分解して確認します。

        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", "1998-01-01"))

MockMvcを使って、/profile/addに対してPOSTリクエスを発行します。 POSTするパラメータはparamメソッドで指定します。

            .andExpect(MockMvcResultMatchers.status().isFound())

リクエストした結果のHTTPステータスがFound(302)であることを確認します。
リダイレクトの確認なので、OK(200)ではなく、Found(302)であることがポイントです。

            .andExpect(MockMvcResultMatchers.view().name("redirect:/profile/list"));

テンプレート名としてredirect:/profile/listが返却されることを確認します。

        Mockito.verify(profileService, Mockito.times(1))
            .addProfile("penguin", LocalDate.of(1998, 1, 1));

profileServiceはモックオブジェクトです。モックオブジェクトのメソッドは実装された処理が実行されないだけではなく、メソッドの呼び出しをトレースすることができます。
テスト対象の実装を確認すると、画面の入力内容を引数に、ProfileServiceのaddProfileメソッドが呼び出されるはずなので、実際に呼び出されたかを検証します。

Mockito.verifyメソッドを使って、profileServiceのaddProfileメソッドが、引数"penguin"とLocalDate.of(1998, 1, 1)で1回だけ呼び出されたことを検証できます。

次に、入力エラーがあるケースのテストメソッドです。内容は次の通りです。

    @Test
    @DisplayName("プロフィール追加で生年月日が未入力の場合、生年月日にエラーがバインドされ、プロフィール追加画面に遷移するること")
    void testPostAddError() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", ""))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"))
            .andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm"))
            .andExpect(MockMvcResultMatchers.model().errorCount(1))
            .andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull"));
    }
}

入力エラーを発生させるために、必須入力項目である生年月日を未入力としています。

        mockMvc.perform(MockMvcRequestBuilders.post("/profile/add")
            .param("name", "penguin")
            .param("birthday", ""))

では、テストメソッドの実装内容を分解して確認します。

            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/add"))

HTTPステータスがOK(200)であることと、テンプレート名としてprofile/addが返されることを検証します。

            .andExpect(MockMvcResultMatchers.model().attributeHasErrors("profileForm"))

Modelオブジェクトに格納されているprofileFormにエラーが含まれることを検証します。

            .andExpect(MockMvcResultMatchers.model().errorCount(1))

Modelオブジェクトにエラーが1件、含まれることを検証します。

            .andExpect(MockMvcResultMatchers.model().attributeHasFieldErrorCode("profileForm", "birthday", "NotNull"));

Modelオブジェクトに格納されているprofileFormのbirthday項目にNotNullのエラーがバインドされていることを検証します。

最後に、プロフィール一覧画面を表示するときに呼び出されるlistメソッドのテストメソッド実装を見ていきましょう。

テスト対象のメソッドは次の内容です。
ProfileServiceのgetProfileListメソッドによって、データベースから取得されたプロフィール一覧をProfileFormに詰め替えて、テンプレートファイルprofile/listによって、プロフィールの一覧を表示するものです。
このメソッドの最大の検証ポイントは、生年月日と現在の日付から年齢を算出する部分です。

    @GetMapping("list")
    public String list(Model model) {
        List<ProfileForm> profileList = profileService.getProfileList()
            .stream()
            .map(e -> new ProfileForm(e.getName(), e.getBirthday(),
                    ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now())))
            .collect(Collectors.toList());
        model.addAttribute("profiles", profileList);

        return "profile/list";
    }

テストメソッドは、プロフィール一覧が0件のケースと1件のケースで分けています。

プロフィール一覧が0件のケースのテストメソッドは以下の内容です。

    @Test
    @DisplayName("プロフィールが0件の場合、Modelに0件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること")
    void testGetListNoData() throws Exception {
        Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList());

        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/list"))
            .andReturn();

        Map<String, Object> model = result.getModelAndView().getModel();

        Assertions.assertTrue(model.containsKey("profiles"));
        Assertions.assertNotNull(model.get("profiles"));
        Assertions.assertTrue(model.get("profiles") instanceof List<?>);
        List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
        Assertions.assertTrue(profileList.isEmpty());
    }

では、テストメソッドの実装内容を分解して確認します。

        Mockito.when(profileService.getProfileList()).thenReturn(Collections.emptyList());

このテストケースの肝です。
profileServiceはモックオブジェクトです。モックオブジェクトのメソッドの復帰値はオブジェクトの場合はnull、プリミティブ型の数値の場合は0、booleanの場合はfalseですが、変更可能です。
ここでは、profileServiceオブジェクトのgetProfileListメソッドを呼び出した時の復帰値を0件のListオブジェクト(Collections.emptyList())に変更しています。
これによって、テスト対象メソッド内でprofileServiceオブジェクトのgetProfileListメソッドを呼び出した時に、必ず0件のListを返すようになります。

        MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
            .andExpect(MockMvcResultMatchers.status().isOk())
            .andExpect(MockMvcResultMatchers.view().name("profile/list"))
            .andReturn();

HTTPステータスがOK(200)であることと、テンプレート名としてprofile/listが返されることを検証します。
andReturnメソッドを呼び出して、リクエストの実行結果が格納されているMvcResultオブジェクトを取得します。このMvcResultオブジェクトを使用して、詳細な検証をします。

        Map<String, Object> model = result.getModelAndView().getModel();

MvcResultオブジェクトからModelを取得します。

        Assertions.assertTrue(model.containsKey("profiles"));
        Assertions.assertNotNull(model.get("profiles"));
        Assertions.assertTrue(model.get("profiles") instanceof List<?>);
        List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
        Assertions.assertTrue(profileList.isEmpty());

Modelオブジェクトに格納されているprofilesの内容を検証します。

プロフィール一覧が1件のケースのテストメソッドは以下の内容です。

基本的にはプロフィール一覧が0件のケースと同じ検証内容ですが、現在の日付がわかると年齢の期待値も変わってしまうので、現在の日付を固定化する必要があります。

    @Test
    @DisplayName("プロフィールが1件の場合、Modelに1件のProfileFormのリストが設定され、プロフィール一覧画面に遷移するること")
    void testGetListOneData() throws Exception {
        Mockito.when(profileService.getProfileList())
                .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1))));

        try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) {
            LocalDate nowDate = LocalDate.of(2020, 7, 31);
            mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);

            MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.view().name("profile/list"))
                .andReturn();

            Map<String, Object> model = result.getModelAndView().getModel();

            Assertions.assertTrue(model.containsKey("profiles"));
            Assertions.assertNotNull(model.get("profiles"));
            Assertions.assertTrue(model.get("profiles") instanceof List<?>);
            List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
            Assertions.assertEquals(1, profileList.size());
            ProfileForm profileForm = profileList.get(0);
            Assertions.assertEquals("matsuki", profileForm.getName());
            Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday());
            Assertions.assertEquals(22, profileForm.getAge());
        }
    }

では、テストメソッドの実装内容を分解して確認します。

        Mockito.when(profileService.getProfileList())
                .thenReturn(Collections.singletonList(new ProfileDto(1L, "matsuki", LocalDate.of(1998, 1, 1))));

0件のケースと同様、ProfileServiceのモッククラスのgetProfileListメソッドの復帰値を変更して、必ず1件のListを返すようになります。

        try (MockedStatic<LocalDate> mockedLocalDate = Mockito.mockStatic(LocalDate.class, Mockito.CALLS_REAL_METHODS)) {
            LocalDate nowDate = LocalDate.of(2020, 7, 31);
            mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);

テスト対象のメソッドでは、LocalDateクラスのstaticメソッドであるnowメソッドを呼び出して、現在の日付を取得しています。このnowメソッドの復帰値を固定化するために、Mockitoの機能を使用します。

MockitoのmockStaticメソッドの引数にモック化したいLocalDateのクラスを指定します。通常、モック化したクラスのすべてのメソッドは、何も処理をせず、復帰値はオブジェクトの場合はnull、プリミティブ型の数値型は0、booleanはfalseを返すようになります。
ただし、今回、復帰値を変更したいメソッドはnowメソッドだけで、ほかのメソッドは今まで通りの動作をしてほしいので、MockitoのmockStaticメソッドの第2引数にMockito.CALLS_REAL_METHODSを指定します。

MockitoのmockStaticメソッドの復帰値であるMockedStaticは、最後にcloseメソッドを呼ぶ必要があります。closeメソッドを呼ばないと、モック化が解除されず、ほかのテストクラスの実行に影響を及ぼします。MockedStaticはAutoCloseableを実装しているので、try with resources構文を使ってクローズします。

            LocalDate nowDate = LocalDate.of(2020, 7, 31);
            mockedLocalDate.when(LocalDate::now).thenReturn(nowDate);

現在の日付を固定化する部分です。
MockitoのmockStaticメソッドの復帰値のMockedStaticオブジェクトを使用します。whenメソッドでモック化するメソッドをラムダ式で指定します。そして、thenReturnメソッドでモックメソッドの復帰値を指定します。

この実装を見て、ローカル変数「nowDate」はここでしか使用されていないので、以下のように1行にまとめたくなるかもしれません。

            mockedLocalDate.when(LocalDate::now).thenReturn(LocalDate.of(2020, 7, 31));

全く問題のないコードに見えますが、Mockito的に、このコードはNGです。実際に実行すると、例外が発生してしまいます。
理由は、モック化されたメソッドの復帰値の定義中にモック化されたメソッドを呼び出してはいけないからです。
1行にまとめた記述方法では、LocalDateのnowメソッドの復帰値の定義内で、モック化されたLocalDateのofメソッドを呼び出してしまいます。なので、ofメソッドをあらかじめ呼び出して、その復帰値をモックの復帰値の定義で指定する必要があるのです。

別の解決方法で、thenReturnではなく、thenAnswerを使う方法があります。thenAnswerの使い方は、今回の本題から外れてしまうので、別の機会に解説します。

            MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/profile/list"))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.view().name("profile/list"))
                .andReturn();

            Map<String, Object> model = result.getModelAndView().getModel();

            Assertions.assertTrue(model.containsKey("profiles"));
            Assertions.assertNotNull(model.get("profiles"));
            Assertions.assertTrue(model.get("profiles") instanceof List<?>);
            List<ProfileForm> profileList = (List<ProfileForm>) model.get("profiles");
            Assertions.assertEquals(1, profileList.size());
            ProfileForm profileForm = profileList.get(0);
            Assertions.assertEquals("matsuki", profileForm.getName());
            Assertions.assertEquals(LocalDate.of(1998, 1, 1), profileForm.getBirthday());
            Assertions.assertEquals(22, profileForm.getAge());

残りの処理は、0件のケースと同様です。処理を呼び出して、処理結果の検証をします。

次回予告

今回は、Spring Bootで作成したWeb三層アプリケーションのApplication層にあたるControllerクラスのユニットテストを実装しました。
次回は、Business層にあたるServiceクラスのユニットテストを実装します。
では、次回の「Spring BootでテストするWebアプリケーション③ - Serviceクラスのテスト」でお会いしましょう!!

Spring Frameworkで作るWeb三層アプリケーション

今さらですが、Spring FrameworkでWebアプリケーションを作るときに基本となる、Web三層について、実装を交えてまとめます。

そもそもWebアプリケーションって?

Webアプリケーションの種類にもよりますが、Webアプリケーションは基本的に次の流れに沿って処理するものが大半です。

  1. データベースの内容をブラウザに表示

  2. 利用者が表示された内容に対して、アクションを起こす

  3. 利用者のアクションの内容を処理・データベースに反映

  4. 処理結果をブラウザに表示

これを、ショッピングサイトに例えると、次のようになります。

  1. データベースから商品一覧をブラウザに表示

  2. 利用者が購入する商品を購入

  3. 購入内容から、在庫を確認し、在庫があれば注文結果をデータベースに保存

  4. 注文を受け付けたことをブラウザに表示

ほとんどのWebアプリケーションは、この手順の繰り返しになります。

Web三層アプリケーションって?

Webアプリケーションを作るときに、アプリケーションを次の3つの層に分けたアプリケーションを「Web三層アプリケーション」と呼びます。

Application層

ブラウザへの画面表示、ブラウザの入力内容の受け取りをするための層です。
具体的には次の処理を主に実装します。

  • 画面の入力内容のチェックとエラーメッセージの表示

  • 画面に表示するときの表示形式の変換

Domain層

業務処理を実装するための層です。
画面で入力されたデータを処理し、次に画面に表示するデータを用意します。

Infrastructure層

データベースや外部サービスとのやり取りを実装するための層です。
基本的には、データの取得や保存処理のみ実装します。

各層に実装するSpring Frameworkコンポーネント

各層には処理を実装するためのクラスと、層をまたいでデータを受け渡すためのBeanを実装します。

f:id:penguinlabo:20210507180535p:plain

この図を見てもわかるように、各層にクラスとBeanが1つずつ登場していることがわかります。
ただし、この図の登場人物をすべて実装することが必須ではありません。例えば、Application層からDomain層に渡すデータが単一の項目の場合、わざわざDTO Beanを使わずに、Service Classのメソッドの引数に指定する場合もあります。

ぱっと見、登場人物が多くて、複雑な図に見えるかもしれませんが、データを受け渡すためのBeanを省略して、処理の流れだけにフォーカスすると、とてもシンプルな流れであることがわかります。

f:id:penguinlabo:20210507180927p:plain

Spring FrameworkでWeb三層アプリケーションを実装

では、実際にSpring Frameworkを使って、Web三層アプリケーションを実装します。
今回は次のような画面を持つアプリケーションを実装します。

f:id:penguinlabo:20210507193423p:plain

プロジェクトの構成

Web三層アプリケーションのプロジェクト構成に特に規定はありませんが、各層をパッケージで分けるのがわかりやすいです。

f:id:penguinlabo:20210507195759p:plain

Application層の実装

Controllerクラス

パッケージ「controller」を作成して、そこにコントローラークラスを配置します。

package penguin.web.controller;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

import penguin.web.controller.form.ProfileForm;
import penguin.web.service.ProfileService;

@Controller
@RequestMapping("profile")
public class ProfileController {

    @Autowired
    private ProfileService profileService;

    @GetMapping("list")
    public String list(Model model) {
        List<ProfileForm> profileList = profileService.getProfileList()
            .stream()
            .map(e -> new ProfileForm(e.getName(), e.getBirthday(),
                    ChronoUnit.YEARS.between(e.getBirthday(), LocalDate.now())))
            .collect(Collectors.toList());
        model.addAttribute("profiles", profileList);

        return "profile/list";
    }

    @GetMapping("add")
    public String add(ProfileForm profileForm) {
        return "profile/add";
    }

    @PostMapping("add")
    public String add(@Validated ProfileForm profileForm, BindingResult result) {
        if (result.hasErrors()) {
            return "profile/add";
        }

        profileService.addProfile(profileForm.getName(), profileForm.getBirthday());

        return "redirect:/profile/list";
    }
}
Form Bean

Form Beanは、「controller」パッケージ配下に「form」パッケージを作成して、そこに配置します。

package penguin.web.controller.form;

import java.time.LocalDate;

import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import org.springframework.format.annotation.DateTimeFormat;

public class ProfileForm {

    @Size(min = 3, max = 15)
    private String name;

    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate birthday;

    private Long age;

    public ProfileForm(@Size(min = 3, max = 15) String name, @NotNull LocalDate birthday, Long age) {
        super();
        this.name = name;
        this.birthday = birthday;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }

    public Long getAge() {
        return age;
    }

    public void setAge(Long age) {
        this.age = age;
    }
}
画面テンプレート

画面テンプレートはJavaファイルではないので、src/main/resourcesに「templates」フォルダを作成して、その下に配置します。
ここでは、「templates」フォルダ配下に「profile」フォルダを作成して、そこにテンプレートファイル「list.html」と「add.html」を作成します。

<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>List</title>
<style type="text/css">
<!--
table {
    border-collapse: collapse;
}
th, td {
    border: 1px darkgray solid;
    padding: 0.2em 0.5em;
}
-->
</style>
</head>
<body>
<table>
    <caption>Profile List</caption>
    <thead>
        <tr>
            <th scope="col">Name</th>
            <th scope="col">Birthday</th>
            <th scope="col">Age</th>
        </tr>
    </thead>
    <tbody>
        <tr th:each="profile : *{profiles}">
            <td th:text="${profile.name}" />
            <td th:text="${profile.birthday}" />
            <td th:text="${profile.age}" />
        </tr>
    </tbody>
</table>
<a th:href="@{/profile/add}"><input type="button" value="add" /></a>
</body>
</html>
<!DOCTYPE html>
<html lang="ja" xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Add</title>
<style>
.input-error {
    border: 1px solid red;
}
</style>
</head>
<body>
<form th:action="@{/profile/add}" th:object="${profileForm}" method="post">
    <p>
        <label for="name">名前</label>
        <input type="text" name="name" id="name" th:field="*{name}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
    </p>
    <p>
        <label for="birthday">生年月日</label>
        <input type="date" name="birthday" id="birthday" th:field="*{birthday}" th:errorclass="input-error">
        <span style="color: red;" th:if="${#fields.hasErrors('birthday')}" th:errors="*{birthday}"></span>
    </p>
    <p>
        <a th:href="@{/profile/list}"><input type="button" value="Cancel" /></a>
        <input type="submit" value="Ok">
    </p>
</form>
</body>
</html>

Domain層の実装

Serviceクラス

パッケージ「service」を作成して、そこにサービスクラスのインターフェスと実装クラスを配置します。

package penguin.web.service;

import java.time.LocalDate;
import java.util.List;

import penguin.web.service.dto.ProfileDto;

public interface ProfileService {
    List<ProfileDto> getProfileList();

    Long addProfile(String name, LocalDate birthday);
}
package penguin.web.service;

import java.time.LocalDate;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import penguin.web.repository.ProfileRepository;
import penguin.web.repository.entity.ProfileEntity;
import penguin.web.service.dto.ProfileDto;

@Service
@Transactional(rollbackFor = Exception.class)
public class ProfileServiceImpl implements ProfileService {
    @Autowired
    private ProfileRepository profileRepository;

    @Override
    public List<ProfileDto> getProfileList() {
        return profileRepository.findAll()
            .stream()
            .map(e -> new ProfileDto(e.getId(), e.getName(), e.getBirthday()))
            .collect(Collectors.toList());
    }

    @Override
    public Long addProfile(String name, LocalDate birthday) {
        ProfileEntity profile = new ProfileEntity();
        profile.setName(name);
        profile.setBirthday(birthday);
        profile = profileRepository.save(profile);
        return profile.getId();
    }
}
DTO Bean

DTO Beanは、「service」パッケージ配下に「dto」パッケージを作成して、そこに配置します。

package penguin.web.service.dto;

import java.time.LocalDate;

public class ProfileDto {

    private Long id;

    private String name;

    private LocalDate birthday;

    public ProfileDto(Long id, String name, LocalDate birthday) {
        super();
        this.id = id;
        this.name = name;
        this.birthday = birthday;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }
}

Infrastructure層の実装

Repositoryクラス

パッケージ「repository」を作成して、そこにリポジトリクラスのインターフェスクラスを配置します。

package penguin.web.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import penguin.web.repository.entity.ProfileEntity;

@Repository
public interface ProfileRepository extends JpaRepository<ProfileEntity, Long> {
    List<ProfileEntity> findByName(String name);
}
Entity Bean

Entity Beanは、「repository」パッケージ配下に「entity」パッケージを作成して、そこに配置します。

package penguin.web.repository.entity;

import java.time.LocalDate;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "profile")
public class ProfileEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    private LocalDate birthday;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public LocalDate getBirthday() {
        return birthday;
    }

    public void setBirthday(LocalDate birthday) {
        this.birthday = birthday;
    }
}

その他のファイル

Web三層アプリケーションの実装とは関係ありませんが、以下のファイルが、本プロジェクトで実装しています。

Spring Bootアプリケーションクラス

本プロジェクトはSpring Bootで作成したので、Spring Bootの起動クラスを作成しています。

package penguin.web;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}
Spring Bootアプリケーションプロパティファイル
server.servlet.context-path=/penguin-web
spring.jpa.show-sql=true
Gradleファイル

本プロジェクトはGradleプロジェクトとして作成したので、build.gradleを以下の内容に編集しています。

plugins {
    id 'java-library'
    id 'org.springframework.boot' version '2.4.5'
}
apply plugin: 'war'
apply plugin: 'eclipse-wtp'
apply plugin: 'io.spring.dependency-management'

sourceCompatibility = '1.8'
targetCompatibility = '1.8'

compileJava.options.encoding = 'UTF-8'
compileTestJava.options.encoding = 'UTF-8'

eclipse.wtp {
    component.contextPath = 'penguin-web'
    facet {
        facet name: 'jst.java', version: '1.8'
        facet name: 'wst.jsdt.web', version: '1.0'
        facet name: 'jst.web', version: '3.1'
    }
}

repositories {
    jcenter()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'
}

いくつになっても「データと文字」を理解できない自分へ④ - Unicodeコードの悲劇

今回は文字コードの最終回「Unicode」について説明します。

前回、紹介した「JISコード」や「シフトJISコード」で、ひらがなや漢字など、日本語全般の文字を扱うことができるモードの登場により、PCで日本語を容易に扱えるようになりました。
これは、コンピュータの処理性能の向上が大きく関係しています。扱える文字種が多くなるということは、データ量が増えることにほかなりません。コンピュータの処理性能の向上により、増えたデータ量を処理できるようになったのです。

文字コード乱立時代

日本では、コンピュータ上でひらがなや漢字を使いたいという需要があったのと同じように、諸外国でも、同じような需要は当然のようにありました。
日本と同じように、漢字文化の中国や、複数の文字を組み合わせて文字を表すハングル文字など、文字種が多い国でも、コンピュータで母国語を扱えるよう、日本と同じように独自の文字コードを定義しています。

日本国内にしても、PCでは「シフトJISコード」、UNIX系OSでは「EUCコード」、ホスト系OSでは「EBCDICコード」と、複数の文字コードが存在しました。

これら、文字コードが乱立したことで次のような問題が顕著になってきます

国ごとに文字コードが違う

複数の国で使用されるようなグローバルシステムでは、複数の国の文字を使って画面を表示したいところですが、1つのシステムで複数の国の文字コードを扱うことが難しいのです。

OSによって文字コードが違う

日本国内だけでも、OSによって文字コードが違うため、複数のOSからなるシステムの場合、相互に文字コードを変換する必要があります。
例えば、データをホストで保存しているような場合、それをPC上で表示するときは、PC上のクライアントアプリケーションで、ホストの文字コードEBCDICコード」をPCの文字コードシフトJISコード」に変換する必要があります。

Unicodeの登場

いくつもの文字コードを処理するアプリケーションは、文字コードの変換に実装コストや処理コストがかかります。
また、テキストファイルを開くときに文字コードが違うと、文字化けを起こしてしまいます。

扱う文字コードが増えることは不利益しか生まないことから、文字コードを統一しようという動きにつながりました。

ISOの文字コード規格委員会は、世界のすべての文字を扱える文字コードの規格化を始めます。
実は、この動きはかなり昔で、1984年です。Windows95よりはるかに前で、キャラクタベースのMS-DOS全盛期です。
そして、1991年に最初のUnicodeの仕様が公開され、バージョンアップを重ねて、扱える文字が増えていくことになります。

Unicodeとは

Unicodeは、すべての国の文字を扱える文字コードです。

シフトJISコードは、1バイトで1文字を表す半角文字+2バイトで1文字を表す全角文字で成り立っています。
それに対し、Unicodeは、すべての文字を2バイトで表します。2バイトなので65536の文字を表すことができます。

CJK統合漢字とは

ここで、すべての国の文字を2バイトなので65536文字で表現できるか?と疑問に思うかもしれません。
各国のコードで定義されている文字種は、日本で6000種以上、中国で7000種以上、韓国で8000種以上、台湾で13000種以上です。これだけで、半分以上の領域を使ってしまいます。

これらの国の文字で多くを占めるのは、「漢字」です。そこで、Unicodeでは同じ形の文字は1つの文字に統合することで、必要となるコードの数を減らすことにしました。
つまり、日本語の「約」も中国語の「約」も同じコードにしてしまう、ということです。

これらの文字の集まりは、Chinese(中国語)、Japanese(日本語)、Korean(朝鮮語)の頭文字をとって、CJK統合漢字と言います。

Unicodeの普及

Unicodeはバージョンアップを重ねることで、扱える文字が増えていきます。
それに伴い、UnicodeをサポートするOSや言語も増えていきます。WindowsUnixMacOSなどは、文字の処理にUnicodeが使用されていますし、Javaも文字表現はUnicodeが使用されています。そして、Javaは、Unicodeと、Unicode以外の文字を相互に変換する機能も備えているので、Unicode以外のOSとのデータ連携が容易となっています。

Unicodeの問題

すべての文字を収録した素晴らしい文字コードであるUnicodeですが、問題もあります。

文字形の問題

CJK統合漢字で説明したとおり、同じ形の文字は1つのコードに統合されています。
同じ文字なんだから、統合しても問題ない。本当でしょうか。

次の文字を見てください。

f:id:penguinlabo:20210501001647p:plain

上段が中国のフォントで表示したもの、下段が日本のフォントで表示したものです。 文字の形に違和感を感じます。中国のUnicodeフォントで日本語を表示すると、上段のように一部の文字が中国で一般的な形になってしまうのです。

ASCIIコードとの互換性の問題

これまで、いくつかの文字コードの説明をしましたが、それらの文字コードはASCIIコードとの互換性を保ってきました。互換性を捨てることは、過去の資産を切り捨てることになるからです。
しかし、UnicodeはASCIIコードとの互換性を切り捨てています。

ASCIIコードはすべての文字を1バイト(正確には7bit)で表しますが、Unicodeではすべての文字を2バイトで表します。
互換性を完全に捨てているわけではなく、「00」+ASCIIコードの2バイトで既存のASCII文字を表現します。
Unicodeでは、すべてのASCII文字に「00」が付与されるので、既存のテキストエディタでは正しく表示できず、結果、互換性がないことになります。

また、ASCIIコードだけで事足りる、たとえば英語圏のテキストファイルの場合、Unicodeにすると、すべての文字が2バイトになり、単純にデータ量が2倍になってしまいます。

そこで、UnicodeはASCIIコードと互換性のあるUTF-8を策定することになります。
UTF-8は、ASCIIコード部分は1バイトで、それ以外の例えばCJK統合漢字などは3バイトで表現することにしました。
文字を表す最小ビット数が8ビット(1バイト)であることから、UTF-8命名されてます。それに対し、文字を表す最小ビット数が16ビット(2バイト)のUnicodeは、UTF-16とも呼ばれます。

サロゲートペア文字

Unicodeは、全世界の文字を2バイトで表現する、これが始まりでした。
表現できる文字種は65536文字です。足りなくならないよう、CJK統合漢字のように、同じ形の文字を統合することで、文字種が不用意に増えないようにもしました。

しかし、バージョンアップに従って、文字を追加していったことにより、いよいよ2バイトでは不足してきそうになります。
そこで、サロゲートペアという規格が追加されます。
これは、一部の文字を倍の4バイトで表すことで、さらに多くの文字種を扱えるようにするものです。

この、サロゲートペアが、Unicode最大の悲劇だと思います。

既存のシステムはUnicodeを使うことで、1文字は2バイトで表現できることが保証されていました。
例えば、5文字まで入力できる項目の場合、10バイト以内であることをチェックすればよいのですが、サロゲートペア文字を入力されると、3文字で12バイトとなり、入力エラーになってしまいます。

Javaサロゲートペアの規格追加以前の言語なので、既存のAPIでは正しくサロゲートペア文字を扱えません。
以下のコードを実行してみましょう。

System.out.println("𠮷".length());

「𠮷」は「土」+「口」のサロゲートペア文字です。このコードの実行結果は「2」と表示されてしまいます。「𠮷」は2文字として扱われてしまう、といことです。

サロゲートペアという後付け仕様により、既存のAPIでは対応できず、結局、開発者がサロゲートペア文字を意識したコードを書くことが必要になりました。

Unicodeに物申したい!

ここからは個人的な意見です。

Unicodeの問題点をいくつかあげましたが、マツキはUnicode肯定派です。
すべての国の文字を1つのコードで表現するという、途方もないことを現実のものにしてくれました。

しかし、サロゲートペアだけは避けてほしかった。後付けする仕様としては、既存システムへの影響が大きすぎます。

コードが足りなくなったから、やむを得ず、というのが理由なんですが、本当に足りないんでしょうか?

Unicodeはバージョンアップとともに文字を追加しています。次のように「絵文字」も追加されてます。

f:id:penguinlabo:20210501010307p:plain

この「絵文字」は結構な種類が追加されているうえ、サロゲートペアではない2バイトで表現できるエリアにマッピングされている文字もあります。

これらの文字は、サロゲートペアという仕様を追加してまで必要だったのか、大いに疑問です。

絵文字を取り込まなくても、将来的にコードが不足しそうだから、サロゲートペアを追加したのかもしれませんが、だとしたら、初めの「2バイトですべての文字を表現する」という規格自体に見積もりの甘さを感じます。

最後に

このUnicodeの話を持って、文字コードの話しは終了です。

欠点はありますが、Unicodeは素晴らしい規格です。そして、そのUnicodeと既存の文字コードを相互変換できるJavaも素晴らしい言語です。

エンジニアとして、システムづくりにかかわっていると、文字コードに対する取り組みから解放されることはありませんが、UnicodeJavaのおかげで、多くのハードルがなくなる、もしくは極めて低くなったことは事実です。

文字コード乱立時代の先人の苦労を考えると、いい時代に生まれたな、と痛感します。

Java Tips - Map変換 Collectors.toMapでNullPointerException

みなさん、Stream API使ってますか?

Java8で導入されたStream APIですが、メソッドが多くて、なかなかすべてを覚えられません。
今回は、Streamの結果をMapに変換する方法のお話です。

Collectors.toMap

Stream APIでMap変換を実装しようとすると、最初に思いつくのは終端操作のcollectでCollectors.toMapを使用する方法です。

以下は、ListをMapに変換するサンプルです。

List<String> list = Arrays.asList("penguin", "matsuki", "java");

Map<Integer, String> map = IntStream.range(0, list.size())
                                    .boxed()
                                    .collect(Collectors.toMap(i -> i, list::get));

System.out.println(map);

非常にシンプルです。サンプルは見やすくするために改行を入れていますが、ワンライナーで書けるのも魅力です。

実行結果は次のようになります。

{0=penguin, 1=matsuki, 2=java}

しかし、このソースには問題があるんです。

Listの要素にnullを含めてみます。

List<String> list = Arrays.asList("penguin", "matsuki", "java", null);

Mapの値(Value)はnullを含めることができるので、問題なく動きそうですが、実行すると、NullPointerExceptionが発生します。

toMapの内部では、Map.mergeメソッドを使用しています。

private static <K, V, M extends Map<K,V>>
BinaryOperator<M> mapMerger(BinaryOperator<V> mergeFunction) {
    return (m1, m2) -> {
        for (Map.Entry<K,V> e : m2.entrySet())
            m1.merge(e.getKey(), e.getValue(), mergeFunction);
        return m1;
    };
}

そして、このmergeメソッドは、値にnullを指定できない仕様になっています。

docs.oracle.com

解決方法

では、Stream APIで値にnullを含めるMapを作れないかというと、そうではなく、別の方法でMapを作成できます。

List<String> list = Arrays.asList("penguin", "matsuki", "java", null);

Map<Integer, String> map = IntStream.range(0, list.size())
                                    .boxed()
                                    .collect(HashMap::new, (m, i) -> m.put(i, list.get(i)), HashMap::putAll);

System.out.println(map);

collectの引数が変わりました。

第1引数

第1引数には結果のオブジェクトを生成する関数を指定します。
今回は、結果をMapで返したいので、HashMapのインスタンスを生成(new)する式を記述します。

HashMap::new
第2引数

第2引数には結果のオブジェクトに1件のデータを格納する関数を指定します。
今回は、Mapに1件のデータを設定(put)する式を記述します。

(m, i) -> m.put(i, list.get(i))
第3引数

第3引数には結果のオブジェクト同士を1つのオブジェクトにまとめる関数を指定します。第1引数で1件のデータを格納するオブジェクトを生成して、第2引数で1件のデータを格納しています。つまり、件数ごとにHashMapが生成されたことになります。それを1つのオブジェクトにまとめる必要があるので、その方法を第3引数に指定するのです。
今回は、Mapをまとめる(putAll)ための式を記述します。

HashMap::putAll

まとめ

Stream APIでListを生成するときは、Collectors.toListを使います。それと同じノリで、Mapを生成するときにCollectors.toMapを使うと、思わぬバグを生みます。
テストのときにMapの値がnullになるケースを忘れると、バグに気付かないことがあるため、テストケースでは値がnullのコンディションを忘れないようにしましょう。

我が家にWi-Fi 6がやってきた - Archer AX73 を購入しました

我が家の無線LAN環境

我が家の無線LANルーターは、いわゆるWi-Fi 5で、802.11acで接続しています。ルーターの速度も理論値で867Mbps、ルーターからPCの場所が離れていることもあって、実行速度は半分ぐらいしか出ない状態です。

それでも、今のルーターは2017年に購入したTP-Linkのルーターで、非常に安定していて不自由していなかったので、リプレースせずに使い続けていました。

Wi-Fi 6のルーターが各社から出始めると、有線LANの速度を超える802.11axに魅かれ、買い替えを検討し始めました。
そんな中、TP-LinkからもWi-Fi 6のルーターが出始めて、リプレース先を選び始めていました。候補としては、TP-LinkのArcher AX50です。

intel製のWi-Fi 6 CPUを搭載しているところも魅力で、ほぼほぼ購入を決めていたんですが、スペックを調べていると、無線LANの新しい暗号化方式である「WPA3」に対応していないことがわかりました。
TP-Linkでは、ファームウェアの更新で既存製品もWPA3に対応するとの発表がありましたが、下位機種のArcher AX10は対応リストにあるのに、上位機種のArcher AX50はリストにありませんでした。

無線LANルーターはTP-Link社一択で考えていたので、一旦、ルーターのリプレースは見送りになりました。

Archer AX73発表

そして、ルーターの買い替えも忘れた2021年2月、TP-Linkから新しいWi-Fi 6ルーター「Archer AX73」が発表になりました。

気に入った点

  • WPA3対応 (must)
  • HT160対応
  • アンテナが6本
  • デザインがAX50よりマシ
  • AX50を超えるスペック
  • 値段が手ごろ

イマイチな点

  • 本体に厚みがある
  • 縦置きができない (AX50は別売りで縦置きスタンドがある)

欲しかった機能が全部含まれているので、迷うことなく購入となりました。

開封の儀

そして我が家に4年ぶりの新しいルーター「Archer AX73」が届きました。

f:id:penguinlabo:20210316201428j:plain

「箱が意外と大きい」と思いつつも開封

f:id:penguinlabo:20210316201714j:plain

ACアダプターが予想より小さかったのはうれしい誤算です。
あらかじめ知っていたんですが、最近のTP-Linkのアンテナは直付けで外れないものが多いです。外れるようになってれば、もう少し外箱が小さくなるのに。直付けのほうがコストが抑えられるのかもしれません。

f:id:penguinlabo:20210316202140j:plain

本体はこんな感じです。外装がプラスチックなので、大きさの割に軽いです。
アンテナが6本もありますが、ゲーミングルーターほどごつくはないです。リビングとか、普段目の止まるところに置くにはイマイチなデザインです。Archer C9のような神デザインのルーターは出ないんでしょうかね・・・

速度測定

ルーターを置いた場所から約10mぐらい離れている場所で測定します。途中に扉が1枚、壁も1枚ある場所です。

計測に使う機器は、802.11acのノートPCと、802.11axのスマホの2台です。

802.11ac (ノートPC)

HT160に対応しているノートPCです。以前のルーターはHT160に対応してなかったので、その点で速度アップすることに期待です。

変更前 変更後 (Archer AX73)
f:id:penguinlabo:20210317114657p:plain f:id:penguinlabo:20210317114848p:plain

HT160の効果で速度は倍ぐらいになることを期待したんですが、100Mbpsアップぐらいにとどまってます。何度か測定したのですが同じ傾向なので、実測値は100Mbpsアップというところでしょう。

802.11ax (スマートフォン)

今回の本命です。802.11axに対応しているスマートフォンです。以前のルーターでは802.11acで接続していたので、802.11axになることで速度アップすることに期待です。

変更前 (802.11ac) 変更後 (802.11ax for Archer AX73)
f:id:penguinlabo:20210317131832j:plain f:id:penguinlabo:20210317131845j:plain

こちらも速度アップはしていますが、さらに微妙な結果に。100Mbps弱のアップです。
ただ、ルーターの近くだと安定して600Mbps程度、早いときだと700Mbpsを超えることもあるので、ルーターとの距離がネックになっていると思われます。

まとめ

4年ぶりのルーター購入です。今まで、I・Oデータ、BUFFALO、NEC、TP-Linkと、毎回、違うメーカーのルーターに買い替えていたんですが、初のメーカーリピートです。
速度は思っていたほどではないですが、確実にアップしています。ただ、このレベルの速度だと、体感できる速度アップではないですね。
あと、「そろそろ有線接続に追いつくのでは」と思ってたんですが、まだまだ有線最強です。次の無線規格で有線の1Gbpsに追いつくかもしれませんが、そのころには有線LANの2.5Gbpsが普及してそうです。

以上、我が家にWi-Fi 6がやってきたお話でした。