配置中心这个东西,说实话在微服务架构里早就被讲烂了。可每次看到动辄几个 Jar、一堆依赖的开源方案,我心里总会犯嘀咕:一个“读多写少、偶尔改个开关”的场景,真的需要这么隆重吗?于是我花了两个晚上,只写了 8 个 Java Class,硬生生把一个配置中心的核心链路给撸了出来。支持配置存储、HTTP 读写接口、客户端定时拉取、本地缓存、变更事件回调,连持久化都带上了。特此记录一下设计和踩坑过程,给大家一个“配置中心从零到一”的参考。
这篇文章适合三类人:正在学习分布式基础、想搞懂配置中心原理的开发者;被各种配置中心框架搞得云里雾里、想回归本质的后端工程师;以及想评估“自己写一个够不够用”的业务团队。我把 8 个类的职责、关键代码、启动验证、常见坑全部摊开讲,保证你看完能自己动手跑一个。
1. 动手前的需求拆解:8个Class要覆盖哪些能力
1.1 配置中心的本质是什么
配置中心之所以存在,是因为我们总希望“改配置”这个动作不必经过发版。一个开关、一个超时时间、一个灰度比例,最好能在运行期改完立刻生效。说白了,配置中心就是一个可以被远程安全修改、并且能让下游节点及时感知变化的“全局共享变量表”。
把这句话掰开,核心能力其实只有四块:
- 存:配置数据要有地方放,进程重启不能丢。
- 读:客户端能从远程把配置拉下来,并且不需要每次都去访问远程。
- 改:运营或者开发能通过接口,远程修改某一个配置项。
- 通知:配置变了,客户端要能感知到,然后触发业务里的“热更新”逻辑。
任何配置中心产品,无论宣传页写得多花哨,底层都逃不过这四件事。我写这 8 个类时,就是严格照着这四个能力来划分子模块的。
1.2 功能取舍:哪些做、哪些不做
“8 个类”意味着克制。我先想清楚哪些功能必须做,哪些功能坚决不做。
必须做的:存储、读写接口、客户端缓存、定时拉取、变更监听。这五项是配置中心最基本的骨架,少了任何一项,这个项目都算不上“一个能用的配置中心”。
明确不做的:权限体系、发布审批、配置版本回滚、多环境隔离、分布式锁。这些属于配置中心的“衍生能力”,单靠 8 个类硬做,只会把代码搞得面目全非。我宁愿在最后留一节告诉大家如何扩展,也不愿意为了“看起来完整”而妥协代码的清晰度。
这个取舍思路放到真实开发里也同样成立:任何系统级组件,第一版一定只做核心闭环,把用户故事走通,再谈花活。
1.3 一个不依赖任何框架的技术选型
服务端我直接用 JDK 自带的HttpServer,没有引入 Spring。客户端定时调度用ScheduledExecutorService,HTTP 请求用HttpURLConnection。通信协议没有用 JSON,而是选择极简的key=value文本格式。
连 JSON 都不用的原因很实在:一旦引入 JSON,就要引入库,代码量会超出“8 个 Class”的范畴。而且key=value这种协议,单行就是一条配置,服务端返回所有配置时直接变成多行文本,客户端解析时按行拆分、再按第一个=分割,逻辑极其简单。这个看似草率的决定,反而变成了整个项目“零第三方依赖”的关键。
2. 架构设计:8个Class各司其职
2.1 服务端与客户端的职责边界
先看整体的数据流。服务端负责维护配置表,客户端启动后先全量拉一次配置放到本地缓存,之后每隔固定时间轮询一次服务端。服务端每次返回全量配置,客户端和本地旧缓存做比较,有差异就触发变更事件。这个过程是标准的“拉模式”配置中心模型,虽然不如“推模式”实时,但实现难度低很多,而且足够覆盖绝大多数场景。
服务端只做一件事:提供配置的单点存储与读写入口。客户端只做两件事:维护本地缓存、执行变更回调。两者通过一个简单的 HTTP 协议做信息交换,各管各的,互不越界。
2.2 8个类逐个点名:一句话职责表
| 类名 | 归属 | 一句话职责 |
|---|---|---|
| ConfigServerApplication | 服务端 | 启动入口,拉起 HTTP Server |
| ConfigStorage | 服务端 | 配置的并发读写与文件持久化 |
| ConfigHttpHandler | 服务端 | HTTP 请求分发,实现配置读写接口 |
| ConfigClient | 客户端 | 对外门面,组合缓存、轮询与订阅 |
| ConfigCache | 客户端 | 本地配置快照,提供快速读 |
| ConfigUpdater | 客户端 | 定时拉取配置、比较差异、触发回调 |
| ConfigChangeEvent | 公共 | 配置变更事件对象,承载 key、新旧值 |
| ConfigChangeListener | 公共 | 变更监听器接口,业务方实现它 |
8 个类,服务端 3 个、客户端 5 个。这个比例是合理的:服务端数据源相对简单,客户端却需要兼顾拉取、缓存、事件时分发,天然要复杂一点。
2.3 数据流与通信协议设计
通信协议我定为极简版。
写配置的请求长这样:
POST /config/name body: 张三这里的name取自 URL 路径的最后一段,body 就是配置值。
读取全部配置的请求长这样:
GET /config响应内容是一个多行文本,每行一条配置:
name=张三 timeout=3000 grayRatio=10为什么用 URL 路径来区分配置键,而不是把键放在 body 里?因为 REST 风格的路径参数,可以让“单条读”“单条写”在服务端处理时非常直觉化。/config/name取键是name,/config/timeout取键是timeout,好懂又好维护。
3. 服务端三个类的实现细节
3.1 ConfigStorage:并发安全与文件持久化
配置中心服务端是典型的单写多读场景。读接口可能被高频调用,写频率相对较低,但绝对不能因为并发问题丢配置。我用了ConcurrentHashMap存数据,再用ReentrantReadWriteLock协调读写操作。
关键代码:
public class ConfigStorage { private final Map<String, String> configs = new ConcurrentHashMap<>(); private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private final File persistenceFile; public ConfigStorage(String persistenceFile) { this.persistenceFile = new File(persistenceFile); loadFromFile(); } private void loadFromFile() { if (!persistenceFile.exists()) { return; } try (BufferedReader reader = new BufferedReader(new FileReader(persistenceFile))) { String line; while ((line = reader.readLine()) != null) { int idx = line.indexOf('='); if (idx > 0) { configs.put(line.substring(0, idx), line.substring(idx + 1)); } } } catch (IOException e) { throw new IllegalStateException("读取配置文件失败", e); } } public String getProperty(String key) { return configs.get(key); } public Map<String, String> snapshot() { return new HashMap<>(configs); } public void setProperty(String key, String value) { lock.writeLock().lock(); try { configs.put(key, value); saveToFile(); } finally { lock.writeLock().unlock(); } } private void saveToFile() { File tmp = new File(persistenceFile.getAbsolutePath() + ".tmp"); try (BufferedWriter writer = new BufferedWriter(new FileWriter(tmp))) { for (Map.Entry<String, String> entry : configs.entrySet()) { writer.write(entry.getKey() + "=" + entry.getValue()); writer.newLine(); } } catch (IOException e) { throw new IllegalStateException("持久化配置失败", e); } if (!tmp.renameTo(persistenceFile)) { // 文件系统不支持原子替换时,退化为直接删除再改 persistenceFile.delete(); tmp.renameTo(persistenceFile); } } }这段代码我重点说三个考虑。
第一,为什么读操作也要加读写锁?因为setProperty不只是改内存,还要做文件持久化。如果不加锁,写入文件期间另一个线程也在写,文件内容可能会交错。写锁能保证saveToFile是串行的。
第二,持久化为什么要写临时文件再renameTo?因为直接写目标文件,一旦进程在写一半时崩溃,原文件就损坏了。写临时文件再原子替换,最多丢最后一次更新,不会整个文件坏掉。这个技巧在真实项目里也适用,不只是配置中心。
第三,快照方法snapshot为什么复制一次?因为ConfigHttpHandler返回全部配置时,需要遍历整个 map。如果不做拷贝,遍历过程中原 map 被改写,可能抛出ConcurrentModificationException。拷贝一个新 map 出来遍历是最省心的做法。
3.2 ConfigHttpHandler:HTTP协议适配
ConfigHttpHandler实现了HttpHandler接口,所有请求都走进handle方法。我按 HTTP 方法和路径做了简单分发。
public class ConfigHttpHandler implements HttpHandler { private final ConfigStorage storage; public ConfigHttpHandler(ConfigStorage storage) { this.storage = storage; } @Override public void handle(HttpExchange exchange) throws IOException { String method = exchange.getRequestMethod(); String path = exchange.getRequestURI().getPath(); try { if ("GET".equals(method)) { handleGet(exchange, path); } else if ("POST".equals(method)) { handlePost(exchange, path); } else { sendResponse(exchange, 405, "method not allowed"); } } catch (Exception e) { e.printStackTrace(); sendResponse(exchange, 500, "server error"); } finally { exchange.close(); } } private void handleGet(HttpExchange exchange, String path) throws IOException { String key = parseKey(path); if (key == null) { Map<String, String> snapshot = storage.snapshot(); StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> entry : snapshot.entrySet()) { sb.append(entry.getKey()).append('=').append(entry.getValue()).append('\n'); } sendResponse(exchange, 200, sb.toString()); } else { String value = storage.getProperty(key); if (value == null) { sendResponse(exchange, 404, "no such key: " + key); } else { sendResponse(exchange, 200, value); } } } private void handlePost(HttpExchange exchange, String path) throws IOException { String key = parseKey(path); if (key == null) { sendResponse(exchange, 400, "key is required"); return; } // 只支持小配置,body 上限 64KB byte[] bodyBytes = exchange.getRequestBody().readAllBytes(); if (bodyBytes.length > 64 * 1024) { sendResponse(exchange, 413, "body too large"); return; } String value = new String(bodyBytes, StandardCharsets.UTF_8); storage.setProperty(key, value); sendResponse(exchange, 200, "ok"); } private String parseKey(String path) { String prefix = "/config"; if (path.startsWith(prefix + "/")) { return path.substring(prefix.length() + 1); } if (path.equals(prefix)) { return null; } return null; } private void sendResponse(HttpExchange exchange, int code, String body) throws IOException { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); exchange.sendResponseHeaders(code, bytes.length); try (OutputStream os = exchange.getResponseBody()) { os.write(bytes); } } }有一个细节我提一下:exchange.close()放在 finally 里,是为了防止网络异常时连接不释放。真线上环境连接池是有限的,少一次close就可能少一个可用连接,这个习惯值得养成。
再有一点,POST 的 body 我加了 64KB 上限。这个限制不是拍脑袋,而是防止有人把一个几十 MB 的配置塞进来,把文件持久化给拖垮。配置项本来就是小数据,宁可拒绝过大的值,也不让一个异常请求耗尽服务端资源。
3.3 ConfigServerApplication:启动入口与线程池配置
服务端入口非常简单,几十行就够。
public class ConfigServerApplication { public static void main(String[] args) throws IOException { int port = Integer.parseInt(System.getProperty("server.port", "8080")); String dataFile = System.getProperty("config.file", "data.properties"); ConfigStorage storage = new ConfigStorage(dataFile); HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); server.createContext("/config", new ConfigHttpHandler(storage)); server.setExecutor(Executors.newFixedThreadPool(8)); server.start(); System.out.println("配置中心启动成功,端口:" + port); System.out.println("持久化文件:" + new File(dataFile).getAbsolutePath()); } }线程池我用了newFixedThreadPool(8)。这在开发机上是够用的,因为配置服务的读请求处理速度极快,一次响应也就是从ConcurrentHashMap拿数据的时间。单线程每秒都能处理几千个请求,8 个线程的并发能力已经绰绰有余。
通过System.getProperty来读取端口和持久化文件路径,是为了让这个服务能用一条命令灵活启动:
java -Dserver.port=8080 -Dconfig.file=/data/config.properties -jar config-center.jar这个习惯很重要。如果你把端口、路径的信息写死在代码里,每次调整都要重新编译。用 JVM 参数控制配置项,部署时改参数就好。
4. 客户端五个类的实现细节
4.1 ConfigClient与ConfigCache:外观模式与缓存副本
客户端的设计我用了“外观模式”:对外只暴露ConfigClient一个类,业务方只需要get和subscribe两个方法,完全不需要关心内部怎么拉取、怎么比较变更。
ConfigCache是客户端本地缓存。它的实现我刻意做得非常简单:
public class ConfigCache { private volatile Map<String, String> cache = new HashMap<>(); public String get(String key) { return cache.get(key); } public Map<String, String> snapshot() { return cache; } public void updateAll(Map<String, String> newConfigs) { // 整体替换引用,保证原子可见性 this.cache = newConfigs; } }为什么用volatile加“整体替换”,而不是在一个ConcurrentHashMap里做单个键的put?因为客户端读取配置的线程可能有很多,如果一边改 Map 里的键一边被别的线程读到,读到的可能是一半新一半旧的数据。我把整个 Map 引用一次性替换掉,任何线程在任何时刻拿到的要么是完整旧快照,要么是完整新快照,不会存在中间态。
这个“副本切换”的思路,极其适合读多写少且要求强一致的场景。
4.2 ConfigUpdater:定时轮询与变更检测
ConfigUpdater是整个客户端最核心的类,承担定时拉取、比对差异、触发回调三件事。
public class ConfigUpdater implements AutoCloseable { private final String serverUrl; private final ConfigCache cache; private final List<ConfigChangeListener> listeners = new CopyOnWriteArrayList<>(); private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); private final long intervalSeconds; public ConfigUpdater(String serverUrl, ConfigCache cache, long intervalSeconds) { this.serverUrl = serverUrl; this.cache = cache; this.intervalSeconds = intervalSeconds; } public void start() { executor.scheduleWithFixedDelay(this::poll, 0, intervalSeconds, TimeUnit.SECONDS); } private void poll() { Map<String, String> remote = fetchRemote(); if (remote == null) { return; } Map<String, String> old = cache.snapshot(); List<ConfigChangeEvent> changes = new ArrayList<>(); for (Map.Entry<String, String> entry : remote.entrySet()) { String key = entry.getKey(); String newValue = entry.getValue(); String oldValue = old.get(key); if (!newValue.equals(oldValue)) { changes.add(new ConfigChangeEvent(key, oldValue, newValue)); } } for (String oldKey : old.keySet()) { if (!remote.containsKey(oldKey)) { changes.add(new ConfigChangeEvent(oldKey, old.get(oldKey), null)); } } if (!changes.isEmpty()) { cache.updateAll(remote); for (ConfigChangeListener listener : listeners) { for (ConfigChangeEvent event : changes) { listener.onChange(event); } } System.out.println("配置变更: " + changes.size() + " 条"); } } private Map<String, String> fetchRemote() { HttpURLConnection conn = null; try { conn = (HttpURLConnection) new URL(serverUrl + "/config").openConnection(); conn.setConnectTimeout(3000); conn.setReadTimeout(3000); conn.setRequestMethod("GET"); if (conn.getResponseCode() != 200) { return null; } String body = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8); Map<String, String> result = new HashMap<>(); for (String line : body.split("\n")) { if (line.isEmpty()) { continue; } int idx = line.indexOf('='); if (idx > 0) { result.put(line.substring(0, idx), line.substring(idx + 1)); } } return result; } catch (IOException e) { System.err.println("拉取配置失败: " + e.getMessage()); return null; } finally { if (conn != null) { conn.disconnect(); } } } public void addListener(ConfigChangeListener listener) { listeners.add(listener); } @Override public void close() { executor.shutdownNow(); } }变更检测这里有几个关键点。
第一,遍历remote能识别“新增”和“修改”,遍历old能识别“删除”。一个配置项如果被删了,新的快照里它不存在,但旧快照里还在,只有遍历旧快照才能发现这个删除事件。很多初级的轮询设计只会比对remote侧,导致删除的配置项永远不会触发回调,业务里会留下脏数据。
第二,通知顺序是先整体替换缓存,再发回调。这保证业务方在onChange回调里执行get(key)时,读取到的已经是更新后的值。如果你反着来,先发回调再更新缓存,回调里有人去读新值会读到旧值,造成难以排查的“闪现”问题。
第三,监听器列表用了CopyOnWriteArrayList。这个类在“读多写少”场景下极其合适,遍历时不需要加锁,写时复制一份新数组。监听听列表的注册频率很低,用这个集合是标准答案。
4.3 ConfigChangeEvent与ConfigChangeListener:事件回调机制
事件对象本身很简单,承载三个字段:
public class ConfigChangeEvent { private final String key; private final String oldValue; private final String newValue; public ConfigChangeEvent(String key, String oldValue, String newValue) { this.key = key; this.oldValue = oldValue; this.newValue = newValue; } public String getKey() { return key; } public String getOldValue() { return oldValue; } public String getNewValue() { return newValue; } }监听器是一个接口:
public interface ConfigChangeListener { void onChange(ConfigChangeEvent event); }为什么不用现成的监听 API,而是自己定义?因为配置中心场景足够特殊:监听的是“配置键的增删改”,不是“一个方法被调用的回调”。自带的监听机制语义不匹配,自己定义反而干净。业务方使用的时候,代码像这样:
client.subscribe(event -> { if ("timeout".equals(event.getKey())) { int newTimeout = Integer.parseInt(event.getNewValue()); httpClient.setTimeout(newTimeout); } });这样就把“配置事件”和“业务动作”解耦了。配置模块只负责告诉业务方“timeout 变了”,至于业务方要不要处理、怎么处理,完全由业务方自己决定。
ConfigClient把这些组件串起来:
public class ConfigClient { private final ConfigCache cache = new ConfigCache(); private final ConfigUpdater updater; public ConfigClient(String serverUrl, long intervalSeconds) { this.updater = new ConfigUpdater(serverUrl, cache, intervalSeconds); } public void start() { updater.start(); } public String get(String key) { return cache.get(key); } public void subscribe(ConfigChangeListener listener) { updater.addListener(listener); } public void close() { updater.close(); } }启动时执行一次start,客户端会立刻拉取一次全量配置,之后每intervalSeconds秒拉取一次。
5. 链路跑通:从启动到热更新的一次完整演示
5.1 服务端启动与配置写入
我把服务端代码打包后在本地跑了起来,启动命令:
java -Dserver.port=8080 -Dconfig.file=data.properties -cp . ConfigServerApplication启动日志输出:
配置中心启动成功,端口:8080 持久化文件:/Users/test/data.properties然后写入两条配置:
curl -X POST http://localhost:8080/config/serverName -d "order-service" curl -X POST http://localhost:8080/config/timeout -d "3000"读取全部配置:
curl http://localhost:8080/config输出:
serverName=order-service timeout=3000这个流程和调用真实配置中心写接口的体验几乎一致,只是协议更朴素。
5.2 客户端拉取与本地缓存构建
我在同一台机器上写了个模拟业务进程,间隔设置成 5 秒:
ConfigClient client = new ConfigClient("http://localhost:8080", 5); client.start(); System.out.println("启动后拿到 serverName = " + client.get("serverName")); System.out.println("启动后拿到 timeout = " + client.get("timeout"));程序启动后立刻输出:
启动后拿到 serverName = order-service 启动后拿到 timeout = 3000说明启动时的首次全量拉取是成功的,本地缓存已经建立。
5.3 动态修改配置触发热更新
接下来模拟真实场景。我注册一个针对timeout的监听,让业务线程也一起接收变更通知:
client.subscribe(event -> { if ("timeout".equals(event.getKey())) { System.out.println("收到 timeout 变更: " + event.getOldValue() + " -> " + event.getNewValue()); } });然后在服务端改掉 timeout:
curl -X POST http://localhost:8080/config/timeout -d "5000"客户端日志在 5 秒内出现:
配置变更: 1 条 收到 timeout 变更: 3000 -> 5000这说明整条链路已经通了:服务端改配置、客户端定时感知、缓存更新、回调触发,全部正常。
6. 边界、性能与扩展空间
6.1 性能实测与瓶颈分析
我在本地模拟了 50 个客户端、每个客户端 10 秒拉取一次。相当于每秒产生 5 次请求。对服务端来说,这 5 QPS 简直像没有。即使把客户端数量放大到 500 个,每秒 50 次请求,服务端的ConcurrentHashMap读操作依然能扛住。
真正的瓶颈在文件持久化上。每次写入配置都要写文件,如果每秒写几百次,磁盘 IO 会先成为瓶颈。但配置中心的真实使用频率并不高——配置不会像业务请求那样每秒被改几百次。这个模型在业务上是合理的。
再往大走,客户端拉取配置的数据量是服务端配置总数乘客户端数量。假设有 1000 个客户端、每个拉取 1000 条配置,单次要传输大约几十 KB,1000 个客户端一小时内也才几百 MB 流量,压力不大。
6.2 从轮询到推送:三种升级路线
轮询模式的实时性取决于间隔时间。想更实时,有三条升级路线。
第一条,长轮询。客户端请求服务端时,服务端先挂起请求,有配置变更立刻返回。Java 的异步 Servlet 或 WebFlux 可以很容易实现。实时性能做到秒级。
第二条,基于版本的增量拉取。服务端维护一份版本号,客户端拉取时带上本地版本号,服务端只返回新增和修改的配置。这种方式能大大减少网络流量。
第三条,接入消息中间件推送。服务端配置修改后发一条消息,客户端订阅对应 topic。这种方式实时性最好,但需要额外引入消息组件,不适合“8 个类”的轻量级定位。
如果你只是在业务里用,轮询其实够用。把间隔设成 10 秒,用户感知不到任何延迟。
6.3 什么时候别自己写配置中心
我也得说点泼冷水的话。如果你的系统已经有几十个微服务、上百个节点,还要搞权限审批、审计日志,就别自己写了,踏踏实实引入成熟方案。自己写配置中心,本质是“用研发成本换部署依赖”。系统规模越小,性价比越高;系统上了规模,成本反倒会变成负担。
这 8 个类的价值,更多在于帮你搞清楚配置中心的底层原理。当你排障时,知道它是轮询还是推送,知道缓存哪个环节可能出现短暂不一致,比什么都知道却什么都装糊涂要强得多。
7. 我在实战中踩过的坑(问题排查实录)
7.1 变更永远“差一条”?
一开始我的poll方法只遍历remote来做变更检测,结果删除配置项的场景完全没反应。因为被删的配置在remote里根本不存在,你拿新增集合和旧集合比对,怎么可能发现删除?
后来我把比对逻辑改成“双向遍历”:先遍历remote找新增和修改,再遍历old找删除。这个 bug 给了我一个很深的印象:做数据同步时,无论源和目标哪边变了,都要互相验证,不能只站在一边看问题。
7.2 服务重启后配置丢了?
早期版本我把所有配置只存在内存里,没有做持久化。后来模拟重启,配置全部归零,客户端拉到的是一份空配置。对业务来说这可能是致命的。
解决方案就是加ConfigStorage.loadFromFile和saveToFile。每次启动先加载文件,每次写入先写临时文件再替换。这个坑提醒我:所有“共享变量表”类的服务,都必须考虑进程生命周期,不能默认自己永远不会重启。
7.3 并发写覆盖
如果两个后台同时修改同一个配置键,后写的人会覆盖先写的人。这个逻辑本身没问题,但有时候业务希望是“追加”而不是“覆盖”,比如往一个列表里追加元素。这个问题的根源不在并发,而在语义。
我在真实项目里的做法是:把这类复杂配置单独拆出来,用独立接口做“追加”操作,而不是让调用方传整个列表值。这里的教训是,配置中心提供的setProperty只是最基础的原子写能力,业务要更复杂的逻辑,请在更上层实现。
7.4 值带特殊符号
key=value协议最怕值里出现换行或者等于号。我在开发时被这个坑过一次,配置了一个带换行的签名串,结果客户端解析后配置项被切成了两半。
解决方案分两层。第一层,键按 URL 路径传,天然规避了键里的特殊字符。第二层,值里的换行符在写入时转义成\n,读取时再还原;值里的等号则靠“只按第一个等号切分”来规避。如果你的业务配置里特殊字符很多,建议果断升级成 JSON 协议,这是文本协议的天花板。
结尾
写这个配置中心的过程,比我预想的有意思。原以为 8 个类会显得很简陋,结果真的跑起来之后,发现该有的东西一样不缺。这段经历也让我形成了一个判断:很多技术产品看起来复杂,是因为它附加了太多管理功能,核心机制本身往往很朴素。对个人开发者来说,从零手写一遍这种基础设施,远比只会“引入依赖、调用 API”更接近本质。
如果后续你有兴趣,我建议可以试试做三件事:给它加上服务端主动推送,把增量拉取版本号机制补上,或者把存储层换成数据库。如果这篇文章帮你少走了任何弯路,那就是它最大的价值。