基于实际开发过程中有关于Apollo配置的一些思考与实现方案

380 阅读8分钟

Apollo配置中心在项目中的实践思考

最近开始学习Apollo配置服务,我将实际开发过程中遇到的一些问题和我的一些对策以及思考写下来,希望大家可以给我提出一点建议。如果这篇文章对您有一些帮助,请不要吝啬点赞和收藏,感谢各位!

Apollo 客户端的实现

首先,让我们看看基础的 Apollo 客户端实现:

import json
import logging
import threading
import time
import requests

class ApolloClient(object):
    """
    ApolloClient 用于与 Apollo 配置中心通信,支持从远程获取配置、监听配置变化并维护本地缓存。
    """
    def __init__(self, app_id, cluster='default', config_server_url="http://localhost:8080", interval=60, ip=None):
        """
        初始化 ApolloClient 实例。

        :param app_id: 应用 ID,标识当前客户端的应用。
        :param cluster: 集群名称,默认为 'default'。
        :param config_server_url: Apollo 配置中心服务地址。
        :param interval: 长轮询的时间间隔,单位为秒,默认为 60 秒。
        :param ip: 客户端 IP 地址。如果未指定,将通过网络接口动态获取。
        """
        self.config_server_url = config_server_url  # Apollo 服务地址
        self.appId = app_id  # 应用 ID
        self.cluster = cluster  # 集群名称
        self.timeout = 60  # HTTP 请求超时时间
        self.interval = interval  # 长轮询时间间隔
        self.init_ip(ip)  # 初始化客户端 IP
        self._stopping = False  # 控制监听线程的停止
        self._cache = {}  # 本地缓存,存储配置数据
        self._notification_map = {'application': -1}  # 配置变化的通知 ID 映射

    def init_ip(self, ip):
        """
        初始化客户端 IP 地址。如果未提供 IP 参数,尝试自动检测本地 IP。

        :param ip: 客户端 IP 地址。如果未提供,将通过 UDP 套接字检测。
        """
        if ip:
            self.ip = ip
        else:
            import socket
            try:
                s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
                s.connect(('8.8.8.8', 53))  # 使用公共 DNS 服务器检测本地 IP
                ip = s.getsockname()[0]  # 获取本地 IP 地址
            finally:
                s.close()
            self.ip = ip

    def get_value(self, key, default_val=None, namespace='application', auto_fetch_on_cache_miss=False):
        """
        获取指定配置项的值。

        :param key: 配置项的键。
        :param default_val: 如果未找到配置项,返回的默认值。
        :param namespace: 命名空间,默认为 'application'。
        :param auto_fetch_on_cache_miss: 如果本地缓存中没有,是否自动从远程获取。
        :return: 配置项的值。
        """
        if namespace not in self._notification_map:
            # 初始化通知映射表中的命名空间
            self._notification_map[namespace] = -1
            logging.getLogger(__name__).info("Add namespace '%s' to local notification map", namespace)

        if namespace not in self._cache:
            # 初始化缓存中的命名空间
            self._cache[namespace] = {}
            logging.getLogger(__name__).info("Add namespace '%s' to local cache", namespace)
            self._long_poll()  # 尝试长轮询更新数据

        if key in self._cache[namespace]:
            return self._cache[namespace][key]  # 从缓存中返回值
        else:
            if auto_fetch_on_cache_miss:
                return self._cached_http_get(key, default_val, namespace)  # 从远程获取值
            else:
                return default_val

    def start(self):
        """
        启动监听线程,用于实时检测配置变化。
        """
        if len(self._cache) == 0:
            self._long_poll()  # 初始化时更新缓存
        t = threading.Thread(target=self._listener)
        t.start()  # 启动监听线程

    def stop(self):
        """
        停止监听线程。
        """
        self._stopping = True
        logging.getLogger(__name__).info("Stopping listener...")

    def _cached_http_get(self, key, default_val, namespace='application'):
        """
        从远程获取配置并更新本地缓存。

        :param key: 配置项的键。
        :param default_val: 如果未找到配置项,返回的默认值。
        :param namespace: 命名空间,默认为 'application'。
        :return: 配置项的值。
        """
        url = '{}/configfiles/json/{}/{}/{}?ip={}'.format(self.config_server_url, self.appId, self.cluster, namespace, self.ip)
        r = requests.get(url)
        if r.ok:
            data = r.json()
            self._cache[namespace] = data  # 更新本地缓存
            logging.getLogger(__name__).info('Updated local cache for namespace %s', namespace)
        else:
            data = self._cache[namespace]  # 如果请求失败,返回缓存数据

        return data.get(key, default_val)

    def _uncached_http_get(self, namespace='application'):
        """
        从远程获取命名空间的所有配置并更新缓存。

        :param namespace: 命名空间,默认为 'application'。
        """
        url = '{}/configs/{}/{}/{}?ip={}'.format(self.config_server_url, self.appId, self.cluster, namespace, self.ip)
        r = requests.get(url)
        if r.status_code == 200:
            data = r.json()
            self._cache[namespace] = data['configurations']  # 更新缓存
            logging.getLogger(__name__).info('Updated local cache for namespace %s release key %s: %s',
                                             namespace, data['releaseKey'], repr(self._cache[namespace]))

    def _long_poll(self):
        """
        执行长轮询,检测配置的变化。
        """
        url = '{}/notifications/v2'.format(self.config_server_url)
        notifications = [{'namespaceName': ns, 'notificationId': nid} for ns, nid in self._notification_map.items()]

        r = requests.get(url=url, params={
            'appId': self.appId,
            'cluster': self.cluster,
            'notifications': json.dumps(notifications, ensure_ascii=False)
        }, timeout=self.timeout)

        if r.status_code == 304:
            # 如果没有配置变化,直接返回
            logging.getLogger(__name__).debug('No change, loop...')
            return

        if r.status_code == 200:
            data = r.json()
            for entry in data:
                ns = entry['namespaceName']
                nid = entry['notificationId']
                logging.getLogger(__name__).info("%s has changes: notificationId=%d", ns, nid)
                self._uncached_http_get(ns)  # 更新缓存
                self._notification_map[ns] = nid  # 更新通知 ID
        else:
            logging.getLogger(__name__).warn('Sleep...')
            time.sleep(self.timeout)

    def _listener(self):
        """
        监听线程的主循环,定期检查配置变化。
        """
        logging.getLogger(__name__).info('Entering listener loop...')
        while not self._stopping:
            self._long_poll()
            time.sleep(self.interval)
        logging.getLogger(__name__).info("Listener stopped!")

这个基础客户端实现了以下核心功能:

  1. 配置的基本获取和缓存
  2. 支持长轮询更新(通过 start() 方法启动)
  3. 支持多命名空间
  4. 提供了容错和降级机制

值得注意的是,这个实现包含了一个长轮询的功能(通过 start() 方法启动),可以实时监听配置变更。但是,这个功能是否需要使用,需要根据实际场景来决定。

实际需求分析

在我的项目中,配置更新的频率很低,通常可能几天才会有一次更改。这意味着:

  1. 不需要实时监听配置变更
  2. 可以接受配置更新的短暂延迟
  3. 更关注配置获取的可靠性

基于这些特点,我实现了一个更适合我们需求的包装类:

import json
from loger import logging
import os
from apollo_client import ApolloClient

class ApolloHelper:
    def __init__(self, config_server_url, app_id, cluster, local_config_path):
        """
        初始化 ApolloHelper 实例

        :param config_server_url: Apollo 配置服务地址
        :param app_id: 应用 ID
        :param cluster: 集群名称
        :param local_config_path: 本地 JSON 配置文件路径
        """
        self.fetch_client = ApolloClient(
            config_server_url=config_server_url,
            app_id=app_id,
            cluster=cluster
        )
        self.local_config_path = local_config_path

    def get_config(self, key, namespace, max_attempts=3):
        """
        获取配置值,如果 Apollo 连接失败,则从本地配置文件读取。
        同时对比 Apollo 数据与本地文件数据,如果不同则覆盖本地文件。

        :param key: 配置项的 key
        :param namespace: 命名空间
        :param max_attempts: 最大重试次数
        :return: 配置值的字典形式
        """
        attempts = 0
        config_value = None

        # 尝试从 Apollo 获取配置
        while attempts < max_attempts:
            try:
                config_value = self.fetch_client.get_value(key=key, namespace=namespace)
                if config_value:  # 成功获取配置
                    logging.info("Successfully fetched config from Apollo.")
                    break
            except Exception as e:
                logging.info(f"Attempt {attempts + 1}: Failed to connect to Apollo. Error: {e}")
            attempts += 1

        # 如果无法从 Apollo 获取配置,读取本地文件
        if not config_value:
            logging.info(f"Failed to fetch config from Apollo after {max_attempts} attempts. Reading from local file: {self.local_config_path}")
            try:
                with open(self.local_config_path, 'r', encoding='utf-8') as file:
                    config_value = file.read()
            except Exception as e:
                logging.info(f"Connection to Apollo failed, and local file reading also failed: {e}")
                raise
        else:
            # 如果成功获取 Apollo 配置,与本地文件进行对比
            if self._is_different_from_local(config_value):
                logging.info("Apollo configuration differs from local file. Updating local file.")
                self._update_local_file(config_value)

        # 转换为字典并返回
        try:
            return json.loads(config_value)
        except json.JSONDecodeError as e:
            logging.info(f"Failed to parse configuration JSON: {e}")
            raise

    def _is_different_from_local(self, new_data):
        """
        判断新数据是否与本地文件中的数据不同。

        :param new_data: 新的配置数据
        :return: 如果不同则返回 True,否则返回 False
        """
        if not os.path.exists(self.local_config_path):
            logging.info("Local config file does not exist. Treating as different.")
            return True

        try:
            with open(self.local_config_path, 'r', encoding='utf-8') as file:
                local_data = file.read()
            return local_data != new_data
        except Exception as e:
            logging.info(f"Failed to read local file for comparison: {e}")
            return True

    def _update_local_file(self, new_data):
        """
        用新数据覆盖本地 JSON 文件。

        :param new_data: 新的配置数据
        """
        try:
            with open(self.local_config_path, 'w', encoding='utf-8') as file:
                file.write(new_data)
            logging.info("Local config file successfully updated.")
        except Exception as e:
            logging.info(f"Failed to update local config file: {e}")

设计特点与考虑

  1. 简单性优先

    • 没有使用 ApolloClient 的长轮询功能
    • 每次直接获取配置,逻辑简单直观
    • 避免了线程管理的复杂性
  2. 可靠性设计

    • 实现了重试机制(最多重试3次)
    • 使用本地文件作为备份
    • 完整的错误处理和日志记录
  3. 资源效率

    • 不需要维护长期运行的监听线程
    • 按需获取配置,避免不必要的网络请求
    • 最小化资源占用

在这个案例中,虽然每次都创建新的 ApolloClient 实例看起来不是最佳实践,但因为:

  • 没有调用 start() 方法,所以不会产生额外的监听线程
  • 实际上是把它当作简单的 HTTP 客户端使用
  • 配合了本地文件缓存作为容错机制

所以整体来说是一个实用且安全的实现。

实际使用案例

# 初始化 ApolloHelper
apollo_helper = ApolloHelper(
    config_server_url="http://localhost:8080",
    app_id="service",
    cluster="default",
    local_config_path="xxx/xxx/xxx/xxx"
)

# 获取配置
config_dict = apollo_helper.get_config(key='xxxxxx', namespace='xxxxxx')

如果使用start()

优势:

  1. 实时性更好
# 启用长轮询后
client = ApolloClient(...)
client.start()  # 开启监听线程

# 配置更新会自动同步到本地缓存
# 每次 get_value 都能获取到最新配置
value = client.get_value("key")
  • 配置变更能立即被感知到
  • 减少直接的 HTTP 请求次数
  • 适合对配置实时性要求高的场景
  1. 性能优化
  • 减少了重复的 HTTP 请求
  • 通过缓存机制提高读取配置的速度

劣势:

  1. 资源管理风险
def some_task():
    client = ApolloClient(...)
    client.start()  # 创建新线程
    # 如果没有调用 stop(),线程会一直运行
    # 每次调用都会创建新线程
  • 如果没有正确管理线程生命周期,会导致线程泄露
  • 每个未停止的线程都会占用系统资源
  1. 复杂性增加
try:
    client = ApolloClient(...)
    client.start()
    # 业务逻辑
finally:
    client.stop()  # 需要确保在适当时机调用
  • 需要管理线程的启动和停止
  • 需要考虑异常情况下的资源清理
  • 需要在合适的时机调用 stop() 方法
  1. 潜在的资源竞争
# 如果多个地方都这样使用
client1 = ApolloClient(...)
client1.start()
client2 = ApolloClient(...)
client2.start()
# 多个线程同时在运行和更新缓存
  • 多个监听线程可能会造成资源竞争
  • 可能会影响系统的稳定性

总结

在这个实现中,我选择了一个相对简单但很实用的方案。虽然没有使用 Apollo 客户端的所有功能(如配置实时更新),但这个选择是基于实际需求做出的:

  1. 配置更新频率低,不需要实时监听
  2. 更注重实现的简单性和可维护性
  3. 通过重试机制和本地文件备份保证了可靠性