项目中一般都会有配置管理模块,该模块的存在是非常有必要的,我们可以通过配置来控制变更,降低因变更引入错误而导致的风险,常见的A/B test开关控制就是通过配置开关来实现的。今天我们重点讨论下配置管理的实现方案。
数据格式选型
因为是从服务器拉取配置信息,那就涉及到网络通信,这里会涉及到数据的序列化和反序列化。服务器会将配置对象序列化为可以传输的二进制数据,客户端收到二进制数据后采用约定好的协议将二进制数据转换为可操作的配置对象。
客户端和服务器通信常见的序列化和反序列化方案有下面几种:
-
XML序列化
- 可读性好,方便阅读和调试
- 序列化后的字节码文件较大,效率不高
- 适用于对性能要求不高的企业级内部系统间数据交换
- 语言无关性,适用于异构系统间的数据交换
- 常用的实现方式有java自带的XML序列化和反序列化
-
JSON序列化
- 轻量级数据交换格式,字节流小,可读性好。常用的开源工具有JackSon、FastJson和GSON
-
Protocol Buffers(ProtoBuf)
- Google的数据编码方式,用于数据序列化和反序列化
- 高效、语言中立(支持C++、Jave、Python等)、可扩展
- 需要预先定义数据结构,并使用Protoc编译,适合内部场合使用。
三种方案各有优劣
- 性能方面: Protobuf > Json > XML
- 可理解性: Json > XML > Protobuf
注意:这里性能主要有2个方面的考虑,一个是网络通信传输性能,Protobuf包体更小所以传输性能更好;第二个是本地序列化和反序列化性能,也是Protobuf更优。
因为移动客户端更关注性能,所以Json序列化和ProtoBuf 用的会更多些,故优先排出XML数据格式。
最后我们可以通过具体场景判断使用何种数据格式
Protobuf使用场景:
- 团队内部使用。若是跨团队,甚至跨公司合作场景,不太适合,因为其对开发语言以及技术栈都是有要求的,不确定其他团队是何种技术栈。
- 新建项目优先考虑使用Protobuf。因为没有历史包袱,不必考虑业务兼容性问题。
- 对性能要求极高场景。比如推送,需要考虑消息推送的及时性,热点消息推送慢了也就失去其价值了。
Json使用场景
- 对外合作。考虑到其优秀的可读性,以及格式的通用性,对外合作场景再适合不过了。
- 对性能没有极其严苛场景。普通的网络通信均可以采用Json来通信,其前后兼容性是特别优秀的。
客户端存储方案
客户端收到服务器下发的配置数据后,需要考虑在本地持久化存储,不然进程重启配置数据就丢失了。那么应该如何存储配置文件数据呢?不同的业务场景要求实现方案也有差异
完整存储
即将服务器下发的Json字符串一次性完整的存储到本地文件中。
优势:
- 和业务解耦。上层业务使用配置完全不需要关注配置是如何存储的。
- 使用简单。配置模块一次读取完整的配置文件,并序列化为配置对象,业务通过配置对象直接获取配置即可。
- 读取性能高。因为读取是操作内存级别的配置对象,不涉及到文件操作。
劣势:
-
有潜在的加载性能问题。
该方案有一个非常隐蔽的缺陷。随着配置项的数量增加(比如我们项目的配置项最近几年增加到上百个了)其Json反序列化性能持续下降,即性能与配置项量级成反比:配置越多,性能越低。 如果App启动过程中有某项配置必须读取,此时必须等全部配置反序列化(在中低端设备上大概耗时200-500ms)完成后才能获取到配置,启动过程中等待这么长时间显示是不可接受的。此时,我们可以考虑单独存储方案。
单独存储
即将服务器下发的数据反序列化后,逐个读取配置,然后将每个配置项单独存储在本地文件中。该方案能够有效解决App启动时配置加载过慢的问题,每次读取单个配置项不会超过2ms(中低端机上验证的数据),这完全是可以接受的。
优势:
- 支持任意时刻快速读取配置项。
劣势
- 存储和读取配置项会复杂很多。后面会聊到这点。
- 每次读取配置项均需要花费2ms左右。不像完整存储方案,加载完成后每次读取基本都是内存级操作。
两者适用场景:
- 完整存储方案。后台应用、配置项不多,启动app过程中不需要读取配置(该场景很难保证,业务每天都在变化)等场景。
- 单独存储方案。配置项过多并且app启动过程中需要使用到配置项的场景。
客户端架构设计
配置模块总体并不复杂,仅涉及存储和业务配置接口,具体如下:
- Iconfig: 上层业务配置接口,即业务需要哪些配置项,从该接口中获取
- ConfigInfo:配置对象类。可以将JSON字符串数据直接反序列化为ConfigInfo对象。
- IStorage: 配置存储接口。负责配置的存储和读取
- AllStorage: 完整存储实现类
- SeparateStorage: 单独存储实现类
- ConfigManager: 配置管理类。负责加载和存储配置,同时供上层业务获取配置信息。
麻雀虽小,五脏俱全。对于没有设计经验的同学,可以抓住任何机会尝试去设计试试,做的多了就会了。
再谈存储
前面谈到单独存储会使配置的存储和获取逻辑变得很复杂。这是因为单独存储需要很多的存储Key,每次新增配置项,需要额外定义存储Key,这就导致上层业务和配置模块强耦合。那有没有解决方案呢?答案是有的。
可以序列化的条件
如上述架构设计中提到的ConfigInfo 对象,它之所以可以完成反序列是因为我们和服务器约定将ConfigInfo对象的成员变量名作为配置配置Key,即JSON字符串中的key值 跟ConfigInfo中的变量名是一一对应的(完全相同),只有这样才能完成序列化。
复用配置key
那么我们单独存储的时候是否可以把配置key直接拿来作为存储key呢?当然可以,这样是不是就解决了每个配置新增存储key的窘境了。这样做的代价就是增加了代码实现的复杂度。
代码实现参考
我们通过反射的方式可以做到复用配置Key来做存储。这样我们在新增配置的时候仅需在ConfigInfo对象中添加一个变量即可。下面是存储和获取的具体代码参考
存储配置
public synchronized void saveConfig(ConfigInfo newConfig) {
if (newConfig == null) {
return;
}
Field[] fields = newConfig.getClass().getDeclaredFields();
SharedPreferences storeSp = 获取业务自己的存储对象。
for (Field field : fields) {
if (field == null) {
continue;
}
//变量类型
String type = field.getType().getSimpleName();
//变量名,也是配置key
String configKeyName = field.getName();
//变量值
Object value;
try {
value = field.get(newConfig);
} catch (Exception e) {
LogUtility.e(TAG, e);
continue;
}
switch (type) {
case "String":
value = TextUtils.isEmpty((String) value) ? "" : value;
storeSp.putString(configKeyName, (String) value);
break;
case "int":
if (value instanceof Integer) {
storeSp.putInt(configKeyName, (int) value);
}
break;
case "long":
if (value instanceof Integer) {
storeSp.putLong(configKeyName, (long) value);
}
break;
case "boolean":
if (value instanceof Boolean) {
storeSp.putBoolean(configKeyName, (boolean) value);
}
break;
default:
LogUtility.d(TAG, "type not support. type: ", type);
break;
}
}
}
注意:此处仅存储String,int,long,boolean数据,对于嵌套的json数据在获取配置时由业务自己去解析。
获取配置
这里仅列出了int类型的配置获取逻辑,对于String、boolean、long类型的配置也是一样的处理。
protected int getIntConfig(String configKey, int defaultValue) {
if (TextUtils.isEmpty(configKey)) {
return defaultValue;
}
StoreSp storeSp = 获取业务自己的存储对象。
return storeSp.getInt(configKey, defaultValue);
}
总结
我们首先介绍了配置数据格式选型,常用的数据格式主要集中在Protobuf和JSON格式上;再谈到配置的存储方案,并讲述了两种方案的优劣势和适用场景;然后谈到配置模块架构设计;最后讲到单独存储实现过程中可能遇到的问题以及解决方案。
希望这篇文章对你有用,我们下次再见👋