博客 / 詳情

返回

輕量級的灰度&配置平台|得物技術

一、前言

隨着近幾年得物的業務和技術的快速發展,我們不管是在面向C端場景還是B端供應鏈;業務版本的迭代更新,技術架構的不斷升級;不管是業務穩定性還是架構穩定性,業務灰度的能力對我們來説都是一項重要的技術保障,越來越受到我們業務研發的關注。然而,傳統的灰度發佈服務往往過於定製化,缺乏靈活性和通用性,無法滿足不斷變化的業務需求,往往灰度的場景可能通過代碼硬編碼或者簡單的配置中心配置。在這樣的背景下,本文將介紹一種全新的、輕量級的灰度平台,它將為大家的業務帶來全新的灰度體驗。

二、總體架構設計

01.jpg

整體架構模塊的概覽

主要由如下模塊組成

  • 灰度運營平台:為用户提供增刪查改的灰度發佈管理和UI界面;
  • 灰度服務端:為灰度運營平台提供標準的增刪查改功能、權限控制、灰度場景管理和應用接入命名空間;
  • Nacos&Ark: 提供高性能的灰度配置讀取和存儲服務;
  • 灰度SDK: 為研發提供輕量級高性能的灰度判斷API和配置服務。

功能特性説明

  • 系統灰度開關可以在後台運營頁面上進行可視化管理
  • 配置類型可以支持多種開關、灰度、業務配置;
  • 配置的值可以採用富文本形式來進行編輯
  • 能夠支持自定義維度白名單的方式進行灰度;
  • 能夠支持自定義多個分組的方式進行灰度;
  • 能夠支持自定義分組實驗的方式進行灰度;
  • 能夠支持按照指定維度以一定比例進行灰度;
  • 能夠支持自定義維度進行灰度配置;
  • 能夠支持歷史配置一鍵回滾和追溯配置;
  • 能夠方便灰度配置信息進行生命週期管理

適用的場景:

02.jpg

三、灰度服務關鍵設計

我們參考上面的總體架構設計可知,灰度配置的存儲不是關鍵,複用已有的Nacos等或者自研的配置服務中心即可,重點是在SDK和灰度數據結構的設計上。

灰度數據類型設計

首先我們針對不同的場景可能會遇到不同的數據類型灰度,不同的數據類型在規則處理中可能也有特點場景的實現,所以先定義灰度數據類型參考如下:

/**
 * 版本號
 */
VERSION("version"),
/**
 * 字符串類型
 */
STRING("string"),
/**
 * 集合類型
 */
SEGMENT("segment"),

/**
 * 數字類型
 */
NUMBER("number"),

/**
 * 非法規則
 */
NONE("none"),
;

03.jpg

灰度規則設計

有了灰度數據類型的定義,我們需要針對不同的字段數據類型使用不同的灰度規則,如包含關係、完全匹配、正則表達式等。於是就可以自定義各種規則,根據實際的業務場景抽象,規則完全可以再自定義。

IS_IN("in"),
IS_NOT_IN("notIn"),
REGEX("regex"),
NREGEX("nregex"),
EQ("eq"),
NEQ("neq"),
EQUAL_TO("="),
NOT_EQUAL_TO("!="),
GREATER_THAN(">"),
GREATER_OR_EQUAL(">="),
LESS_THAN("<"),
LESS_OR_EQUAL("<="),
NONE("none"),

04.jpg

灰度類型實現的規則對應級聯關係

05.jpg

06.jpg

07.jpg

08.jpg

簡單灰度觸發器模型

根據上面定義的數據結構和灰度規則,很容易抽象出我們需要定義的灰度模型,簡單數據結構參考如下:

@Data
@NoArgsConstructor
public class Toggle implements Serializable {
    /**
     * 默認不配置該字段,如果啓用該字段fullGray=1,則全量開關不進行後續的版本判斷
     */
    private Integer fullGray = 0;

    /**
     * 是否生效;1:生效;0:不生效
     */
    private Integer enabled = 1;
    /**
     * 規則列表,每一項都是或的關係
     */
    private List<Rule> rules;

    /**
     * 規則列表
     * 每一項是或的關係
     */
    @Data
    public static class Rule implements Serializable {

        /**
         * 規則字段條件,每一項是且的關係
         */
        private List<Condition> conditions;
    }


    /**
     * 條件列表
     */
    @Data
    public static class Condition implements Serializable {

        /**
         * 規則解析類型
         *
         * @see ConditionType
         */
        private String type;

        /**
         * 規則解析字段
         *
         * @see GrayFields
         */
        private String subject;

        /**
         * 條件判斷
         *
         * @see PredicateType
         */
        private String predicate;

        /**
         * 條件判斷內容
         */
        private List<Object> objects;

    }
}

灰度觸發器模型和規則實現

靈活的灰度規則實現,我們針對以上定義的好的數據模型和灰度規則需要進行映射實現。整體的架構實現需要考慮,每新增一種規則實現代碼量都非常少,極具擴展性,後續如有不滿足的規則場景也容易擴展。

定義灰度規則匹配器

public interface Matcher {

    ConditionType getConditionType();

    Map<PredicateType, PredicateMatcher> getPredicateMatcher();
}

數字類型灰度規則匹配

/**
 * 數字類型匹配
 */
@Component
public class NumberMatch implements Matcher {

    @Override
    public ConditionType getConditionType() {
        return ConditionType.NUMBER;
    }

    @Override
    public Map<PredicateType, PredicateMatcher> getPredicateMatcher() {
        EnumMap<PredicateType, PredicateMatcher> matcherEnumMap = new EnumMap<>(PredicateType.class);

        matcherEnumMap.put(PredicateType.EQUAL_TO, ((target, objects) -> objects.stream()
                .filter(Objects::nonNull)
                .map(Objects::toString)
                .filter(StringUtils::isNotBlank)
                .map(Long::valueOf)
                .anyMatch(t -> Objects.equals(Long.valueOf(String.valueOf(target)), t))));

       // ...... 此處後續的代碼省略,設計和上面的實現雷同....
        return matcherEnumMap;
    }
}

字符串類型灰度規則匹配

/**
 * 字符串匹配
 */
@Component
public class StringMatch implements Matcher {

    @Override
    public ConditionType getConditionType() {
        return ConditionType.STRING;
    }

    @Override
    public Map<PredicateType, PredicateMatcher> getPredicateMatcher() {
        EnumMap<PredicateType, PredicateMatcher> matcherEnumMap = new EnumMap<>(PredicateType.class);

        matcherEnumMap.put(PredicateType.EQ, ((target, objects) -> objects.stream()
                .filter(Objects::nonNull)
                .map(Objects::toString)
                .filter(StringUtils::isNotBlank)
                .anyMatch(t -> String.valueOf(target).equalsIgnoreCase(t))));

        // ...... 此處後續的代碼省略,設計和上面的實現雷同....
        return matcherEnumMap;
    }
}

客户端版本號灰度規則類型匹配

/**
 * @author feel
 */
@Component
public class VersionMatch implements Matcher {

    @Override
    public ConditionType getConditionType() {
        return ConditionType.VERSION;
    }

    @Override
    public Map<PredicateType, PredicateMatcher> getPredicateMatcher() {
        EnumMap<PredicateType, PredicateMatcher> matcherEnumMap = new EnumMap<>(PredicateType.class);
        matcherEnumMap.put(PredicateType.EQUAL_TO, ((target, objects) -> objects.stream()
                .filter(Objects::nonNull)
                .map(Objects::toString)
                .filter(StringUtils::isNotBlank)
                .anyMatch(t -> VersionUtils.compareMajorVersion(String.valueOf(target), t) == 0)));

        // ...... 此處後續的代碼省略,設計和上面的實現雷同....
        return matcherEnumMap;
    }

}

複雜集合類型灰度規則匹配

/**
 * 集合列表匹配
 */
@Component
public class SegmentMatch implements Matcher {

    @Override
    public ConditionType getConditionType() {
        return ConditionType.SEGMENT;
    }

    @Override
    public Map<PredicateType, PredicateMatcher> getPredicateMatcher() {
        EnumMap<PredicateType, PredicateMatcher> matcherEnumMap = new EnumMap<>(PredicateType.class);

        matcherEnumMap.put(PredicateType.IS_IN, ((target, objects) -> objects.stream()
                .filter(Objects::nonNull)
                .map(Objects::toString)
                .filter(StringUtils::isNotBlank)
                .anyMatch(t -> {
                    if (target instanceof List || target instanceof Set) {
                        return ((Collection<Object>) target).stream()
                                .filter(Objects::nonNull)
                                .map(Objects::toString)
                                .anyMatch(v -> String.valueOf(v).equalsIgnoreCase(t));
                    }
                    return String.valueOf(target).equalsIgnoreCase(t);
                })));

        // ...... 此處後續的代碼省略,設計和上面的實現雷同....

        return matcherEnumMap;
    }
}

客户端版本號比較算法

目前客户端的版本號存在特殊的灰度版本號,且安卓和iOS由於歷史原因存在2套不兼容的情況,我們也做了定製的算法實現。

/**
 * 通用版本號比較
 * 時間複雜度O(n+m)
 * 空間複雜度O(n+m)
 *
 * @param v1    版本號v1
 * @param v2    版本號v2
 * @param limit 限制截取長度
 * @return
 */
public static int compareVersion(String v1, String v2, Integer limit) {
    if (v1 == null || v2 == null) {
        return 0;
    }
    try {
        // 處理非法字符和老版本iphone,appversion:"5.16.1(100.0421)" 格式問題
        String[] majorV1 = parserPinkAppVersion(v1).split("\\.");
        String[] majorV2 = parserPinkAppVersion(v2).split("\\.");

        int len = Math.min(Optional.ofNullable(limit)
                .orElse(Integer.MAX_VALUE), Math.max(majorV1.length, majorV2.length));
        // 一位一位比較,注意:中間不能直接跳出
        for (int i = 0; i < len; ++i) {
            int x = 0, y = 0;
            if (i < majorV1.length) {
                x = Integer.parseInt(majorV1[i]);
            }
            if (i < majorV2.length) {
                y = Integer.parseInt(majorV2[i]);
            }
            if (x > y) {
                return 1;
            }
            if (x < y) {
                return -1;
            }
        }
    } catch (NumberFormatException e) {
        //ignore
    }
    return 0;
}

灰度服務SDK接口設計

根據上面的設計,我們也就很容易抽象出一個通用標準的灰度服務接口,打包成SDK供業務研發使用。

/**
 * 灰度服務
 */
public interface GrayService {

    /**
     * 根據灰度場景判斷當前請求是否命中灰度
     *
     * @param sceneKey 灰度場景key
     * @param attrs    灰度參數值
     * @return
     */
    boolean hitGray(String sceneKey, Map<String, Object> attrs);

    /**
     * 根據規則配置命中灰度
     *
     * @param toggle
     * @param attrs
     * @return
     */
    boolean hitGray(Toggle toggle, Map<String, Object> attrs);

    /**
     * 根據灰度場景key判斷當前請求命中的的實驗分組
     *
     * @param sceneKey 灰度場景key
     * @param attrs    灰度參數值
     * @return
     * @see MapValueUtils 配置值的獲取可以使用該工具方法
     */
    HitResult hitExperimentGroup(String sceneKey, Map<String, Object> attrs);

    /**
     * 根據灰度場景key指定實驗分組key,如果命中則返回命中分組都對應的配置;如果沒有命中,則配置重置為空字符
     *
     * @param sceneKey           灰度場景key
     * @param experimentGroupKey 實驗分組KEY
     * @param attrs              灰度參數值
     * @return
     * @see MapValueUtils 配置值的獲取可以使用該工具方法
     */
    HitResult hitExperimentGroup(String sceneKey, String experimentGroupKey, Map<String, Object> attrs);
}

四、配置服務關鍵設計

我們很多業務類型場景自定義的配置,早期的設計都是存放在Nacos或者Ark配置平台上。這種配置存在只能面向研發等侷限性,尤其在配置複雜時,修改過程需要特別謹慎。為了解決這個問題,我們設計了面向研發和業務的可視化動態表單的配置方式,集成現成的頁面表單搭建技術平台或動態表單技術平台,使得配置可以以可視化的形式展示。

配置可視化設計集成

09.jpg

10.jpg

這是我們內部頁面表單搭建技術,通過簡單配置或者拖拽的方式進行自定義表單。

11.jpg

12.jpg

最後在灰度配置裏面選擇關聯上對應的表單模版即可。

13.jpg

灰度服務的接口抽象

/**
 * 配置服務
 */
public interface GrayConfigService {


    /**
     * 獲取配置JSON VALUE
     *
     * @param sceneKey
     * @return
     */
    String getConfigValue(String sceneKey);

    /**
     * 根據場景key獲取String類型值
     *
     * @param sceneKey     配置開關配置key
     * @param pathKey      字段key,支持路徑path查找;字段是”$."前綴表示,則優先使用JSONPATH解析<a  href="https://gotest.hz.netease.com/doc/jie-kou-ce-shi/xin-zeng-yong-li/can-shu-xiao-yan/jsonpi-pei/jsonpathyu-fa.html">jsonpath使用用例</a>
     * @param defaultValue 默認值
     * @return
     */
    String getStringValue(String sceneKey, String pathKey, String defaultValue);

    /**
     * 根據場景key獲取boolean類型值
     *
     * @param sceneKey     配置開關配置key
     * @param pathKey      字段key,支持路徑path查找;字段是”$."前綴表示,則優先使用JSONPATH解析<a  href="https://gotest.hz.netease.com/doc/jie-kou-ce-shi/xin-zeng-yong-li/can-shu-xiao-yan/jsonpi-pei/jsonpathyu-fa.html">jsonpath使用用例</a>
     * @param defaultValue 默認值
     * @return
     */
    boolean getBooleanValue(String sceneKey, String pathKey, boolean defaultValue);

    /**
     * 根據場景key獲取Integer類型值
     *
     * @param sceneKey     配置開關配置key
     * @param pathKey      字段key,支持路徑path查找;字段是”$."前綴表示,則優先使用JSONPATH解析<a  href="https://gotest.hz.netease.com/doc/jie-kou-ce-shi/xin-zeng-yong-li/can-shu-xiao-yan/jsonpi-pei/jsonpathyu-fa.html">jsonpath使用用例</a>
     * @param defaultValue 默認值
     * @return
     */
    Integer getIntegerValue(String sceneKey, String pathKey, Integer defaultValue);

    /**
     * 根據場景key獲取Long類型值
     *
     * @param sceneKey     配置開關配置key
     * @param pathKey      字段key,支持路徑path查找;字段是”$."前綴表示,則優先使用JSONPATH解析<a  href="https://gotest.hz.netease.com/doc/jie-kou-ce-shi/xin-zeng-yong-li/can-shu-xiao-yan/jsonpi-pei/jsonpathyu-fa.html">jsonpath使用用例</a>
     * @param defaultValue 默認值
     * @return
     */
    Long getLongValue(String sceneKey, String pathKey, Long defaultValue);

    /**
     * 根據場景key獲取String類型值
     *
     * @param sceneKey     配置開關配置key
     * @param pathKey      字段key,支持路徑path查找;字段是”$."前綴表示,則優先使用JSONPATH解析<a  href="https://gotest.hz.netease.com/doc/jie-kou-ce-shi/xin-zeng-yong-li/can-shu-xiao-yan/jsonpi-pei/jsonpathyu-fa.html">jsonpath使用用例</a>
     * @param defaultValue 默認值
     * @return
     */
    Double getDoubleValue(String sceneKey, String pathKey, Double defaultValue);

    /**
     * 根據場景key獲取BigDecimal類型值
     *
     * @param sceneKey 配置開關配置key
     * @param pathKey  字段key,支持路徑path查找;字段是”$."前綴表示,則優先使用JSONPATH解析<a  href="https://gotest.hz.netease.com/doc/jie-kou-ce-shi/xin-zeng-yong-li/can-shu-xiao-yan/jsonpi-pei/jsonpathyu-fa.html">jsonpath使用用例</a>
     * @param scale    精度
     * @return
     */
    BigDecimal getBigDecimalValue(String sceneKey, String pathKey, int scale);


    /**
     * 根據場景key獲取String類型值
     *
     * @param sceneKey 配置開關配置key
     * @param pathKey  字段key,支持路徑path查找;字段是”$."前綴表示,則優先使用JSONPATH解析<a  href="https://gotest.hz.netease.com/doc/jie-kou-ce-shi/xin-zeng-yong-li/can-shu-xiao-yan/jsonpi-pei/jsonpathyu-fa.html">jsonpath使用用例</a>
     * @return
     */
    JSONArray getJSONArrayValue(String sceneKey, String pathKey);

    /**
     * 獲取配置JSONObject
     *
     * @param sceneKey
     * @return
     */
    JSONObject getConfigJSONObject(String sceneKey);


    /**
     * 獲取配置反序列化對象
     *
     * @param sceneKey
     * @param targetClass
     * @param <T>
     * @return
     */
    <T> T getConfigObject(String sceneKey, Class<T> targetClass);
}

五、穩定的百分比流量調控

我們在場景使用過程中需要實現精確到萬分之一的比例調控,session會話穩定,同樣的上下文多次請求,命中的結果不變。設計開1萬個分桶,分桶算法的實現使用了murmur3_32。

14.jpg

/**
 * 高性能的hash分桶算法
 */
private static final HashFunction murmur3 = Hashing.murmur3_32();

/**
 * 100_00個分桶
 */
private static final int bucket = 100_00;
/**
 * 穩定的流量分桶算法
 * 用來調控灰度流量比例
 *
 * @param attrsContext
 * @param bucketKey
 * @param rate
 * @return
 */
public boolean hitBucket(Map<String, Object> attrsContext, String bucketKey, Integer rate) {
    // 全量
    if (Objects.equals(Optional.ofNullable(rate).orElse(bucket), bucket)) {
        return true;
    }
    // 灰度流量比例為0
    if (Objects.equals(Optional.ofNullable(rate).orElse(bucket), 0)) {
        return false;
    }
    String bucketValue = Optional.ofNullable(attrsContext)
            .map(v -> v.get(bucketKey))
            .filter(Objects::nonNull)
            .map(String::valueOf)
            .filter(StringUtils::isNotBlank)
            .orElse(String.valueOf(System.currentTimeMillis()));
    // [0,10000)
    int hash = Hashing.consistentHash(murmur3.hashString(bucketValue, Charsets.UTF_8), bucket);
    int rateLimit = bucket;
    if (rate >= 0 && rate <= bucket) {
        rateLimit = rate;
    }
    return hash <= rateLimit;
}

六、灰度分組設計

使用場景一:自定義的灰度規則有多個,每一組都是或的關係,我們需要設計多個灰度分組條件命中;

使用場景二:當有多個灰度分組實驗的時候,我們需要指定命中那個條件分組下的規則。

相關的灰度模型設計參考如下:

public class Toggle implements Serializable {

    /**
     * 規則列表,每一項都是或的關係
     */
    private List<Rule> rules;

    /**
     * 規則列表
     * 每一項是或的關係
     */
    @Data
    public static class Rule implements Serializable {
      ..............
    }


    /**
     * 條件列表
     */
    @Data
    public static class Condition implements Serializable {

       .......
    }
}

15.jpg

七、白名單設計

有些場景可能需要命中白名單的規則命中全量,無需按照具體的條件命中,這個時候設計一個優先級最高的白名單機制就顯得尤為重要。相關數據模型參考如下:

public class Toggle implements Serializable {
    /**
     * 灰度白名單列表
     */
    private List<WhiteList> whiteLists;

    /**
     * 白名單
     * 每一項是或的關係
     */
    @Data
    public static class WhiteList implements Serializable {
        /**
         * 白名單解析字段
         *
         * @see GrayFields
         */
        private String subject;

        /**
         * 白名單內容
         */
        private List<Object> values;
    }
}

16.jpg

八、其他非功能設計

還有一些非功能性的設計更多是和現有系統能力集成,對接成本和每個公司的技術棧有差異,在此就不展開描述,有興趣的同學可以自行設計。

  • 灰度數據大數據埋點服務集成,提供可視化的數據分析能力
  • 靈活的權限控制
  • 集成發佈平台管控能力
  • 全生命週期灰度場景下線機制
  • 集成完整的ABTest能力

九、展望和總結

本文介紹了我們團隊所開發的輕量級灰度及配置平台,以及它所帶來的創新和優勢。通過對業務灰度發佈的需求和挑戰進行深入剖析,我們意識到傳統的灰度服務存在着一系列問題,諸如定製化程度高、缺乏靈活性、無法應對自定義場景等。為解決這些問題,我們研發出了一個高性能輕量級的SDK集成工具,它具有強大的功能,利用表單化的方式實現了灰度服務的產品化能力,使得灰度規則的配置和管理變得簡單而直觀,大大降低了研發和業務使用的門檻。這個集成工具已經成為我們研發過程中穩定性的三大保障之一,不僅得到了團隊的認可,也申請通過了相關專利,目前已被集成到了日常的運營平台中。它的存在使得研發人員和業務使用者能夠更輕鬆地享受到灰度服務的益處。

通過本文,我們希望這個輕量級的灰度配置平台的設計思路對你有所幫助。整體的設計思路非常輕量級,還有很多用户體驗和非功能的設計沒有在本文中展開描述,這些也並不是這個平台的核心設計內容。如果您有興趣,可以自行補充;如需進一步的幫助或有其他需求,歡迎隨時聯繫我們。

*文 / feel

本文屬得物技術原創,更多精彩文章請看:得物技術

未經得物技術許可嚴禁轉載,否則依法追究法律責任!

user avatar showmeai2 頭像 compose_hub 頭像 jjyin 頭像
3 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.