IoTデバイスにおける Feature Flag の実装
Feature Flag は条件分岐などを用いて, デプロイ後に機能を有効化・無効化するための手法です[1]. インターネットに接続されているIoTデバイスであれば, On the Air アップデート(OTA) によって工場出荷後であっても機能を追加・変更することができます. しかし本稿で説明する Feature Flag はOTAに依存せず機能を変更することができます. Feature Flag の利点はOTAのようなデプロイと機能のデリバリを分けることができる点です. これにより, IoTデバイスのようなファームウェア開発においても, トランクベース開発を基本としたCI/CDを構築することが可能になります[2].
そこで本稿では Feature Flag の中でも特に機能リリースを目的とした Release Toggles と, A/Bテストなどで用いられる機能を実験的に変更するための Experiment Toggles を実装します. ぶっちゃけただの設定変更です.
Feature Toggles (aka Feature Flags)
実装概要
実装方法の概略図を以下にシーケンス図として示します. Feature Flag の設定はWebアプリケーション(Web Client)から行い, WebアプリケーションとIoTデバイス(Device)とのやり取りにはMQTTを用います. IoTデバイスはデバイス内部のストレージ(Storage)に Feature Flag の設定を保存し, 起動時に読み込んで機能を切り替えます. また Feature Flag の変更を受け取ったら, ストレージに保存します.
フロー図
環境
システムに利用する環境は以下の通りです. IoTデバイスとしてESP32を使用します.
端末
- ESP32
- ESP-IDF v5.4
MQTTブローカー
- EMQX v5.4.8
フロントエンド
- Vite 6.0.5
- React 19.0.0
- MQTT.js 5.10.3
以下ではESP-IDFによる実装のみを扱います.
Release Toggles の実装
Release Toggles は基本的には条件分岐によって機能を有効化・無効化することができます. ここではインスタンス切り替えることで実現します.
いま, Greeter
クラスを用意し, この機能を Release Toggles で切り替えることを考えます. Greeter
クラスは Greet
メソッドを持ち, 最終的にメッセージを受けたら返信する機能を実装します.
#pragma once
namespace greeter {
class Greeter {
public:
virtual void Greet() noexcept = 0;
};
} // namespace greeter
まず機能を無効化した GreeterDummy
クラスを用意します.
#include "greeter.h"
namespace greeter {
class GreeterDummy : public Greeter {
public:
void Greet() noexcept override;
};
} // namespace greeter
#include "greeter_dummy.h"
void greeter::GreeterDummy::Greet() noexcept {
// NOOP
}
この時点で Greeter
クラスをアプリケーションに組み込むことができます. 機能は動作しないので意味はありませんが, ここでアプリケーションの実装上の設計を行います.
#include "greeter_dummy.h"
std::unique_ptr<greeter::Greeter> greeter_i = std::make_unique<greeter::GreeterDummy>();
void loop() {
greeter_i->Greet();
}
extern "C" {
void app_main(void) {
while (1) {
loop();
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
}
次に機能を実装した GreeterImpl
クラスを用意します. GreeterImpl
ではMQTTからメッセージを受け取ったら返信します.
#pragma once
#include "greeter.h"
#include "mqtt.h"
namespace greeter {
class GreeterImpl : public Greeter {
public:
GreeterImpl(const std::shared_ptr<mqtt_client::Mqtt> mqtt, const std::string_view client_id, const std::string_view topic) noexcept
: mqtt_(mqtt), client_id_(client_id), topic_(topic) {
mqtt_->Subscribe(topic_);
};
void Greet() noexcept override;
private:
const std::shared_ptr<mqtt_client::Mqtt> mqtt_;
const std::string client_id_;
const std::string topic_;
};
} // namespace greeter
#include "greeter_impl.h"
void greeter::GreeterImpl::Greet() noexcept {
auto messages = mqtt_->PopMessages(topic_);
if (messages.empty()) {
return;
}
for (const auto& msg : messages) {
auto m = message::MessageFrom(msg);
if (m == nullptr) {
continue;
}
if (typeid(*m) != typeid(message::GreeterMessage)) {
continue;
}
std::unique_ptr<message::GreeterMessage> greeter_message = std::make_unique<message::GreeterMessage>(dynamic_cast<message::GreeterMessage*>(m.get()));
mqtt_->Publish(topic_, message::GreeterReplyMessage(client_id_, "Hello, " + greeter_message->name + "!").ToJson());
}
}
GreeterImpl
クラスを組み込むために, Release Toggles を実装します. 端的に説明すると, 条件分岐によって Greeter
クラスのインスタンスを切り替えます.
if (greeter_is_enabled) {
greeter_i = std::make_unique<greeter::GreeterImpl>(mqtt, client_id, kGreeterTopic);
} else {
greeter_i = std::make_unique<greeter::GreeterDummy>();
}
この Release Toggles の変更を外部から行うために, MQTTからメッセージを受け取ります. Feature Flag の通知を行うトピックとして feature
を用意します. feature
トピックから以下のようなメッセージを受け取り, Release Toggles を切り替えます.
{
"feature": {
"greeter": true
}
}
これをアプリケーションとして実装すると以下のようになります.
#include "greeter_dummy.h"
#include "greeter_impl.h"
#include "message.h"
#include "mqtt.h"
std::unique_ptr<greeter::Greeter> greeter_i = std::make_unique<greeter::GreeterDummy>();
void setup() {
mqtt->Connect(kMqttBrokerDomain, kMqttBrokerPort);
while (!mqtt->Connected()) {
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
mqtt->Subscribe(kFeatureTopic);
}
void loop() {
auto messages = mqtt->PopMessages(kFeatureTopic);
if (messages.empty()) {
return;
}
for (const auto& msg : messages) {
auto m = message::MessageFrom(msg);
if (m == nullptr) {
continue;
}
if (typeid(*m) != typeid(message::FeatureMessage)) {
continue;
}
std::unique_ptr<message::FeatureMessage> feature_message = std::make_unique<message::FeatureMessage>(dynamic_cast<message::FeatureMessage*>(m.get()));
if (feature_message->greeter_is_enabled) {
greeter_i = std::make_unique<greeter::GreeterImpl>(mqtt, client_id, kGreeterTopic);
} else {
greeter_i = std::make_unique<greeter::GreeterDummy>();
}
}
greeter_i->Greet();
}
extern "C" {
void app_main(void) {
setup();
while (1) {
loop();
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
}
namespace message {
struct Message {
virtual ~Message() = default;
};
struct FeatureMessage : public Message {
FeatureMessage(const bool greeter_is_enabled)
: greeter_is_enabled(greeter_is_enabled), timestamp_format {};
FeatureMessage(const FeatureMessage* message)
: greeter_is_enabled(message->greeter_is_enabled) {};
const bool greeter_is_enabled = false;
}
};
std::unique_ptr<Message> MessageFrom(const std::string_view msg) noexcept;
} // namespace message
この Release Toggles の設定は永続化する必要があります. ここではデバイスのストレージに保存することで永続化します. 起動時にストレージから読み込むことで, デバイスの再起動時にも設定が保持されます. 設定をストレージから読み出したら, MQTTで設定を通知します.
#include "storage_controller.h"
storage_controller::StorageController feature_storage(kStorageNamespace);
auto greeter_flag = feature_storage.ReadByte(kStorageKeyGreeter).value_or(0);
bool greeter_enabled = greeter_flag & kGreeterFlagMask;
mqtt->Publish(kFeatureTopic, message::FeatureMessage(client_id, greeter_enabled).ToJson());
#pragma once
#include <optional>
#include <string_view>
namespace storage_controller {
class StorageController {
public:
std::optional<uint8_t> ReadByte(const std::string_view key) noexcept;
};
} // namespace storage_controller
これで Release Toggles を実装して, その設定を反映することができました.
Experiment Toggles の実装
Experiment Toggles ではA/Bテストのように一つの機能に対して, 複数の実装を提供します. ここでは単純に一つのクラスの中で条件分岐によって実現します.
クラスによる実装
インスタンスの切り替えによって実装することで開放閉鎖原則に従ったコードを書くことができます. とはいえ実装量が増えるため, すぐ消される Feature Flag の実装であれば, 条件分岐で十分でしょう.
enum class FeatureType {
kFeatureA,
kFeatureB,
};
class Feature {
public:
virtual void Exec(FeatureType type) = 0;
}
class FeatureA : public Feature {
public:
void Exec(FeatureType type) override {
if (type != FeatureType::kFeatureA) {
return;
}
// Feature A implementation
}
}
class FeatureB : public Feature {
public:
void Exec(FeatureType type) override {
if (type != FeatureType::kFeatureB) {
return;
}
// Feature B implementation
}
}
FeatureType type = FeatureType::kFeatureA;
std::vector<std::unique_ptr<Feature>> features = {
std::make_unique<FeatureA>(),
std::make_unique<FeatureB>(),
};
for (const auto& feature : features) {
feature->Exec(type);
}
新たに Timestamp
クラスを用意し, このクラスで時刻を通知する機能を提供します. このとき通知する時刻のフォーマットを Experiment Toggles として切り替えます.
#pragma once
#include <chrono>
#include <format>
#include <memory>
#include <string>
#include "message.h"
#include "mqtt.h"
#include "timestamp_format.h"
namespace timestamp {
class Timestamp {
public:
Timestamp(const std::shared_ptr<mqtt_client::Mqtt> mqtt, const std::string_view client_id, const std::string_view topic) noexcept
: mqtt_(mqtt), client_id_(client_id), topic_(topic){};
void Notify(const TimestampFormat format) noexcept;
private:
const std::shared_ptr<mqtt_client::Mqtt> mqtt_;
const std::string client_id_;
const std::string topic_;
bool subscribed_ = false;
};
} // namespace timestamp
#pragma once
namespace timestamp {
enum class TimestampFormat {
kIso8601 = 0,
kEpoch,
};
} // namespace timestamp
Experiment Toggles は TimestampFormat
として Notify
メソッドの引数として受け取るだけなので, 実装そのものはただの条件分岐です.
#include "timestamp.h"
void timestamp::Timestamp::Notify(const timestamp::TimestampFormat format) noexcept {
if (!subscribed_) {
mqtt_->Subscribe(topic_);
subscribed_ = true;
}
auto messages = mqtt_->PopMessages(topic_);
if (messages.empty()) {
return;
}
for (const auto& msg : messages) {
auto m = message::MessageFrom(msg);
if (m == nullptr) {
continue;
}
if (typeid(*m) != typeid(message::TimestampMessage)) {
continue;
}
std::chrono::time_point now = std::chrono::system_clock::now();
std::string timestamp;
switch (format) {
case TimestampFormat::kIso8601:
timestamp = std::format("{:%FT%R:%OS%Ez}", now);
break;
case TimestampFormat::kEpoch:
timestamp = std::to_string(std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()).count());
break;
}
mqtt_->Publish(topic_, message::TimestampReplyMessage(client_id_, timestamp).ToJson());
}
}
これをアプリケーションに組み込みます. Feature Flag の受信と通知に関しては Release Toggles と同様です.
#include "timestamp.h"
#include "message.h"
#include "mqtt.h"
timestamp::Timestamp timestamp_i(mqtt, client_id, kTimestampTopic);
timestamp::TimestampFormat timestamp_format = timestamp::TimestampFormat::kIso8601;
void setup() {
mqtt->Connect(kMqttBrokerDomain, kMqttBrokerPort);
while (!mqtt->Connected()) {
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
mqtt->Subscribe(kFeatureTopic);
}
void loop() {
auto messages = mqtt->PopMessages(kFeatureTopic);
if (messages.empty()) {
return;
}
for (const auto& msg : messages) {
auto m = message::MessageFrom(msg);
if (m == nullptr) {
continue;
}
if (typeid(*m) != typeid(message::FeatureMessage)) {
continue;
}
std::unique_ptr<message::FeatureMessage> feature_message = std::make_unique<message::FeatureMessage>(dynamic_cast<message::FeatureMessage*>(m.get()));
timestamp_format = feature_message->timestamp_format;
}
timestamp.Notify(timestamp_format);
}
extern "C" {
void app_main(void) {
setup();
while (1) {
loop();
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
}
}
Release Toggles と同様にストレージに永続化し, 起動時にMQTTで通知します.
auto timestamp_flag = feature_storage.ReadByte(kStorageKeyTimestamp).value_or(0);
timestamp_format = timestamp::TimestampFormatFrom(timestamp_flag);
mqtt->Publish(kFeatureTopic, message::FeatureMessage(client_id, greeter_enabled, timestamp_format).ToJson());
これで Experiment Toggles も実装できました. どんな用途であれ Feature Flag は一度仕組みを作ってしまえば, 簡単に流用できてしまいます.
ファームウェアにおいてはリソース上の制約があります. Feature Flag を用いることで本来要するリソース以上にリソースを消費してしまったり, 制約を満たさない場合があります. そのため Feature Flag の設計だけでなく, リソースの見積りや制約条件の可否についても検討する必要があります. また動的な Feature Flag だけでなくプリプロセッサなどの静的な設定方法も踏まえて検討することが望ましいでしょう. Feature Flag はあくまでトランクベース開発を支援するための手段の一つに過ぎません. 手段によらず早期に柔軟な設計ができることが重要です.
-
CI/CDが一般的なWebアプリケーションと異なり, OTAによるアップデートはユーザーを含めたクライアント側がコストを負担することになります. そのため単にデプロイ回数を増やすといったアプローチは避けるべきです. 本稿において Feature Flag を扱う意図はトランクベース開発とCI/CDパイプラインの単純化にあります. ↩︎
Discussion