🚩

Spring Boot で Feature Flag を使う

2024/03/18に公開

Spring Profile 機能を用いて Feature Flag によるリリース管理を行います[1].


Feature Toggles (aka Feature Flags)

https://github.com/toms74209200/spring-boot-feature-flag-example

Spring Profile

Spring Profile 機能は, 設定した Profile に応じた Bean を使用することができる機能です[2]. Spring Boot ではインスタンスのDIコンテナへの登録が Spring 起動時の初回のみ行われる singleton スコープがデフォルトのため, この機能を使った Release Toggle の実装のみを扱います.

エンドポイントの有効化

Spring Profile 機能を使って Controller の実装を切り替えることで, エンドポイントの Release Toggle を実現することができます. リリース予定日が決まっていたり, 内部実装前にリリースすることができるようになります.

まずはエンドポイントのインターフェースを作成します. インターフェースがなくても profile の切り替えは可能ですが, 複数の Controller クラスが同じエンドポイントであることを示すため, インターフェースを作成します.

public interface GreeterController {
    ResponseEntity<String> greet();
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/main/java/io/github/toms74209200/controller/GreeterController.java

次に, 内部実装を行った feat-greet profile 用の Controller クラスと, 非公開のためのクラスとを作成します. ここで内部実装は簡単にHTTPステータスコード200と "Hello, World!" を返すだけのものとします. 非公開の場合にはHTTPステータスコード404を返すようにします.

GreeterControllerFeat.java

@Slf4j
@Profile("feat-greet")
@Controller
public class GreeterControllerFeat implements GreeterController {
    @Override
    @GetMapping("/greet")
    public ResponseEntity<String> greet() {
        log.info("Get greet feat-greet");
        return ResponseEntity.ok("Hello, World!");
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/main/java/io/github/toms74209200/controller/GreeterControllerFeat.java

GreeterControllerProd.java

@Slf4j
@Profile("!feat-greet")
@Controller
public class GreeterControllerProd implements GreeterController {
    @Override
    @GetMapping("/greet")
    public ResponseEntity<String> greet() {
        log.info("Get greet production");
        return ResponseEntity.notFound().build();
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/main/java/io/github/toms74209200/controller/GreeterControllerProd.java

@Profile アノテーションの値に ! を付けると, その profile 値が有効でない場合に Component が使用されます[3]. つまり application.propertiesfeat-greet が設定されていれば GreeterControllerFeat が, そうでなければ GreeterControllerProd が使用されます. これにより本番環境の application.properties に変更を加えずに開発中の成果物をリリースすることができます.

開発環境の application.yml.

spring:
  profiles:
    active: foo, bar, feat-greet # /greet が有効になる

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/2b4c5ad75072f817780358f62256d665783c56e5/src/main/resources/application.yml

本番環境の application.yml.

spring:
  profiles:
    active: foo, bar

SpringBootTest での Profile の切り替え

確認のため, SpringBootTest を使ってテストを行います(実際にはより実際に即しており, 可搬性も高い Web API テストによって検証されるのが望ましいでしょう). SpringBootTest では @ActiveProfiles によってテスト時の Profile を指定することができます. なお GreeterControllerProd に対するテストでは feat-greet profile 以外を指定する必要があるため, てきとうに prod という profile を作成しています.

GreeterControllerFeatTest

@ActiveProfiles("feat-greet")
@AutoConfigureMockMvc
@SpringBootTest
public class GreeterControllerFeatTest {

    @Test
    public void testGreet(@Autowired MockMvc mockMvc) throws Exception {
        MvcResult result = mockMvc.perform(get("/greet")).andExpect(status().isOk()).andReturn();
        assertThat(result.getResponse().getContentAsString()).isEqualTo("Hello, World!");
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/test/java/io/github/toms74209200/controller/GreeterControllerFeatTest.java

GreeterControllerProdTest

@ActiveProfiles("prod")
@AutoConfigureMockMvc
@SpringBootTest
public class GreeterControllerProdTest {

    @Test
    public void testGreet(@Autowired MockMvc mockMvc) throws Exception {
        mockMvc.perform(get("/greet")).andExpect(status().isNotFound());
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/test/java/io/github/toms74209200/controller/GreeterControllerProdTest.java

機能の有効化

エンドポイントの有効化と同様に既存のエンドポイントに新たに機能を追加する場合や既存の機能を変更する場合にも利用できます. 今回は既存機能を変更する場合について考えてみます. 前述のエンドポイントの有効化の場合と同様に Service の実装を丸ごと切り替えることでも実現できますが, 今回は変更部分のみを Service クラスから切り出して, その部分のみを切り替えます. 切り替える部分は Bean を使って DI することで実現します.

例として TODO アプリケーションを考えます. GETリクエストに対して登録済みの TODO のリストを返すエンドポイントを作成します. TODOが期限日 deadline と優先度 priority のプロパティを持つとき, リストのソート順を変更することを考えます.

TODOのプロパティ

interface Todo {
  "task": string,
  "done": boolean,
  "deadline": string,
  "priority": number
}

まずは deadline でソートする実装を行います.

TodoService

@AllArgsConstructor
@Service
public class TodoService {

    private final TodoRepository todoRepository;

    public List<Todo> getTodos() {
        List<Todo> todos = todoRepository.selectTodos();
        return todos.stream()
                .sorted(Comparator.comparing(Todo::deadline))
                .collect(Collectors.toList());
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/fe251804e363552617ba03f388caeda8ded389e5/src/main/java/io/github/toms74209200/service/TodoService.java

次に, ソート処理を別クラスに切り出して, DIできるようにします.

TodoService

@AllArgsConstructor
@Service
public class TodoService {

    private final TodoRepository todoRepository;
    private final SortTodo sortTodo;

    public List<Todo> getTodos() {
        List<Todo> todos = todoRepository.selectTodos();
        return sortTodo.sort(todos);
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/main/java/io/github/toms74209200/service/TodoService.java

SortTodoByDeadline

@Slf4j
public class SortTodoByDeadline implements SortTodo {

    @Override
    public List<Todo> sort(List<Todo> todos) {
        log.info("Sorting todos by deadline");
        return todos.stream()
                .sorted(Comparator.comparing(Todo::deadline))
                .collect(Collectors.toList());
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/main/java/io/github/toms74209200/logic/SortTodoByDeadline.java

優先度 priority によるソート処理を行うクラスを作成します.

SortTodoByPriority

@Slf4j
public class SortTodoByPriority implements SortTodo {
    @Override
    public List<Todo> sort(List<Todo> todos) {
        log.info("Sorting todos by priority");
        return todos.stream()
                .sorted(Comparator.comparing(Todo::priority))
                .collect(Collectors.toList());
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/main/java/io/github/toms74209200/logic/SortTodoByPriority.java

これらのクラスをそれぞれ Bean として登録し, Profile によって切り替えることで, Release Toggle を実装することができます. 優先度 priority によるソート処理が feat-priority profile で有効になるようにします. これにより Spring Component 以外の機能でも Feature Flag を実装することができます.

SortTodoBean

@Configuration
public class SortTodoBean {

    private final SortTodo sortTodoByDeadline = new SortTodoByDeadline();
    private final SortTodo sortTodoByPriority = new SortTodoByPriority();

    @Profile("!feat-priority")
    @Bean
    public SortTodo sortTodoByDeadline() {
        return sortTodoByDeadline;
    }

    @Profile("feat-priority")
    @Bean
    public SortTodo sortTodoByPriority() {
        return sortTodoByPriority;
    }
}

https://github.com/toms74209200/spring-boot-feature-flag-example/blob/master/src/main/java/io/github/toms74209200/bean/SortTodoBean.java

まとめ

Spring Profile 機能を使って Feature Flag によるリリース管理を行う方法を紹介しました. application.properties に付与する profile によって, 静的な Feature Flag である Release Toggle を実現することができます.

脚注
  1. Release Toggles | Feature Toggles (aka Feature Flags) | martinfowler.com ↩︎

  2. 正確な定義や詳細は公式ドキュメントや書籍を参考にしてください. 3. Profiles | Core Features | docs.spring.io, 『プロになるためのSpring入門――ゼロからの開発力養成講座』など. ↩︎

  3. If a given profile is prefixed with the NOT operator (!), the annotated component will be registered if the profile is not active — for example, given @Profile({"p1", "!p2"}), registration will occur if profile 'p1' is active or if profile 'p2' is not active. Profile (Spring Framework 6.1.4 API) ↩︎

Discussion