Uzabase Tech Blog

SPEEDA, NewsPicks, FORCASなどを開発するユーザベースの技術チームブログです。

Spring Data R2DBCでリアクティブにDBアクセスを行なう

はじめに

こんにちは、SaaS Product Teamのヒロオカです。
SPEEDAではSpring Webfluxの採用が行われおり、一部リアクティブなシステムが動いています。

今回は、R2DBCという、リアクティブな非同期でRDBにするための仕様とSpring(Reactor Project) による実装およびサポートを利用して、APIの実装を試してみたいと思います。

R2DBとは

前述していますが、R2DBCはRDBアクセスに対して、リアクティブな非同期APIを提供するための仕様です。 以下のような特徴を持っています。

  • Reactive Streams specificationに基づいている
  • リアクティブなAPIを利用してRDBにアクセスできる
    • 1コネクション1Threadのモデルより、スケーラブルである
  • R2DBCはオープンな仕様であり、Service Provider Interface(SPI)を定義している

ちなみにR2DBCはReactive Relational Database Connectivity の略です。

リアクティブシステムにおけるJDBCの問題点

じゃあ、今までのJDBCだとどういった点が問題なのかというところに関してですが、大きく2つの問題点があります。

  • そもそも、JDBCはブロッキングAPI
  • トランザクションの情報をThreadLocalに保存してしまう

まず前者に関してですが、言葉通りでJDBCはそもそもブロッキングです。1コネクションに対して1Threadを割り当てるモデルを採用しています。リアクティブシステムのなかでJDBCを利用すると、EventLoop等のThreadをブロックして、リクエストがハングしてしまうと言ったようなことが起こります。Mono.fromCallable等をつかって、ブロッキングの処理を切り離すなどは、やろうと思って思えばできないことは無いと思います。とはいえ、リアクティブにシステムを構築しているメリットが減ってしまいます。

また、JDBCはトランザクションの情報をThreadLocalに保存します。コードが実行されるThreadがダイナミックに変わるリアクティブシステムに置いては、これは大きな問題となります。

こういった理由により、リアクティブシステムに置いてJDBCを利用することは、あまり得策ではないと思われます。

実際に使っていく

R2DBCについて簡単に紹介したところで、実際にコードを書いていって動かしてみたいと思います。 今回は、R2DBCとWebfluxを用いて、顧客情報を提供する簡単なWebAPIを実装してみたいと思います。
具体的に提供するAPIは以下の通りになります。

  • GET /customers で顧客の一覧を取得できる
  • GET /customers/{id} で指定した顧客の情報を取得できる
  • POST /customers で顧客の登録ができる

WebAPIはSpring Bootを使って作成します。
今回、DBはPostgresSQLを利用しますが、その起動はDockerを用いてやろうと思います。

環境

アプリケーションの実行環境は以下の通りです。

$ java --version
openjdk 15 2020-09-15
OpenJDK Runtime Environment (build 15+36-1562)
OpenJDK 64-Bit Server VM (build 15+36-1562, mixed mode, sharing)

$ mvn --version
Apache Maven 3.6.3
Maven home: /usr/share/maven
Java version: 15, vendor: Oracle Corporation, runtime: /home/somebody/.sdkman/candidates/java/15-open
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-60-generic", arch: "amd64", family: "unix"

$ docker version
Client: Docker Engine - Community
 Version:           20.10.2
 API version:       1.41
 Go version:        go1.13.15
 Git commit:        2291f61
 Built:             Mon Dec 28 16:17:43 2020
 OS/Arch:           linux/amd64
 Context:           default
 Experimental:      true

Server: Docker Engine - Community
 Engine:
  Version:          20.10.2
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       8891c58
  Built:            Mon Dec 28 16:15:19 2020
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.4.3
  GitCommit:        269548fa27e0089a8b8278fc4fc781d7f65a939b
 runc:
  Version:          1.0.0-rc92
  GitCommit:        ff819c7e9184c13b7c2607fe6c30ae19403a7aff
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

事前準備

プロジェクトの作成

Spring Initializrを用いて作成します。
設定は以下の通りとなります。

f:id:yuya_hirooka:20210112130255p:plain

R2DBC関連のものに関しては、Spring Data R2DBCPostgresSQL Driverの依存を追加してやる必要があります。DriverはH2MySQLなど様々用意されています。その一覧に関しましてはここを参照してください。

DBの起動

Dockerを使ってPostgresSQLを起動しておきます。

$ docker run  --rm --name postgres-r2dbc -p 5432:5432 -e POSTGRES_PASSWORD=password -d postgres

今回は試しに利用するだけなので、Volumeを当てたりするようなことはしません。
DBの作成だけ行っておきます。

$ docker exec -it postgres-r2dbc  psql -U postgres 
psql (12.2 (Debian 12.2-2.pgdg100+1))
Type "help" for help.

postgres=# CREATE DATABASE customers;
CREATE DATABASE
postgres=# 

APIを実装していく

まずはテストから...と言いたいところですが、テストは今回の主眼では無いので割愛させていただきます。
また、設計の良し悪しに関しても一旦おいておいて、ハンドラーからリポジトリーを直接呼び出すようにします。

空ハンドラーの実装とルーティングの設定

まずは、空のハンドラーを実装します。

@Component
public class CustomerHandler {

    // 顧客一覧を取得するハンドラー
    Mono<ServerResponse> findAll(ServerRequest request) {
        return ServerResponse.ok().build
    }

    // ID指定した顧客を受け取るハンドラー
    Mono<ServerResponse> find(ServerRequest request) {
        return ServerResponse.ok().build();
    }

    // 顧客を登録するハンドラー  
    Mono<ServerResponse> create(ServerRequest request) {
        return ServerResponse.status(HttpStatus.CREATED).build();
    }
}

ここでは、まだそれぞれのステータスコードを返すだけのハンドラーになっています。

WebfluxではMVCと同様にアノテーションベースのハンドラーの定義が行えますが。今回は、一覧性が高さを優先してRouterFunctionを使ってルーティングの設定を書いていきます。
ルーティングの設定はエントリーポイントであるR2dbcSampleApplication.classに直接書きます。

@SpringBootApplication
public class  {

    public static void main(String[] args) {
        SpringApplication.run(R2dbcSampleApplication.class, args);
    }

    @Bean
    RouterFunction<ServerResponse> setUpEndPoints(CustomerHandler customerHandler) {
        return route()
                .nest(path("/customers"),
                        builder -> builder
                                .GET("/{id}", customerHandler::find)
                                .GET(customerHandler::findAll)
                                .POST(customerHandler::create)
                                .filter((req, res) -> res.handle(req)
                                        .onErrorResume(CustomerNotFoundException.class,
                                                e -> ServerResponse.notFound().build()))).build();
    }
}

ここまでは、Webfluxの話なので細かい説明は割愛させていただきますが、今回作ることを想定している3つのエンドポイントとカスタマーが見つからなかったときのハンドリングをフィルターで書いています。(CustomerNotFoundExceptionはRuntimeExceptionを継承した自作のクラスです。ソースコードはここ)

リポジトリの実装

それでは、今回のメインであるリポジトリを実装していきたいと思います。
以下の流れで作って行きたいと思います。

  • DBの諸々の設定
  • BDの初期化処理の記述
  • エンティティの作成
  • ReactiveCrudRepositoryを継承したリポジトリインターフェースを作成

DBの諸々の設定

前述の通り、今回はSpring Bootを用いています。
本来であればConnectionFactoryやDatabaseClient等を自分で設定しなければなりませせんが、org.springframework.boot.autoconfigure.r2dbcorg.springframework.boot.autoconfigure.data.r2dbc がそのへんはよしなってくれているみたいなので、その設定をそのまま使いたいと思います。
カスタマイズなどをする際も、この辺を参考にすればできそうです。

アプリケーション固有の設定をapplication.propertiesに書いてやる必要があります。Bootではspring.r2dbc.*のプリフィクスで設定するプロパティのプールのサイズ、タイムアウトや接続情報を記述することができます。
今回は接続情報だけを以下の通り記述します。

spring.r2dbc.url=r2dbc:postgresql://localhost:5432/customers
spring.r2dbc.username=postgres
spring.r2dbc.password=password

基本的には今まで書いていたような設定と似たような感じにはなるのですがurlのr2dbcの部分が今までjdbcと書いていたと思いますので注意が必要です。

BDの初期化処理の記述

ドキュメントによると、データの初期化に関しては以下のコードをR2dbcSampleApplication.classに追加することで行えそうです。

    @Bean
    public ConnectionFactoryInitializer initializer(ConnectionFactory connectionFactory) {

        ConnectionFactoryInitializer initializer = new ConnectionFactoryInitializer();
        initializer.setConnectionFactory(connectionFactory);

        CompositeDatabasePopulator populator = new CompositeDatabasePopulator();
        populator.addPopulators(new ResourceDatabasePopulator(new ClassPathResource("./db-schema.sql")));
        initializer.setDatabasePopulator(populator);

        return initializer;
    }

後述しますが、Spring Data R2DBCを使ってデータを操作する方法はいくつかあります。
上記のコードはそのうちの1つを使って初期化ようのsqlを流しています。

resources配下に初期化用のdb-schema.sqlファイルも用意しておきましょう。

drop table if exists customer;

create table customer (
    id varchar(36),
    user_name varchar(10),
    primary key (id)
);

エンティティの作成

次に、DBのカラムを表現するエンティティを作成します。

public class Customer implements Persistable {

    public Customer() {
    }

    public Customer(String id, String userName, boolean isNew) {
        this.id = id;
        this.userName = userName;
        this.isNew = isNew;
    }

    @Id
    private String id;

    private String userName;

    @Transient
    private boolean isNew;

   // setterとgetterは省略

    @Override
    public boolean isNew() {
        return isNew;
    }
}

今回はユーザネームとIDを持つだけのシンプルな顧客情報を取り扱うことを想定します。
isNewが気になるところではあるかもしれませんが、後述しますので、ここでは一旦無視していただいて大丈夫です。

ReactiveCrudRepositoryを継承したリポジトリインターフェースを作成

Spring Data R2DBCを利用してRDBにクエリを発行する方法には以下のようなものがあります

今回はその4番目の方法をやってみたいと思います。

ReactiveCrudRepositoryってなんぞ?というところに関してですが。
Spring Dataはリポジトリの抽象化を用意してくれており、そのインターフェスを継承した独自のインターフェースを実装することで、いわゆるボイラープレートを削減することができます。身近そうな例で言うとJpaRepository で、このリポジトリを継承するインターフェースを作るとDBにCURDを行なうためのクラスを自動生成してくれます。
Spring Data R2DBCにおいては、その抽象化の一つにReactiveCrudRepositoryがあります。
今回の場合以下のようにインタフェースを作成します。

import org.springframework.data.repository.reactive.ReactiveCrudRepository;

interface CustomerRepository extends ReactiveCrudRepository<Customer, String> {
}

実は今回のユースケースに関しては、デフォルトで提供されるクラスだけを利用していれば問題ないのでこれでリポジトリの実装は終わりです。

今回のユースケースでは必要ありませんが、@QueryでのSpEL式のサポートしているので、もし新たにクエリを足す必要などがある場合は以下のように定義できます。

interface CustomerRepository extends ReactiveCrudRepository<Customer, String> {

    @Query("select * from customer c where c.user_name == $1")
    Mono<Customer> findByUserName(String userName);

}

ハンドラーの修正

リポジトリができたのでそのリポジトリを用いて、ハンドラーを実装していきます。

@Component
public class CustomerHandler {

    private final CustomerRepository customerRepository;

    public CustomerHandler(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    // 顧客一覧を取得するハンドラー
    Mono<ServerResponse> findAll(ServerRequest request) {
        return ServerResponse.ok()
                .contentType(MediaType.APPLICATION_NDJSON)
                .body(customerRepository.findAll()
                                .map(c -> new CustomerDto(c.getId(), c.getUserName())), CustomerDto.class);
    }

    // ID指定した顧客を受け取るハンドラー
    Mono<ServerResponse> find(ServerRequest request) {
        String id = request.pathVariable("id");
        return customerRepository.findById(id)
                .map(c -> new CustomerDto(c.getId(), c.getUserName()))
                .flatMap(c -> ServerResponse.ok().body(Mono.just(c), CustomerDto.class))
                .switchIfEmpty(Mono.error(new CustomerNotFoundException(String.format("Customer whose id %s is not found", id))));
    }

    // 顧客を登録するハンドラー 
    Mono<ServerResponse> create(ServerRequest request) {
        return request.bodyToMono(CustomerDto.class)
                .map(c -> new Customer(c.getId(), c.getName(), true))
                .map(customerRepository::save)
                .flatMap(customer ->
                        ServerResponse.status(HttpStatus.CREATED)
                                .body(customer.map(c -> new CustomerDto(c.getId(), c.getUserName())), CustomerDto.class));
    }
}

ごちゃごちゃいろいろ書いてますが、外部APIコールの形を表現するConsumerDto(実装はこちら)をエンティティにマップしているだけです。
もう一つ注目する点として、先程、エンティティにisNewの部分です。顧客登録のハンドラーを注目すると、この値にtureをセットしています。 これは、R2DBCリポジトリのエンティティ状態検出戦略 によるものでデフォルトではエンティティのIDがnullでない場合はエンティティはすでにDBに存在するものとして扱われてしまいます。エンティティのIDを外部のロジックを利用して生成したい場合は、その回避策としてPersistableを実装してisNew()メソッドでエンティティが新しいかどうかを判定するようにしてやることができます。
今回は、入力として渡されるIDをそのままインサートしたかったので上記のようにisNewプロパティを定義して、外部から新しいエンティティなのかどうかを制御できるようにしています。

これで、WebAPIの実装まで終わりました。
それではアプリケーションを起動して、その挙動を確認してみたいと思います。

# アプリケーションの起動
$ mvn spring-boot:run


# 顧客の一覧を取得する
# まだ、顧客はいないので空のリストが返ってくる
$ curl -v localhost:8080/customers
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /customers HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: application/x-ndjson
< 
* Connection #0 to host localhost left intact


# 存在しない顧客を取得する
# 存在しない顧客のIDを指定しているので404が返ってくる
$ curl -v localhost:8080/customers/notexist
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /customers/notexist HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 Not Found
< content-length: 0
< 
* Connection #0 to host localhost left intact

# 顧客を登録する
$ curl -v -XPOST -d '{"id": "15c6895c-54ac-11eb-95a1-576ca336aec3", "name": "hirooka"}' --header "Content-type: application/json"  localhost:8080/customers
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /customers HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> Content-type: application/json
> Content-Length: 65
> 
* upload completely sent off: 65 out of 65 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< Content-Type: application/json
< Content-Length: 62
< 
* Connection #0 to host localhost left intact
{"id":"15c6895c-54ac-11eb-95a1-576ca336aec3","name":"hirooka"}


# 顧客を登録する
$ curl -v -XPOST -d '{"id": "15c6895c-54ac-11eb-95a1-576ca336aec3", "name": "uzabase-1"}' --header "Content-type: application/json"  localhost:8080/customers
Note: Unnecessary use of -X or --request, POST is already inferred.
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /customers HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> Content-type: application/json
> Content-Length: 67
> 
* upload completely sent off: 67 out of 67 bytes
* Mark bundle as not supporting multiuse
< HTTP/1.1 201 Created
< Content-Type: application/json
< Content-Length: 64
< 
* Connection #0 to host localhost left intact
{"id":"15c6895c-54ac-11eb-95a1-576ca336aec3","name":"uzabase-1"}

# 顧客の一覧を取得する
# 先程作成したした顧客情報が返ってくる
$ curl -v localhost:8080/customers
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /customers HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< transfer-encoding: chunked
< Content-Type: application/x-ndjson
< 
{"id":"15c6895c-54ac-11eb-95a1-576ca336aec3","name":"uzabase-1"}
{"id":"e077985c-54ad-11eb-a03c-9f7f02e269eb","name":"uzabase-2"}
* Connection #0 to host localhost left intact

# ID指定で顧客を取得する  
$ curl -v localhost:8080/customers/e077985c-54ad-11eb-a03c-9f7f02e269eb
*   Trying 127.0.0.1:8080...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /customers/e077985c-54ad-11eb-a03c-9f7f02e269eb HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.68.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Content-Type: application/json
< Content-Length: 64
< 
* Connection #0 to host localhost left intact
{"id":"e077985c-54ad-11eb-a03c-9f7f02e269eb","name":"uzabase-2"}

これでAPIのSpring Data R2DBCを用いたWebAPIの作成ができました。

終わりに

はじめにでも記述しましたが、SPEEDAでは一部リアクティブの技術を使ってシステムを構築しています。しかし、今はまだ導入段階であり、その機能は限定的に使えている状態であると言った感じです。今後、R2DBCやRScoket等を導入したフルリアクティブなシステムを構築して行けるといいなぁと個人的な思いがあったりします。

参考資料

Smalltalk かつ TDD で『オブジェクト指向設計実践ガイド』の「第5章 ダックタイピングでコストを削減する」をハンズオンしたら 9章も確認せざる得なかった

今日は。 SPEEDA を開発している濱口です。

前回の続きです。
以下の通り、今回も設計の段階的な進化に沿った忠実な写経ができたと思います。

  1. ダックを見逃す
  2. 問題を悪化させる
  3. ダックを見つける

概要としては、依存関係でがんじがらめになった設計を、ダックタイプを使って柔軟性のあるものに変える、というものです。
ハイライトだけ抜粋します。
↓これが、

"依存しまくりの恐ろしい分岐"
Trip >> prepare: preparers [
    preparers
        do: [ :preparer | 
            preparer class == Mechanic
                ifTrue: [ preparer prepareBicycles: bicycles ].
            preparer class == TripCoordinator
                ifTrue: [ preparer buyFood: customers ].
            preparer class == Driver
                ifTrue: [ preparer
                        gasUp: vehicle;
                        fillWaterTank: vehicle ] ]
]

↓こうなる。

"Preparer という抽象に任す"
Trip >> prepare: preparers [
    preparers do: [ :aPreparer | aPreparer prepareTrip: self ]
]

ただし、以下のような記述に引っかかります。

ダックタイプをつくるときは、そのパブリックインターフェースの文書化とテストを、両方ともしなければなりません。

幸い、優れたテストは最高の文書でもあります。ですから、すでに半分は終わっているようなものでしょう。

あとはテストを書きさえすれば、両方とも終わりです。

ダックタイプのテストについてのさらなる話は、「第9章 費用対効果の高いテストを設計する」を参照してください。

このハンズオンは TDD で行っている為、テストのことを先送りにはできません。
既にテストは書いていましたが、 私の書いたテストは妥当かどうか、著者のものと比較してみました。

ダックタイプのテスト要件

「9.5 ダックタイプをテストする」で、著者は以下のように主張しています。

テストで記述すべきことは Preparer ロールの存在であり、証明するべきことは、ロールの担い手がそれぞれが正しく振る舞い、 Trip がそれらと適切に協力することです。

他の箇所も記述も含めまとめると、ダックタイプのテスト要件としては以下の 3 つになると解釈しました。

  1. ロール(ダックタイプ)が存在することを可視化する
  2. メッセージ受信側がロールを担っていることを証明する
  3. 送信側がメッセージを送っていることを証明する

それぞれ対応する Ruby のテストコードは以下です。

# 1. ロール(ダックタイプ)が存在することを可視化する
module PreparerInterfaceTest
    def test_implements_the_preparer_interface
        assert_respond_to(@ object, :prepare_trip)
    end
end
# 2. メッセージ受信側がロールを担っていることを証明する
class MechanicTest < MiniTest::Unit::TestCase
    include PreparerInterfaceTest
    def setup
        @mechanic = @object = Mechanic.new
    end
    # @mechanic に依存するほかのテスト
end
class TripCoordinatorTest < MiniTest::Unit::TestCase
    include PreparerInterfaceTest
    def setup
        @trip_coordinator = @object = TripCoordinator.new
    end
end
class DriverTest < MiniTest::Unit::TestCase
    include PreparerInterfaceTest
    def setup
        @driver = @object = Driver.new
    end
end
# 3. 送信側がメッセージを送っていることを証明する
class TripTest < MiniTest::Unit::TestCase
    def test_requests_trip_preparation
        @preparer = MiniTest::Mock.new
        @trip = Trip.new
        @preparer.expect(:prepare_trip, nil, [@trip])
        @trip.prepare([@preparer])
        @preparer.verify
    end
end

それを知らずに書いた私のテスト

一方、私が 9 章を読む前に書いていたテストは以下です。

"受信側"
MechanicTest >> testPrepareTrip [
    | mechanic trip |
    mechanic := Mechanic new.
    mechanic stub.
    trip := Mock new.
    trip stub bicycles willReturn: #(#bicycle1 #bicycle2).
    mechanic prepareTrip: trip.
    (mechanic should receive prepareBicycle: #bicycle1) once.
    (mechanic should receive prepareBicycle: #bicycle2) once
]

TripCoodinatorTest >> testPrepareTrip [
    | tripCoordinator trip |
    tripCoordinator := TripCoordinator new.
    tripCoordinator stub.
    trip := Mock new.
    trip stub customers willReturn: #customers.
    tripCoordinator prepareTrip: trip.
    tripCoordinator should receive buyFood: #customers
]

DriverTest >> testPrepareTrip [
    | driver trip |
    driver := Driver new.
    driver stub.
    trip := Mock new.
    trip stub vehicle willReturn: #vehicle.
    driver prepareTrip: trip.
    driver should receive gasUp: #vehicle.
    driver should receive fillWaterTank: #vehicle
]

「2. メッセージ受信側がロールを担っていることを証明する」ことは、出来ています。
ロールを担っている( prepareTrip を受信できる)ことに加え、受信したら何をするかも合わせてテスト出来ていて良いのではないでしょうか。

"送信側"
TripTest >> testPrepare [
    | trip preparers preparer1 preparer2 |
    trip := Trip new.
    preparer1 := Mock new.
    preparer2 := Mock new.
    preparers := { preparer1 . preparer2 }.
    trip prepare: preparers.
    (preparer1 should receive prepareTrip: trip) once.
    (preparer2 should receive prepareTrip: trip) once.
]

「3. 送信側がメッセージを送っていることを証明する」ことも出来ています。
ここは Ruby のテストとほぼ同じですね。

残るひとつ、「1. ロール(ダックタイプ)が存在することを可視化する」は出来ていませんでした。

テストカバレッジを上げてみる

比較して足りなかったテストが本当に必要なのかということは置いておいて、
このハンズオンでは忠実な写経を旨としているためお手本と同じカバレッジをとにかく満たすようにします。
キモは受信側でなるべくテストコードを共有することです。
Ruby では module を使ってそれを実現しています。
Smalltalk では trait を使って実現しました。

TPrepareInterfaceTest >> targetObject [
    ^ self SubclassResponsibility 
]

TPrepareInterfaceTest >> testImplementsThePreparerInterface [
    self assert: (self targetObject respondsTo: #prepareTrip:) equals: true.
]

これを以下のように targetObject フックメソッドを実装して使用します。

MechanicTest >> targetObject [
    ^ Mechanic new 
]

TripCoodinatorTest >> targetObject [
    ^ TripCoordinator new 
]

DriverTest >> targetObject [
    ^ Driver new 
]

結論

実のところ、上記の体験を経ても次に同じようなケースに遭遇した時に、ダックタイプを可視化するテストを書けるかまだ自信はありません。
可視化のメリットをまだ理解出来ていないからだと思います。
今は、コードではなくテストコードでダックタイプを可視化することで、コードの柔軟性を殺さずに必要なコミュニケーションを行っている、ということだとなんとなく理解しています。

Rustでモックオブジェクトを自作してみる

こんにちは、SaaS Product Team の Ryo33 です。

この記事では Rust でモックオブジェクトを作ることを通してRefCellMutexRcArcの使い方やSendSyncについて学びます。

この記事を読むことで Rust でモックオブジェクトを自作できるようになります。

サンプルプログラム

まず書いていきたいコードとして以下のようなcreate_user_with_nameというユースケースを考えます。 もし、名前が空文字でなければUserPort.storeを呼ぶというコードです。

#[derive(PartialEq, Eq, Debug, Clone)]
pub struct Name(pub String);

impl Name {
    pub fn is_valid(&self) -> bool {
        self.0 != ""
    }
}

#[derive(PartialEq, Eq, Debug, Clone)]
pub struct User {
    pub name: Name,
}

mod use_case {
    use super::*;

    pub trait UserPort {
        fn store(&self, user: User) -> Result<(), String>;
    }

    pub struct UserUseCase<T: UserPort> {
        pub user_port: T,
    }

    impl<T: UserPort> UserUseCase<T> {
        pub fn create_user_with_name(&self, name: Name) -> Result<(), String> {
            if name.is_valid() {
                return self.user_port.store(User { name });
            }
            Err("not created".to_string())
        }
    }
}

もし呼び出すとしたら、以下のようにUserPortを実装して

mod gateway {
    use crate::use_case::UserPort;

    pub struct UserGateway;

    impl UserPort for UserGateway {
        fn store(&self, user: crate::User) -> Result<(), String> {
            println!("stored {}.", user.name.0);
            Ok(())
        }
    }
}

以下のように呼び出します。

fn main() {
    use gateway::UserGateway;
    use use_case::UserUseCase;

    let use_case = UserUseCase {
        user_port: UserGateway,
    };
    let result = use_case.create_user_with_name(Name("ryo33".to_string()));
    println!("{:?}", result);
}

テストを書いてみる

SaaS Product Team では TDD で開発しているため、TDD のサイクルを回しながら設計と実装を進めていきます。 したがって上のcreate_user_with_nameの実装はまだ私たちの頭の中にしかないものとして、まず成功系のテストを考えてみます。

// in the use_case module
#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_create_user_with_name() {
        // prepare mock
        let user_port_mock = UserPortMock {
            store_mocks: vec![(
                // args
                User {
                    name: Name("ryo33".to_string()),
                },
                // return value
                Ok(()),
            )],
            ..Default::default()
        };

        let target = UserUseCase {
            user_port: user_port_mock,
        };
        assert_eq!(
            target.create_user_with_name(Name("ryo33".to_string())),
            Ok(())
        );

        // verify mock
        assert_eq!(
            target.user_port.store_calls,
            vec![User {
                name: Name("ryo33".to_string()),
            }]
        )
    }
}

このような感じで良さそうです。 最初のセクションでstore_mocksuser_port.storeの引数と返り値の組*1を登録してモックを定義した後、 次のセクションでcreate_user_with_nameを実行して返り値をassertして 最後にstore_callsを使ってuser_port.storeが正しい引数で呼ばれたことをverifyしています。

とはいえUserPortMockをまだ作っていないのでまだ動きません。

モックオブジェクトをつくる

UserPortMockは以下のように定義してみます

struct UserPortMock {
    store_calls: Vec<User>,
    store_mocks: Vec<(User, Result<(), String>)>,
}

impl Default for UserPortMock {
    fn default() -> Self {
        Self {
            store_calls: Vec::new(),
            store_mocks: Vec::new(),
        }
    }
}

impl UserPort for UserPortMock {
    fn store(&self, user: User) -> Result<(), String> {
        // 呼び出しの記録
        self.store_calls.push(user); // <---------------

        // 返り値の検索
        self.store_mocks
            .iter()
            .find(|(args, _return_value)| *args == user)
            .unwrap()
            .1
            .clone()
    }
}

こんな感じでしょうか。しかし、上記の矢印のところでpushするためにmutableな参照が取れないのでコンパイルエラーになります。 &self&mut selfにすれば、問題は解決しますが、テストの都合でUserPortのインターフェースを変えたくありません。

RefCell

インターフェースは不変参照にしたいが可変参照が欲しいといったような上記のようなニーズに応えるのがRefCellです*2。 RefCellを使うと不変参照しかないところに可変参照を生やすことができます。 storeの型をVec<User>からRefCell<Vec<User>>に変え、push(user)の前にborrow_mut()することで先程の例のコンパイルが通るようになります。

borrow_mutの箇所はこんなかんじ

self.store_calls.borrow_mut().push(user.clone());

verifyの箇所はこんな感じ

// verify mock
assert_eq!(
    *target.user_port.store_calls.borrow(),
    vec![User {
        name: Name("ryo33".to_string()),
    }]
);

これでUserUseCaseを実装すれば、ちゃんとテストが通る状態になりました。

非同期

もしuser_port.storeを非同期な処理にしたい場合はどうでしょうか。 async-trait クレートを使って以下のような感じです。

#[async_trait]
pub trait UserPort {
    async fn store(&self, user: User) -> Result<(), String>;
}

ユースケース側は単に関数をasyncにしてuser_port.storeの呼び出しの後に.awaitをつける予定で考えます。

impl<T: UserPort> UserUseCase<T> {
    pub async fn create_user_with_name(&self, name: Name) -> Result<(), String> {
        if name.is_valid() {
            return self.user_port.store(User { name }).await;
        }
        Err("not stored".to_string())
    }
}

テストは tokio クレートなどのテスト用ランタイムを使って以下のような感じになります。

#[tokio::test]
async fn test_create_user_with_name() {
    /* 前略 */

    let target = UserUseCase {
        user_port: user_port_mock,
    };
    assert_eq!(
        target
            .create_user_with_name(Name("ryo33".to_string()))
            .await, // <------ await が必要
        Ok(())
    );

    /* 攻略 */
}

あとはUserPortMockの実装を変える必要がありますが、単に下のようにするだけではコンパイルエラーになります。

#[async_trait]
impl UserPort for UserPortMock {
    async fn store(&self, user: User) -> Result<(), String> {
        /* 中略 */
    }
}

async関数は別スレッドで実行される可能性があるため&selfSendである(スレッド間で安全に値を移動できる)必要があり*3、 参照(&self)を別スレッドに持っていくということはスレッド間で値を共有するということなのでUserPortMockself)はSyncである(スレッド間で安全に値を共有できる)必要があります。 しかしRefCellSyncトレイトを実装していないのでUserPortMock全体で!Syncとなり、&self!Sendになってしまいます。 安全に&selfを別スレッドに移動できないのでコンパイルエラーです。

そこでMutexを使います*4MutexRefCellと似たような使い方ができ、Syncが実装されています。 RefCell<Vec<User>>Mutex<Vec<User>>に型を変えます。

また、borrow_mut()borrow()lock()にします。

#[async_trait]
impl UserPort for UserPortMock {
    async fn store(&self, user: User) -> Result<(), String> {
        self.store_calls.lock().unwrap().push(user.clone());
        
        /* 中略 */
    }
}

#[tokio::test]
async fn test_create_user_with_name() {
    /* 中略 */

    // verify mock
    assert_eq!(
        *target.user_port.store_calls.lock().unwrap(),
        vec![User {
            name: Name("ryo33".to_string()),
        }]
    );
}

これでコンパイルが通りちゃんとテストできるようになります。

複数の所有権

今回の例であるuser_port.storeでは若干違和感がありますが、&selfではなくselfな関数をモックしたいこともあります。

ためしに、UserPortstoreUserUseCasecreate_user_with_name&selfから&を外してみると、問題が発生します。

なぜなら、テストコード内でtarget.create_user_with_nameを呼び出すとtargetの所有権が奪われてtarget.user_port.store_callsを借用できなくなるからです。

#[tokio::test]
async fn test_create_user_with_name() {
    let user_port_mock = UserPortMock {
        store_mocks: vec![(
            // args
            User {
                name: Name("ryo33".to_string()),
            },
            // return value
            Ok(()),
        )],
        ..Default::default()
    };

    let target = UserUseCase {
        user_port: user_port_mock,
    };
    assert_eq!(
        target
            .create_user_with_name(Name("ryo33".to_string())) // ここで所有権が奪われる
            .await,
        Ok(())
    );

    // verify mock
    assert_eq!(
        *target.user_port.store_calls.lock().unwrap(), // ここがコンパイルエラーになる
        vec![User {
            name: Name("ryo33".to_string()),
        }]
    );
}

そこでRcArcを使って複数の所有権を持てるようにします。 同期の例ではRcを使ってRefCell<Vec<User>>Rc<RefCell<Vec<User>>>に、 非同期の例ではRc!SendなのでArcを使ってMutex<Vec<User>>Arc<Mutex<Vec<User>>>に変えます。

以下のように先にstore_callscloneしておき、それを使うことでコンパイルが通るようになります。

#[tokio::test]
async fn test_create_user_with_name() {
    let user_port_mock = UserPortMock {
        store_mocks: vec![(
            // args
            User {
                name: Name("ryo33".to_string()),
            },
            // return value
            Ok(()),
        )],
        ..Default::default()
    };
    let store_calls = user_port_mock.store_calls.clone(); // 先にcloneしておく

    let target = UserUseCase {
        user_port: user_port_mock,
    };
    assert_eq!(
        target
            .create_user_with_name(Name("ryo33".to_string())) // targetの所有権は奪われる
            .await,
        Ok(())
    );

    // verify mock
    assert_eq!(
        *store_calls.lock().unwrap(), // store_callsの所有権を先に取得していたので使っても大丈夫
        vec![User {
            name: Name("ryo33".to_string()),
        }]
    );
}

最後に

と、頑張ってモックオブジェクトを作りましたが、最近になってmock-itというクレートを見つけました。 内部実装を見るとほとんど同じことをやっています。 こちらを使うほうが便利で書く量も少なくて済むので、徐々にこちらに移行しているところです。

参考文献

*1: HashMapを使っても良い

*2:実はMutexでもRwLockでもできる

*3:userも同様だがすでにSend

*4:RwLockでもよい

開発チームに来て感じたこと

こんにちは。7月からSaaS Product Teamに参加している横山です。

Uzabaseにきて大体半年が経ちました。 SaaS Product Teamに来るまでは、比較的ウォーターフォールがメインの現場にいたのですが、 ここにきて驚いたことのうち3つを書いていきたいと思います。

SaaS Product Teamってどんな開発をしているのだろうというのが少しでも伝わればと思います。

仕様書のドキュメントがない

SaaS Product Teamでは、ドキュメントがほどんどありません。 アーキテクチャ図や、手順書のようなものはありますが、それもドキュメントの形式で残っているものではないです。

理由としては、ドキュメントを残さない理由として属人化を防ぐというのが挙げられます。 野口さんも以前書いてくださっています。
「ここではすべてが流れている!」SPEEDA の開発チームに入って驚いた 3 つのこと - Uzabase Tech Blog その2

しかし、仕様書が全く存在しないのか、というとそうではありません。 SPEEDAにおける仕様書はE2Eテストになります。いわゆる「実行可能なドキュメント」と呼ばれるものです。 ここでGaugeという自動化ツールを使用し、より仕様書のようにすることが可能です。

参考: GaugeのConceptを用いてテストシナリオをより仕様書のように記述する - Uzabase Tech Blog

このように、「コミュニケーション」と「実行可能なドキュメント」を用いてドキュメントを書かない開発を実現しています。 実行可能なドキュメントがきちんと作られている状態に非常に驚き、感動しました!

常にペアプロを行っている

SaaS Product Teamでは、ほぼ常にペアプロを行っています。

詳しくは鈴木さんの記事が書いてくださっていますが、開発チームでは、大体1時間毎(チームにより異なる)にペアを交代します。こうすることで、一つのストーリーを特定の人のみが知っている状況というのを防げます。

ペアプロと育休の取得しやすさの関係について - Uzabase Tech Blog

属人化した状況を作り出さないだけでなく、鈴木さんが書いてくださっているような休みやすい環境(育休に限らず)や、知識の共有(業務的知識、技術的知識)、相手の考え方を知る面でも非常に良いと感じています。 特に相手の考え方を知るという面では、タスクをもらい、期限までにレビューをしてもらいマージするのとは異なり、私にとって非常に大きな学びと発見をもたらしてくれています。衝突することもありますが、そこからの議論でより良い意思決定ができていると感じています。

触れる技術の幅が非常に広い

SaaS Product Teamでは非常に様々な技術に触れます。 チーム毎に採用している技術は異なりますし、チーム内でも触れる技術はいくつもあります。 例えば、今私が参画しているプロジェクトでは、TypeScript、Python、Kotlin、Elixirを使用していますし、CI/CD、インフラ面もチームで担当します。ここでのチームで担当とは、チームの中でインフラやる人、Kotlinやる人のように分かれているわけではなく、チームのメンバー全員で担当するという意味です。

また、チームの入れ替えもあるので、あの技術を触っているチームに行きたいというのがある場合、本人の希望を最大限尊重し、希望するチームに移動することもできます。

私も一つのプロジェクトでこれだけ多くの技術に触れたのは、SaaS Product Teamが初めてでした。 多くの技術に触れられるSaaS Product Teamは非常に楽しく、学びになりました。

SaaS Product Teamでは非常に多くを学び、多くの驚きと発見のある半年間でした。しかしSaaS Product Teamはまだまだ成長していきます。

SaaS Product Teamがどのようなチームなのかは以下をご覧ください。 journal.uzabase.com

少しでも興味のある方は以下からエントリーお願いします! apply.workable.com

ペアプログラミングはXPの5つの価値をエクストリームにする

SaaS Product Team の野口です。

以前にもいくつかの記事で触れたように、SaaS Product Team では XP(エクストリーム・プログラミング)をベースとしたチーム開発に取り組んでおり、ほぼ全ての作業をペアで行っています。*1

かく言う私もこのチームに入ってから 1 年以上の間 *2、日々ペアプログラミングに取り組む中でわかってきたことがあるので、この記事で共有したいと思います。

XP はうまくいくことを極限(エクストリーム)まで推し進めることから生まれた

Kent Beck の『エクストリームプログラミング』の「はじめに」には、以下の記述があります。

本書は、良いソフトウェア開発チームの共通点を私なりにまとめたものだ。個人的にうまくできたことや、うまくできているところを目の当たりにしたことについて、私が考える最も純粋で最も「エクストリーム」な形で抽出している。(『エクストリームプログラミング』pp. xvii、強調は筆者)

また、インタビューでこのようにも語っています。

チームに、私が必要不可欠だと思うこと全てのツマミを10に上げ、それ以外はすべて省いてくれと求めました。(翻訳を Wikipedia「エクストリーム・プログラミング」より引用、強調は筆者)

このように、XP は、Kent Beck のソフトウェア開発における体験からうまくいっていたことを極限まで推し進めることから生まれたと言えます。

ペアプログラミングは XP の 5 つの価値を極限まで推し進める

XP では、チーム開発を導く共通の価値を 5 つ定めています。

全員がチームにとって大切なことに集中するとしたら、何に集中すべきだろうか? XP では、開発を導く 5 つの価値を採用している。コミュニケーション、シンプリシティ、フィードバック、勇気、リスペクトだ。(『エクストリームプログラミング』pp. 16)

ペアプログラミングは XP の主要プラクティスの 1 つですが、個人的には XP のプラクティスの中でも最も核心に近く、XP を特徴づけるプラクティスだと考えています。なぜなら、ペアプログラミングは XP の 5 つの価値をいずれも極限まで推し進めるプラクティスだからです。

以下、5 つの価値それぞれについて、その理由を説明していきます。

注記

SaaS Product Team は全体で数十人のチームです。現在は SPEEDA / INITIAL / FORCAS Sales という 3 つの製品を扱っており、製品や、マイクロサービス・マイクロフロントエンドの単位で数名程度の小さなチームに分かれて日々の開発を行っています。以降の文章で出てくる「チーム」という言葉は、特に断りがない場合、この「数名程度のチーム」を指しているとご理解ください。

コミュニケーション

ペアプログラミングでは、常にコミュニケーションを取ります。

「常に」とはどういう意味でしょうか。文字通り、「常に」です。SaaS Product Team で愛用している「ペアプロの心得」にも、こう書かれています。

6.ドライバーをしているときに進行中の作業についてこれから何をするのか、いま何をしているのかについて話してますか? ペアプログラミングでは、15秒の沈黙ですら長すぎます。(強調は筆者)

このように、ペアプログラミングには絶え間ないコミュニケーションがあります。ずっとペアプログラミングをしていると、ずっとコミュニケーションを取り続けることになります。エクストリームなコミュニケーションです。

また、チーム全体でペアプログラミングに取り組んでいる場合、継続的なペア交代によって、ペア間のみならずチーム内でのコミュニケーションも取り続けることをシステムに組み込むことができます。私たちのチームでは、たとえば 1 時間に 1 回程度ペアの交代を行っています。具体的な交代の仕方については以下の記事に詳しいので、ご参照ください。

tech.uzabase.com

なお、この記事にも書かれているように、チーム間でのメンバーの入れ替えも行っていれば、チーム内のみならずチームをまたいだコミュニケーションを取り続けることもシステムに組み込むことができます。このようなチームメンバーの入れ替えが機能するのも、やはりペアプログラミングによって継続的なコミュニケーションと知識の撹拌が実現できているからだと思います。

シンプリシティ

シンプリシティを保つのは、難しいことです。シンプリシティの価値を十分理解したプログラマーでも、常にその価値を尊重できるとは限りません。

ペアプログラミングには、常に 2 人の頭脳、2 人の目があります。1 人がシンプリシティに反してしまいそうになったとき、もう 1 人がそれに気づかせてくれます。設計判断に迷ったとき、あるいはシンプリシティに反するような設計・実装を行ってしまっているように見えたとき、「最もシンプルで、うまくいきそうなものは何ですか?」(「エクストリームプログラミング」pp. 17)という質問を 2 人のうちどちらかが発することができます。そうやって、常にシンプリシティを保ち続けることができます。

また、ペアプログラミングでは常にコミュニケーションを取り続けているので、今すぐに必要とは限らない機能に気づいたら実装を延期したり、2 つの機能に同時に取り組んでいることに気づいたらストーリーを分割したり、といった判断も即座に行うことができます。目の前のコードだけに限らず、機能(ストーリー)やプロジェクトマネジメントの領域までを含む、エクストリームなシンプリシティです。

フィードバック

コードレビューというプラクティスがあります。これは、ある人が書いたコードに別の人がフィードバックを与えるための 1 つのやり方です。

ペアプログラミングでは、ペアのパートナーは、実装が完了してからではなく、常にフィードバックを与え合います。コードレビューのときを待つ必要はなく、むしろ常にコードレビューというフィードバックをしている状態と言えます。エクストリームなフィードバックです。

ペアで継続的にフィードバックを与え合えば、1 人でプログラミングしていたときよりもよい設計や実装を導くことができます。「ペアプロの心得」にもこう書かれています。

17.パートナー同士で設計、方向性、技法についての対立は起きていますか? 対立が起きていなければ、ペアは機能障害を起こしています。

対立から対話が生まれ、2 人の中で、またチームにとって真に最適な解が導かれます。

なお、SaaS Product Team におけるペアプログラミングとコードレビューとの関係については、以下の記事で考察されています。

tech.uzabase.com

個人的には、XP の価値・原則・プラクティスをすべて取り入れて活動していれば、別途コードレビューを行う必要が生じる可能性は低いと考えています。(もちろん、チームの状態や開発する対象にもよりますが)

勇気

ペアプログラミングの中には、いたるところで勇気を発揮する機会があります。

  • ペアのパートナーに、技術的な対立を生むようなフィードバックを与える。
  • 相手が何をしているのかわからないとき、手を止めて説明してもらったり、あえて自分が作業する。
  • まずい設計や重大な問題を見つけたとき、見て見ぬ振りをせずそれに向き合う。
  • 難しい問題があるとき、手探りでもとにかく進んでみる。
  • 進んできた方向がシンプリシティなどの観点から間違っていたとわかったとき、立ち止まって引き返す。
  • 行き詰まったとき、もっと頑張るのではなく、あえて休憩を取る。

そして、これらの機会は、ペアで作業しているからこそ、最大の機会となります。1 人では勇気が足りずにできないことも、2 人ならできます。エクストリームな勇気です。

といっても、勇気がすべてではありません。

勇気のみでは危険だが、他の価値と合わせれば強力だ。(『エクストリームプログラミング』pp. 19)

ペアプログラミングをしていれば、2 人のものの見方を合わせて、勇気と他の価値とのバランスを取ることができます。バランスが取れているとわかるからこそ、勇気を持って踏み出すことができます。

リスペクト

ペアプログラミングをしていると、常にペアのパートナーのコンテキストを取り込みながらコードを書くことになります。

それは、リスペクトを育む場所です。相手が何を大事にしてコードを書いているのか。何が得意で、何が苦手なのか。結果として書かれたコード。そのすべてをリスペクトしたとき、ペアプログラミングはうまくいきます。そして、チームが書いたコードへの将来のリスペクトにもつながります。「これはあいつ(ら)が書いたコードだから」などという言葉はそこには生まれません。

「ペアプロの心得」にもこう書かれています。

11.パートナーの仕事は自分の仕事であることを認識していますか? ペアプログラミングは、お互いに100%の責任を持つ共同作業です。「君の設計にバグがあるよ」「そのバグは、君の分担部分が原因だ」などと言ったり、考えたりすることは許されません。

ペアプログラミングでは、すべては「私たちが書いたコード」です。ペアプログラミングは、ペアのパートナーのアイデアや、今まさに書かれたコードをリスペクトし続けることであり、エクストリームなリスペクトです。

1 人でプログラミングをしていても、他の人や他の人が書いたコードをリスペクトすることはできます。そして、ペアプログラミングをしていれば、常にリスペクトを育む機会があります。

旅は続く

この記事では、ペアプログラミングが、XP の 5 つの価値をエクストリームにするのに重要な役割を担うことをお話ししてきました。

私見ですが、ペアプログラミングというプラクティスから XP の 5 つの価値への直接の影響があることを意識すると、ペアプログラミングへの取り組みの質も、そうしない場合とは少し変わってくるように思います。

『エクストリームプログラミング』では、XP のプラクティスは組み合わせて使うのがよいと言われています。

XP のプラクティスは組み合わせたほうがうまくいく。一度にひとつだけプラクティスを選んでも改善は見られるだろう。だが、組み合わせて使うようになれば、劇的な改善が見られるはずだ。プラクティスの相互作用が、その効果を増幅させるのである。(『エクストリームプログラミング』pp.34)

私が日々ペアプログラミングやテスト駆動開発を始めとする XP のプラクティスに取り組む中で、このことが意味するところがようやく少しずつわかってきつつあると感じます。それぞれのプラクティスは原則を通して価値を支え、それぞれの価値は支え合っています。

一緒にペアプログラミングと XP を探求しませんか?

私たち SaaS Product Team では、「技術力で、ビジネスをリードする」をミッション、「阿吽の呼吸で走り、進化する流体的組織」をビジョンとして、日々精力的にサービスの開発・運用に取り組んでいます。

そして、一緒にペアプログラミングや XP を探求し、ミッション・ビジョンの実現に向かって進んでくれる仲間を絶賛募集中です。

apply.workable.com

XP やアジャイルのマスターである必要はなく、「TDD、個人ではやってみたけど業務でもがっつりやってみたいんだよな」とか、「ペアプロ、たまにやってるけどもっとやりたいんだよな」といったレベルでも構いません。少しでも興味のある方はぜひご応募いただけると幸いです!

ペアプログラミングをしているのはわかったけど、組織文化についてもっと色々知りたいな、という方は、去年私が入社したばかりの頃に書いたこの記事も見ていただければと思います。

tech.uzabase.com

*1:コロナ禍によって原則リモート勤務となってからも、ペアプログラミングの実践を続けています。さまざまな方法を探索した結果、現在は当初に比べるとかなり快適にリモートでのペアプログラミングを実現できています。

*2:過去の経験も含めると、3 年くらい。

Smalltalkで『オブジェクト指向設計実践ガイド』の「第4章 柔軟なインタフェースをつくる」を考える

今日は。 SPEEDA を開発している濱口です。

前回の続きですが、この章にはコードが出てこないため、
前回までのようないわゆる写経にはなりませんでした。

そもそも、この章の趣旨のひとつとして、コードを書かずシーケンス図を用いることで
かんたんにインタフェースの可能性を探索できる、というのもあります。

今回は、著者の主張とは逸れますが、
テストコードを書きながらインタフェースの可能性を探索する、ということを試しました。
そこで、少し Smalltalk のコードが出てきます。

f:id:yhamaro:20201130101002j:plain
すこしの Smalltalk あります

視点を変える

これまでの章では、クラス内部の設計に注目してきました(単一責任のクラスをつくる、依存関係を管理する)。
この章とつぎの章では、

"オブジェクト指向アプリケーションは「クラスから成り立つ」のですが、メッセージによって「定義される」のです。"

"オブジェクトが存在するからメッセージを送るのではありません。メッセージを送るためにオブジェクトは存在するのです。"

などと書いているように、「メッセージのやりとり」というアプリケーションの動的側面こそが本質であり、その視点でも設計を行う必要があると説いています。
この章ではクラスの外部に対し何を公開するか(パブリックインタフェース)を、
次の章ではクラスから独立した(クラス横断的な)インタフェースについて記されています。

パブリックインタフェースの見つけ方

では、どうやってパブリックインタフェースを設計していくか。
著者は以下の方法を紹介しています。

ツール

あくまで「一過性の方法」という前提ですが、有効な方法としてシーケンス図を勧めています。

視点

デメテルの法則からの逸脱を、パブリックインタフェースが欠けているオブジェクトがあるというサインと捉えること、などの具体的な助言もありますが、
先程も挙げたような視点の転換を強調しています。

"基本的な設計の質問を、「このクラスが必要なのは知っているけれど、これは何をすべきなんだろう」から、「このメッセージを送る必要があるけれど、だれが応答すべきなんだろう」へ変えることが、キャリア転向への第一歩です。"

この視点に立った設計の進化の過程を、シーケンス図を用いて三段階で表現しています。
シーケンス図を使った説明はこの本を読んで頂くとして、
以下ではテストコードを使って設計の進化の過程をシミュレートしていきます。

パブリックインタフェース進化の三段階

以下のユースケースを想定します。

"「旅行が開始されるためには、使われる自転車がすべて整備されていることを確実にする必要がある」"

※ モックオブジェクトのフレームワークに Mocketry を使っています。

Stage.0

"私は自分が何を望んでいるかを知っているし、あなたがそれをどのようにやるかも知っているよ"

ここでいう「私」は trip で、「あなた」は mechanic です。

testPrepare
    | aTrip aMechanic bicycles |
    aMechanic := Mock new.
    bicycles := #(#bicycle1 #bicycle2).
    aTrip := Trip bicycles: bicycles mechanic: aMechanic .
    
    aTrip prepare.

    aMechanic should receive cleanBicycle: #bicycle1.
    aMechanic should receive cleanBicycle: #bicycle2.
    aMechanic should receive pumpTires: #bicycle1.
    aMechanic should receive pumpTires: #bicycle2.
    aMechanic should receive lubeChain: #bicycle1.
    aMechanic should receive lubeChain: #bicycle2.
    aMechanic should receive checkBrakes: #bicycle1.
    aMechanic should receive checkBrakes: #bicycle2.

trip のテストを書いているのに mechanic についてのバリデーションが多い、
ひいては、 trip が mechanic の詳細を知りすぎているな、ということが気づくことができます。
つまり、mechanic のパブリックインタフェースは詳細を外部に晒しすぎです。

この状態では、 mechanic の責務の内容の変更が trip にも影響してしまいます。

Stage.1

"私は自分が何を望んでいるかを知っていて、あなたが何をするかも知っているよ"

testPrepare
    | aTrip aMechanic bicycles |
    aMechanic := Mock new.
    bicycles := #(#bicycle1 #bicycle2).
    aTrip := Trip bicycles: bicycles mechanic: aMechanic.
    
    aTrip prepare.

    aMechanic should receive prepareBicycle: #bicycle1.
    aMechanic should receive prepareBicycle: #bicycle2.

Stage.0 の課題は大分解消していますが、 trip はまだコンテキストを抱えています。
mechanic という具体に bicycle を渡せば、 prepareBicycle という行為を行ってくれることを知っている、というものです。
それがそのままテストを書く手間となって現れています。

この状態では、mechanic の準備とその使い方に関する知識が前提となるため、 trip の再利用がしにくいです。

Stage.2

"私は自分が何を望んでいるかを知っているし、あなたがあなたの担当部分をやってくれると信じているよ"

testPrepare
    | aTrip aPreparer |
    aTrip := Trip new.
    aPreparer := Mock new.
    aTrip add: aPreparer.
    
    aTrip prepare.

    aPreparer should receive prepareTrip: aTrip.

コンテキストは最小になりました。
mechanic は preparer という抽象的で注入できるもののひとつに過ぎなくなり、
trip から bicycle を渡す必要も無くなりました。(そもそも bicycle を能動的に求めるのは mechanic であるべき)
Stage.0 および Stage.1 では、 trip の相手に対するメッセージが how でしたが、 what (旅行の準備をしてくれ)になりました。

結論

上記で見てきた限りでは、インタフェースのまずさはテストコードにも如実に現れていました。
パブリックインタフェースの探索は、手軽さの面でシーケンス図が勝るかもしれませんが、
テストを必ず書くという前提では、テストコードで行う、でよいと思いました。

Vue.jsでComposition APIを使ってクリーンアーキテクチャ

こんにちは!
Saas Product Teamの板倉です。

今回は少し前にバージョン3がリリースされたVue.jsとComposition APIを使ってクリーンアーキテクチャをどう組むのかを書いてみたいと思います。
クリーンアーキテクチャについてはこちらを参照ください

今回のエントリーで使用したバージョンは以下の通りです。

Vue: 3.0.0
Typescript: 3.9.7

作成したコードはこちら

準備

まずはプロジェクトを作っていきましょう!
vue-cliを使って作っていきます。
vue-cliが入っていない方は yarn global add @vue/cli を実行してインストールしましょう。

vue create <PROJECT_NAME>

Manually select features を選択して Typescript を追加で選択し、Vueのバージョンを3とします。
全体としては、以下のように選択しました。

Vue CLI v4.5.9
? Please pick a preset: Manually select features
? Check the features needed for your project: Choose Vue version, Babel, TS, Linter
? Choose a version of Vue.js that you want to start the project with 3.x (Preview)
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)?
 Yes
? Pick a linter / formatter config: Prettier
? Pick additional lint features: Lint on save
? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated config files

始める前に

そもそもComposition APIって何?

コンポーネントロジックの柔軟な構成を可能にする、関数ベースのAPIです。
こちらはVue2でも使用可能です。
Vue2ではオプショナルなプラグインとして提供されていましたが、Vue3では標準で使用可能となりました。
※ 2020年11月時点ではVue3はまだIE11に対応していません。レガシーブラウザに対応する必要がある方はVue2.xを使いましょう。

Composition API RFC | Vue Composition API

作っていきましょう

モジュールを作る

クリーンアーキテクチャの登場人物である各レイヤーのモジュールを作っていきます。
依存関係については先に挙げた作成コードをご覧いただければと思いますので、説明については割愛します。

  • Entity
  • UseCase
  • Gateway
  • Controller
  • Presenter
  • Driver

上記の他に、画面に表示する内容を管理するためのViewStateを作ります。

モジュールの準備

Composition APIの inject, provideを使って実装します。
inject, provideは setup関数から実行する必要がありますので、モジュールの登録は Application のセットアップで行います。

export default defineComponent({
  name: "App",
  setup() {
    provide(Keys.TaskViewStateKey, reactive(new TaskViewState()));
    provide(Keys.TaskStorageKey, new TaskStorage());
    provide(
      Keys.TaskGatewayKey,
      new TaskGateway(inject(Keys.TaskStorageKey)!!)
    );
    provide(
      Keys.TaskPresenterKey,
      new TaskPresenter(inject(Keys.TaskViewStateKey)!!)
    );
    provide(
      Keys.TaskUseCaseKey,
      new TaskUseCase(
        inject(Keys.TaskGatewayKey)!!,
        inject(Keys.TaskPresenterKey)!!
      )
    );
    provide(
      Keys.TaskControllerKey,
      new TaskController(inject(Keys.TaskUseCaseKey)!!)
    );
  }
});

ここで大事なのは画面で参照するViewStateは reactive で包むことです。
そうすることでViewStateの値を更新するだけで画面が更新されるようになります。

Componentから利用する

VueComponentでモジュールを取得するのも同じようにsetup関数で行います。

<template>
  <h1>タスク一覧</h1>
  <task-list :tasks="state.values"></task-list>
</template>
export default defineComponent({
  name: "TodoList",
  components: {
    TaskList
  },
  setup: () => {
    const controller = inject(Keys.TaskUseCaseKey)!!;
    const state = inject(Keys.TaskViewStateKey)!!;
    onBeforeMount(() => {
      controller.find();
    });
    return {
      state
    }
  }
});

今回作ったアーキテクチャの処理の流れ

以下の図は処理の流れを簡単に示したものです。

f:id:diskit:20201120151333j:plain

VueComponentでは値を取得して更新するのではなく、処理の呼び出しを行うだけです。
上の図の青い部分以外はフレームワークに依存しないので、テストも書きやすいです!

まとめ

Vuexを使うと少し頑張らないとクリーンアーキテクチャは組みづらかったのですが、Composition APIを使うことでより簡単に組むことができると思いました。
作るアプリケーションのサイズによってはtoo muchだなと思われるかもしれません。状況に応じて崩すのもありだと思います。
ただ、しっかりとアーキテクチャを作っておくことでアプリケーションの成長に伴って手を出しづらくなるのを予め防ぎやすくなると思います。

さいごに

一緒にプロダクトを開発する仲間を募集しています!
Saas Product Teamってどんな感じなの?という疑問に対しては、以下の記事が参考になるかと思います!

journal.uzabase.com

少しでも興味のある方は一度お話ししませんか?
以下のリンクからエントリーしていただけると嬉しいです

SPEEDA - ソフトウェアエンジニア(サーバー/フロント) - Uzabase, Inc.

WebComponentsを使ってみよう(その1)

こんにちは、SaaS Product Teamのとみたです。

今回は、WebComponentsについて調べてみたことを書きます。その1として、 カスタムエレメント に注目して書いています。ほかの項目については、また別の機会に書きます。

また、今回のサンプルコードと、デモです。

WebComponentsとは

カスタムエレメント、ShadowDOM、HTMLTemplateといった技術的要素をまとめた言葉です。 カスタムエレメントとは、divinputのようなHTMLElementと同様にmy-web-componentsのように独自のコンポーネントを定義し、使うことができる仕組みです。 コンポーネントの利用者は、自分のアプリケーションに<my-web-components>という感じでコンポーネントを差し込むだけで、inputなどのエレメントと同じように機能させることができるようになります。

ShadowDOMとは、外部からアクセスできないようにしたDOMツリーを構築することができるようになる要素のことです。ShadowDOMをルートエレメントとして、ぶら下げたDOMツリーは外側のCSSに影響されず、querySelectorなどで列挙することが難しくなります。これにより、スタイルやスクリプト化をより自由に行えるようになります。

HTMLTemplateとは、template, slotという特殊なタグのことを指します。DOMの構造を再利用しやすくする機能です。

マイクロフロントエンドのやりかたの一つとして挙げられることが多いようです。

iframeとの違い

マイクロフロントエンドとして、iframeでアプリケーションを埋め込む場合がありますね(embedやobjectと呼ばれるさらに古いものもあるようですが、よく知りません)。たとえば、このはてなブログもポップアップするエディター画面はiframeで埋め込まれています。WebComponentsもiframeもアプリケーションに何かを埋め込むという点では同じことができます。

大きな違いは何かというと、動作するホストでしょう。

iframeはアプリケーションとは別のURLのhtmlを埋め込むことができ、iframe内では親のホストとは異なるホストで動作します。iframe用のアプリケーションを提供する側はCORSを気にする必要がありません。例えば、Google Mapをiframeで埋め込んでいますが、どこに埋め込まれているかを気にせずに利用することができています。

一方で、WebComponentsは親のホスト上で動作することになるため、コンポーネントを提供する側はCORSを考慮したWebAPIを用意する必要があります。

他にも、カスタムエレメントは他のHTMLElementと同様に周りのエレメントの幅や高さに影響されますが、iframeは幅や高さが影響されず、iframe内でスクロールします。これは利点でもありますが、スクロールさせたくない場合には欠点にもなるでしょう。

俺のカスタムエレメントを作ってみる

1. 時計

まずは、時計を作成しましょう。

これで最低限、カスタムエレメントをどのように定義し、描画するかを学びます。

class SimpleClock extends HTMLElement {
  constructor() {
    super();
    // 1. HTMLから分離されたノード(ShadowDOM)を作成する。
    //    これで、親のドキュメントのCSSなどに影響されない環境を作成できます。
    this.shadow = this.attachShadow({mode: 'open'}); 
    // 2. 今の時間を表示する h1 エレメントを ShadowDOM 下に作成する。
    const now = new Date();
    const clockElement = document.createElement("h1");
    clockElement.textContent = `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`;
    this.shadow.append(clockElement);
  }
}

(function main() {
  // 3. simple-clock という名前でSimpleClockクラスを登録する
  customElements.define("simple-clock", SimpleClock);
})();

このJSをhtmlから呼び出して、<simple-clock>というカスタムエレメントを配置すれば、ページをロードした時間が表示されるようになるでしょう。

<!-- simple-clock.jsという名前でアクセスできるものとする -->
<script src="simple-clock.js"></script>
<simple-clock></simple-clock>
※注意点
  • カスタムエレメントの名前はdivinputなどの通常の要素と区別するため、ハイフンで区切られた名前である必要があります。

2. チャート

続いて、グラフを表示しましょう。チャートは外からデータを受け取り、そのデータをグラフに描画します。チャートライブラリとしてC3.jsを利用します。

ここでは、外からデータを受け取る方法と、独自のスタイルシートを適用する方法を学びます。

import * as c3 from "c3";

class SimpleChart extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: "open" });
    // 1. ShadowRootのinnerHTMLに link タグを入れることで、ShadowRoot内限定で外部のCSSをロードできます。
    //    style タグも利用できます。
    this.shadowRoot.innerHTML = `
      <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/c3/0.7.20/c3.css">
      <div></div>
    `;
    // 2. ここは重要ではありませんが、c3.jsはShadowRootに直接バインドできなかったので div エレメントを用意しています。
    this.div = this.shadowRoot.querySelector("div");
    // 3. attribute "columns" に文字列でデータが渡されることを想定しています。
    //    '[["data1", 3, 2, 1], ["data2", 1, 2, 3], ...]' のような c3.js の columns プロパティ用のデータです。
    const columns = JSON.parse(this.getAttribute("columns"));
    this.renderedChart = c3.generate({
      bindto: this.div,
      data: {
        columns,
      },
    });  
  }
}

これは以下のように利用できます。

<simple-chart columns='[["data1", 1, 2, 3], ["data2", 10, 9, 8]]'></simple-chart>
※注意点
  • 外からなんらかのデータを渡すには、jsxのpropsなどと同様にAttributesを利用します。なお、通常のHTMLタグと同様文字列のみが利用できます。AttributeでなくWebComponentsのエレメントのプロパティにデータをセットすることもあるかもしれませんが、あまり良い方法ではないでしょう。
  • CSSを読み込む方法はこちらを参考にするとよりよいでしょう。

3. Attributesが更新されたら再描画する

class SimpleFormatter extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: "open" });
    // 1. 描画には render(再描画でも使う) を使うようにします
    this.render();
  }

  static get observedAttributes() {
    // 2. 変更を監視するAttributeの名前を配列で複数指定できます。
    //    ここに書かれたAttributeが変更された場合、attributeChangedCallbackが呼び出されます。
    return ["name"];
  }

  attributeChangedCallback() {
    // 3. Attributeが更新されたら、attributeChangedCallbackが呼ばれます。
    //    isConnectedがtrueであれば、すでに描画済みです。
    //    attributeChangedCallbackは初回の描画よりも先に呼ばれることがあり、
    //    その際にrenderするのを避けるためにチェックしています。
    if (this.isConnected) {
      this.render();
    }
  }

  render() {
    const name = this.dataset.name;
    this.shadow.textContent = name;
  }
} 

Reactなどからこのように呼び出すことができます。

const App = () => {
  const name = ...
  return <simple-name name={name} />;
}

まとめ

WebComponentsの基本的な作り方を書きました。

  • ShadowRootを使えばスタイルが外部に影響されないものを作ることができる(外からスタイルを適用する方法もある)
    • 今回はShadowRootしか使っていませんが、代わりに普通のdivエレメントなどを使うことも可能です。
    • また、ShadowRootはWebComponents専用の機能ではなく、普通に利用できます。
  • カスタムエレメントのタグ名には - ハイフンが必須
  • CSSは link タグや style で適用できる

サンプルコードの話

  • components/clock: 時計のカスタムエレメントです。
    • Reactで書かれていて、250ms間隔で時間を更新するようになっています。
  • components/chart: チャートのカスタムエレメントです。
    • 素のjsとc3jsで書かれています。
    • チャートの上にinput要素を表にして並べています。ここに数値を入れたり、+で要素を増やしたりすると、チャートが更新されるようになっています。attributeChangedCallbackで更新されます。
    • c3.js用のCSSをCDNから取得しています。adoptedStyleSheetsの機能を使いたかったのですが、なぜか利用できませんでした。まだ対応してないのでしょうか。
  • app: 上記2つのカスタムエレメントを利用しているアプリケーションです。
    • Reactで書かれています。

ペアプログラミングではいつコードレビューするのか?

こんにちは、SaaS Productチームの比嘉です。

私たちSaaS Product チームは常日頃からペアプログラミングを行っています。

チームペアプロの細かい流れは過去に鈴木さんが紹介しています。

tech.uzabase.com

そんな中、あるときエンジニアの友人から質問されました。

「ペアプログラミングではいつコードレビューするの?」

話を聞いてみると、その会社ではペアプロを導入し始めたのですが、従来から行なっているコードレビューと役割が重複していることに気づいたそうです。

そこで今回はペアプログラミングとコードレビュー*1について書いてみようと思います。

離れてペアプロする猫と熊の画像です。コロナ時代はリモートペアプロ中です。
時世を鑑みてリモートペアプロ中

なぜコードレビューを行うのか?

なぜコードレビューを行うのでしょうか?

Wikipediaによると、コードレビューには次の効果が期待できるとされています。

  1. レビューで発見された同様・類似バグについてレビュー参加者内で共通認識を図ることができる。
  2. バグの隠蔽を減少させることが期待できる。
  3. レビューを行うことへの意識により、人に見せるコードを書くようになるため可読性が向上する。
  4. コーディング規約等に対する各自の認識のずれを修正することができる。

またきのこ14では次のように述べられています。

コードレビューの目的は、ただコードの誤りを修正するだけではありません。重要なのは、チーム全員に同じ知識を共有させること、またコーディングにおいて全員が守るべきガイドラインを確立することです。

つまり、コードレビューにはコードに問題について指摘したりチーム内で知識を共有したりする目的があるということです。

ペアプロはいつコードレビューしている?

ペアプロでは常にコードレビューを行っています。ペアプロはドライバー(キーボードを使う人)とナビゲーター(キーボードを使わない人)をお互いに交換して作業を行います。このナビゲーターがコードレビューをしているのです!

ナビゲーターがいると、コードを書いているそばから問題点をつかんで対処することができます。また、ドライバーは書いているコードをナビゲーターに見せることになるため、可読性を考慮してコードを書くことになります。

チームでペアプロを行えば、定期的にペアを交換することになります。ペアを交換することで同じコードを複数人が触ることになります。複数人で触ることできのこ14「チーム全員に同じ知識を共有させること」が可能になります。

ペアプロでコードレビューできてる?

ちょっと待って!ペアプロで行われているコードレビューは信頼しても良いのでしょうか? チームメンバーを集めてじっくりコードレビューした方がいいじゃないでしょうか?

調べてみると、ペアプロをしつつもコードレビューをする現場*2もあるようです。コードレビューの時間を作ることには、ペアを交代した時に抜け落ちた知識(やるはずだったリファクタリングや仕様の抜け漏れ)を集めたり、できるだけコードに関わっていない開発者のレビューを取り入れる意図があるようです。

一緒にコードを書くことによってレビューの視点に偏りを持ってしまうことは確かにありそうです。また、知識の断片化についてはどう対処していけばいいのでしょうか?

SaaS Product チームの場合

冒頭の繰り返しですが、Productチームではチームでペアプログラミングを採用しています。目的はシンプルに生産性をあげるためです。また、特別に時間をとってコードレビューはしていません。すべてはペアプロの中で行われています。コードレビューをしない代わりに仕組みや文化が補っています。

その1つがテスト駆動開発です。私たちはドキュメントを作成していません(一部、手順書などはあります)。仕様はE2Eテストに書いています。ペアが交代したときに今のコードに何が足りないのか、これから何をすべきかはテストが通っているかどうかで判断することができます。

また「人に聞く」ことを推奨しています。仕様の抜け漏れの確認や、コードを書く上で決断に悩んでいる場合はより詳しい他の人に聞いています。たとえば、テストが仕様を満たせているのかをテストエンジニアに相談したり、クラスの責務分けをベテランエンジニアに相談したりしています。Productチームでは頻繁にチームメンバーが入れ替わります。もしかしたら他のチームに情報を持っている人がいるかもしれません。その時は個々のチームを越えて話にいくことも日常茶飯事です*3

まとめ

今回は「ペアプログラミングではいつコードレビューするの?」について答えてみました。 ペアプログラミングでは常にコードレビューをしています。そしてそれにはメリット・デメリットがありました。

SaaS Productチームでは生産性をあげるためにペアプロをしています。メリットを享受しつつも、デメリットを補う文化があることを紹介しました。

この記事がペアプロで悩んでいる方の一助になれれば幸いです。

*1:この記事にあるコードレビューは、プルリクエストやマージリクエストなどであげられたコードをチェックして指摘したり承認したりする行為を指します。

*2:http://appresso.hatenablog.com/entry/2017/03/03/114855

*3:https://tech.uzabase.com/entry/2019/08/19/154623

EnvoyをFront Proxyとして利用する

こんにちは、ユーザベースのProductチームでSREをやっています阿南です。弊社ではKubebrnetes + Istioを利用してサービスメッシュの構築、マイクロサービスの運用を行っています。Istioでは sidecar proxyとしてEnvoyが利用されていますが、このEnvoyをFront Proxyとしても利用できないかと思い、よく使われる設定について調べてみました。下記目次です。

少し長いので、気になる部分だけでも読んで頂ければ幸いです。 今回作成した全体の設定はこちらにありますので、適宜参照してください。

envoy proxy for frontend · GitHub

また、実際にEnvoyを起動して確認してみたい方は、下記に環境構築について記載しています。

GitHub - tanan/migrate-from-apache-to-envoy

EnvoyをFront Proxyとして利用するメリット

(Front Proxyに限らずですが...) Envoyを利用することで下記のようなメリットがあります。

  • 柔軟なLoadBalancing機能
    • Blue/GreenやWeightを利用して簡単にBalancingの設定を変更することができます。また、rate limitやcircuit breakerの機能も有しているため、適切に設定を行うことでエラーが全体に波及するのを防ぐことができます。
  • control-planeやファイルベースのdynamic configurationが利用できる
    • Apache等では明示的に各プロセスに対してreload/restart等の動作を実行する必要がありますが、Envoyではcontrol-planeを利用して設定をDynamicに反映することができます。Apacheでもupstream先を有効/無効にする等は動的に設定できますが、プロセスがrestartすると状態が消えてしまいます。
  • Observabilityを高めるための他ツールとの連携のしやすさ
    • prometheus / grafana / jaegerとの連携が簡単に実現できるエコシステムが整っています。

Envoyのバージョン

envoyバージョンはv1.16.0-devで、v3 APIを利用しました。

Front ProxyのためのEnvoy Configuration

現在の弊社環境はざっくり下記のような構成になっています。WebサーバとしてApacheを利用し、元々開発されてきたtomcatのモノリスアプリケーションと新しく開発しているマイクロサービス群にPathベースでルーティングをしています。(Apacheとtomcatはon-premiseに構築されています。)

f:id:tanan55:20200927132457p:plain
architecture

今回は、このApacheをEnvoy Proxyに代替するために、基本的な設定を行いましたので、以降でそれぞれの設定を見ていきたいと思います。

80(HTTP),443(HTTPS)でアクセスを受け付ける

listenerを定義することで特定のポートでアクセスを受け付ける事ができます。

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 80 }

TLSの設定をする場合は、filter_chainsにtls_contextを設定します。

tls_context:
  common_tls_context:
    tls_certificates:
      - certificate_chain:
          filename: "/etc/envoy/certs/example-com.crt"
        private_key:
          filename: "/etc/envoy/certs/example-com.key"

ここではfilenameを指定していますが、証明書の内容を直接記載することも可能です。

Pathベースのルーティングを設定する

Routingの設定はlistenerのfilter部分に記載します。

route_config:
  name: backend_route
  virtual_hosts:
  - name: backend
    domains:
    - "www.example.com"
    - "example.com"
    routes:
    - match:
        prefix: "/service/2"
      route:
        prefix_rewrite: "/"
        cluster: service2
        hash_policy:
          - cookie:
              name: balanceid
              ttl: 0s
    - match:
        prefix: "/"
      route:
        cluster: service1

上記の設定は、

  • ドメインはwww.example.com,example.comのみ許可
  • pathが/service/2にマッチした場合、service2のclusterを参照する
  • pathが/にマッチした場合、service1のclusterを参照する

という設定になります。ちなみにpathのルールは上から順に判定されるようで、matchの順番を逆にするとすべてservice1にリクエストが送られるので注意が必要です。

clusterではupstreamのendpointやbalancing ruleを定義します。 lb_policyには、ROUND_ROBIN の他に、 LEAST_REQUEST や後述の RING_HASH などが指定できます。

clusters:
  - name: service1
    connect_timeout: 0.25s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    load_assignment:
      cluster_name: service1
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: service1-1
                port_value: 8080
        - endpoint:
            address:
              socket_address:
                address: service1-2
                port_value: 8080

また、 https_redirect をtrueにすることで、HTTPSへのリダイレクトが可能です

routes:
- match:
    prefix: "/"
  redirect:
    https_redirect: true

レスポンスに特定のヘッダーを付与する

response_headers_to_add に追加したいヘッダーを記載することができます。 注意点としては、v1.8でRouteActionレベルでは本パラメータがdeprecatedになりましたので、Routeレベルで設定する必要があります。 その他、リクエストヘッダーに特定の値を付与できる request_headers_to_addrequest_headers_to_remove もここで指定できます。

response_headers_to_add:
  - header:
      key: "x-frame-options"
      value: "sameorigin"
  - header:
      key: "x-xss-protection"
      value: "1; mode=block"

Healthcheckに失敗した場合、upstreamの対象から除外する

普段の運用ではupstreamに複数のエンドポイントを設定して、正しく応答が返せない場合はリクエストが割り振られないようにしたいケースがよくあると思います。Envoyではhealth_checks をclusterの設定に追加することでヘルスチェックを実施することができます。

health_checks:
  - timeout: 1s
    interval: 10s
    interval_jitter: 1s
    unhealthy_threshold: 6
    healthy_threshold: 2
    http_health_check:
      path: "/healthz"

jitterはinitial_jitterinterval_jitterが指定でき、ヘルスチェックがすべて同時にリクエストされないように、ばらつきを与えたい場合に設定します。 ヘルスチェックに失敗したupstreamはendpointのリストから除外され、healthy_thresholdの数だけ成功するとhealthyとみなされ再度リストに追加されます。 ちなみに初回起動時はhealthy_thresholdに関係なく1回の成功でhealthyとみなされます。

Cookieをもとに、upstreamを固定(sticky session)する

アプリケーション内にセッション情報を持っているようなシステムでは、純粋にROUND ROBINでリクエストが振られるとアプリが正常に動作しなくなってしまいます。そのため、Session Cookie(sessionが続く間だけ有効なcookie)を使って、ユーザーのリクエストを同じupstreamにルーティングするようにするsticky sessionが利用されます。 Envoyではclusterでlb_policy: RING_HASHを指定した上で、下記を設定することにより、sticky sessionが実現できます。

hash_policy:
  - cookie:
      name: balanceid   ## nameは何でもよい
      ttl: 0s

この設定でのポイントはttlです。ttlの有無により下記のように動作が異なります。

  • ttlが指定されない場合、cookieはつくられません。既にあればその値が利用されます。
  • ttlが指定されている場合、かつ、cookieがない場合はcookieを生成します。また、ttlが0sのときはsession cookieになります。

詳細はhashpolicy-cookieのDocumentを参照して下さい。

まとめ

EnvoyをFront Proxyとして利用するための設定を見ていきました。EnvoyのDocumentの読み方を覚えるまでに少し時間がかかりましたが、無事意図通り動作するよう設定できました。 実際に利用する場合は、control-planeを導入してdynamic configurationで設定反映をすることが多いかと思いますが、staticな設定を一度書いてみると理解が早まると思いますので、興味のある方はぜひやってみて下さい。

仲間募集!!

ユーザベースでは、「経済情報で、世界をかえる」 というミッションの実現に向けて、日々邁進しており、SREのメンバーを募集しています。 技術的にも様々なことにチャレンジしてますので少しでも興味を持ってくださった方はこちらまで!