【架构师系列】QConfig配置中心系列之Client端(二)

声明

原创文章,转载请标注。https://www.cnblogs.com/boycelee/p/18033286
《码头工人的一千零一夜》是一位专注于技术干货分享的博主,追随博主的文章,你将深入了解业界最新的技术趋势,以及在Java开发和安全领域的实用经验分享。无论你是开发人员还是对逆向工程感兴趣的爱好者,都能在《码头工人的一千零一夜》找到有价值的知识和见解。

配置中心系列文章

《【架构师视角系列】风控场景下的配置中心设计思考》 https://www.cnblogs.com/boycelee/p/18355942
《【架构师视角系列】Apollo配置中心之架构设计(一)》https://www.cnblogs.com/boycelee/p/17967590
《【架构师视角系列】Apollo配置中心之Client端(二)》https://www.cnblogs.com/boycelee/p/17978027
《【架构师视角系列】Apollo配置中心之Server端(ConfigSevice)(三)》https://www.cnblogs.com/boycelee/p/18005318
《【架构师视角系列】QConfig配置中心系列之架构设计(一)》https://www.cnblogs.com/boycelee/p/18013653
《【架构师视角系列】QConfig配置中心系列之Client端(二)》https://www.cnblogs.com/boycelee/p/18033286

一、架构

一、客户端架

架构介绍会从分层、职责、关系以及运行负责四个维度进行描述。

1、Server 职责

(1)配置管理

Server 是QConfig配置中心的服务端组件,负责管理应用程序的配置信息。它存储和维护应用程序的各种配置项。

(2)配置发布

Server 负责将最新的配置发布给注册在它上面的Client。当配置发生变更时,Config Service 负责通知所有订阅了相应配置的客户端。

(3)配置读取

Client 向 Server 发送请求,获取应用程序的配置信息。

2、Client 职责

(1)配置拉取

Client 负责向 Server 发送配置拉取请求,获取三方应用程序的配置。

(2)配置注入

Client 将从 Server 获取到的配置注入到三方应用程序中。

(3)配置变更监听

Client 可以注册对配置变更的监听器。当 Server 发布新的配置时, Client 能够感知到配置的变更,并触发相应的操作。

3、基本交互流程

(1)应用启动

Client 在应用启动时向 Server 发送配置拉取请求,获取初始的配置。

(2)配置变更通知

Server 在配置发生变更时,通知所有注册的 Client。

(3)配置更新

Client 接收到配置变更通知后,向 Server 发送请求,获取最新的配置。

(4)配置注入

Client 将获取到的最新配置注入到应用程序中,以便使用最新的配置信息。

通过以上交互流程达到应用不需要重启,动态配置变更的目的。

二、架构思考

架构师视角系列,在分析一款组件的源码时,需要深入思考其设计背后的动机。以下是读者在阅读本篇文章时应思考的问题:

  1. 配置拉取的设计:
  • 思考点: 设计中采用的配置拉取方式是如何选择的?背后的动机是什么?可能的考虑包括系统性能、可维护性和安全性。
  1. 配置的注入方式:
  • 思考点: 配置是如何被注入到组件中的?这种注入方式有何优势?设计选择的原因可能涉及松耦合、动态变化和代码可维护性等方面。
  1. 配置变更的通知机制:
  • 思考点: 配置变更是如何通知其他组件的?为什么选择当前的通知机制?可能的考虑包括实时性、效率以及系统整体的架构要求。
  1. 为什么配置拉取拆分为两个请求?
  • 思考点: 配置拉取为何拆分为两个独立的请求?这个设计决策的目的是什么?可能涉及到性能优化、可伸缩性以及减轻服务器负担的考虑。
  1. 长轮询的概念:
  • 思考点: 什么是长轮询?为何在配置方案中选择使用它?长轮询的优势在哪里?可能涉及到减少轮询频率、降低网络开销以及更及时的配置变更通知。
  1. 为什么需要做本地文件缓存?
  • 思考点: 为什么在组件中引入了本地文件缓存的机制?这样的设计有哪些优点?可能牵涉到性能优化、离线支持以及用户体验的方面。

在深入研究源码时,理解这些设计决策背后的原因,有助于更全面地理解系统架构,并为自己的设计提供有价值的启示。

三、源码剖析

1、注解初始化

1.1、逻辑描述

通过实现Spring框架提供的BeanPostProcessor接口,并完成postProcessBeforeInitialization函数的实现,我们能够在Bean初始化之前执行自定义的操作。BeanPostProcessor是Spring框架提供的一个扩展点,允许我们在Bean初始化前后插入自定义逻辑。在postProcessBeforeInitialization函数中,我们有机会遍历Bean的成员变量和函数,实现在初始化之前对它们进行定制化处理的需求。

1.2、时序图

1.3、代码位置

QConfigAnnotationProcessor#postProcessBeforeInitialization
class QConfigAnnotationProcessor extends PropertyPlaceholderConfigurer implements BeanPostProcessor, ApplicationContextAware, BeanClassLoaderAware {

    private boolean trimValue;

    private final Map<Class, AnnotationManager> managers = new HashMap<>();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        Class<?> clazz = getRealClass(bean);
        // 如果标记有DisableQConfig,则跳过
        if (AnnotationUtils.isAnnotationDeclaredLocally(DisableQConfig.class, clazz)) return bean;
        // 获取或创建manager,为所有的Spring Bean
        AnnotationManager manager = getOrCreateManager(clazz);
        // manager初始化
        manager.init(clazz);
        // 初始化后,首次执行注入动作
        manager.processBean(bean);
        return bean;
    }
}

2、查找注解

2.1、逻辑描述

根据注解的Bean、Field、Method三种使用方式,进行注解扫描。其中动作加载数据、注册监听器,当监听到配置变更时执行act函数进行数据变更,如Method上的注解就通过反射的方式实现数据变更。

2.2、时序图

2.3、代码位置

AnnotationManager#init
class AnnotationManager {

    /**
     * 初始化。分别有三种维度的注解,分别为Bean、Field、Method
     * @param clazz
     */
    void init(Class<?> clazz) {
        if (inited) return;
        synchronized (INIT_LOCK) {
            if (inited) return;
            // 处理Bean上的注解
            this.beanProcessor = new BeanProcessor(clazz, this);
            // 处理变量上注解
            this.fieldProcessor = new FieldProcessor(clazz, this);
            // 处理函数上注解(篇幅有限,只关注函数上注解)
            this.methodProcessor = new MethodProcessor(clazz, this);
            inited = true;
        }

    }
}
MethodProcessor#MethodProcessor
class MethodProcessor {
    private final List<Processor> processors;

    MethodProcessor(Class<?> clazz, AnnotationManager manager) {
        ProcessorFactory factory = new ProcessorFactory();
        processors = new ArrayList<>();

        // 遍历所有声明的Method
        Method[] methods = clazz.getDeclaredMethods();
        for (Method method : methods) {
            Annotation annotation = manager.extractAnnotation(method);
            if (annotation == null || annotation instanceof DisableQConfig) {
                continue;
            }

            Class<?>[] parameterTypes = method.getParameterTypes();
            if (parameterTypes.length != 1) {
                throw new RuntimeException("method receives qconfig change must be on parameter, method: " + method);
            }

            Map.Entry<Util.File, Feature> fileInfo = manager.getFileInfo(annotation);
            Action action = new MethodInvoker(fileInfo.getKey(), method);
            // 为标记有注解的Method创建一个Porcessor
            factory.create(annotation, parameterTypes[0], method.getGenericParameterTypes()[0], fileInfo, action, processors, manager);
        }
    }

    public boolean process(Object bean) {
        if (processors.isEmpty()) return false;
        // 遍历所有processor(Method),通过反射的方式调用目标函数,替换新value
        for (Processor processor : processors) {
            processor.process(bean);
        }
        return true;
    }
}
JsonProcessor#process
class JsonProcessor implements Processor {
    private final JsonConfig config;
    private final Action action;
    private final QConfigLogLevel logLevel;

    JsonProcessor(Type genericType, String appCode, String file, Feature feature, final Action action, final QConfigLogLevel logLevel, final AnnotationManager manager) {
        this.action = action;
        this.logLevel = logLevel;
        // 加载数据
        config = JsonConfig.get(appCode, file, feature, JsonConfig.ParameterizedClass.of(genericType));
        // 注册监听器
        AnnotationListenerManager.getInstance().addAnnotationListener(config, new Configuration.ConfigListener() {
            @Override
            public void onLoad(Object conf) {
                manager.process(action, conf, logLevel);
            }
        });
    }

    public void process(Object bean) {
        action.act(bean, config.current(), logLevel);
    }
}
MethodInvoker#act
class MethodInvoker implements Action {

    @Override
    public void act(Object bean, Object newValue, QConfigLogLevel logLevel) {
        try {
            // 调用目标函数,替换新value,bean为对象,method为反射调用的函数
            method.invoke(bean, newValue);

            LogUtil.log(this, logLevel, this.value, newValue);
            this.value = newValue;
        } catch (Exception e) {
            LOG.error("receive qconfig change error, class {}, method {}, file [{}], group [{}]", getClazz().getName(), method.getName(), file.file, file.group, e);
            throw new RuntimeException(e);
        }
    }
}

3、配置初始化

2.1、逻辑描述

客户端启动首次加载配置,会首先读取本地存储的配置文件,然后将配置文件的版本信息发送至Server端,若Server端根据上传的版本信息判断有新的版本,则会通知client端,这时client端就会发起拉取配置文件的请求。

2.2、代码位置

AbstractDataLoader#preLoadLocal
abstract class AbstractDataLoader implements DataLoader {
    
    private static final LongPoller LONGPOLLER = new LongPoller(USED_CONFIGS, VERSIONS, new LongPoller.ConfigChangedCallback() {
        @Override
        public Optional<CountDownLatch> onChanged(Map<Meta, VersionProfile> map, TypedCheckResult changed) {
            return loadIfUpdated(map, changed);
        }
    });

    private static final QConfigServerClient CLIENT = QConfigServerClientFactory.create();
    private static final ConfigLogger CONFIG_LOGGER = new HttpConfigLogger(CLIENT);

    static {
        // 客户端启动首次加载
        preLoadLocal();
        LONGPOLLER.start();
    }

    /**
     * 根据本地之前缓存的配置预加载
     */
    private static void preLoadLocal() {
        //读取本地所有[版本文件]
        final Map<Meta, VersionProfile> localVersions = FileStore.findAllFiles();

        for (Map.Entry<Meta, VersionProfile> entry : localVersions.entrySet()) {
            VERSIONS.put(entry.getKey(), new Version(entry.getValue()));
        }

        try {
            // 客户端本地
            Optional<CountDownLatch> holder = checkUpdates(new HashMap<Meta, VersionProfile>(localVersions));
            if (!holder.isPresent()) return;

            CountDownLatch latch = holder.get();
            latch.await(2, TimeUnit.SECONDS);
        } catch (Throwable e) {
            LOG.warn("初始化出错,强制载入本地缓存配置!", e);
            forceLoadLocalCache(localVersions);
        }
    }

    private static Optional<CountDownLatch> checkUpdates(Map<Meta, VersionProfile> versions) throws Exception {
        if (versions == null || versions.isEmpty()) return Optional.absent();

        // 获取新版本(远程)
        TypedCheckResult remote = CLIENT.checkUpdate(versions).get();

        // 加载配置数据
        return loadIfUpdated(versions, remote);
    }
}

3、建立连接(变更通知)

3.1、逻辑描述

Client端会与Server端建立长轮询,通过长轮询的方式获取最新版本。

3.2、代码位置

LongPoller#run
class LongPoller implements Runnable {

    private static final Logger logger = LoggerFactory.getLogger(LongPoller.class);

    private static final TomcatStateViewer TOMCAT_STATE = TomcatStateViewer.getInstance();

    private static final long OVERRIDE_CHECK_INTERVAL = 60 * 1000L;

    private volatile AtomicBoolean initialed = new AtomicBoolean(false);

    private static final QConfigServerClient CLIENT = QConfigServerClientFactory.create();
    private static final Random LONG_POLLING_RANDOM = new Random();
    private static final ScheduledExecutorService LONG_POLLING_EXECUTOR = Executors.newSingleThreadScheduledExecutor(new NamedThreadFactory("qconfig-poller#"));

    private final Map<String, FileStore> usedConfigs;
    private final Map<Meta, AbstractDataLoader.Version> localVersions;
    private ConfigChangedCallback callback;

    LongPoller(Map<String, FileStore> usedConfigs, Map<Meta, AbstractDataLoader.Version> localVersions, ConfigChangedCallback callback) {
        this.usedConfigs = usedConfigs;
        this.localVersions = localVersions;
        this.callback = callback;
    }

    @Override
    public void run() {
        // 判断tomcat是否启动
        while (TOMCAT_STATE.isStopped()) {
            try {
                logger.debug("tomcat is stopped, qconfig sleep");
                Thread.sleep(5000L);
            } catch (InterruptedException e) {
                logger.warn("tomcat stop sleep interrupted", e);
                return;
            }
        }

        logger.debug("start qconfig reloading");
        try {
            // 获取最新版本,加载数据
            Optional<CountDownLatch> latch = reLoading();
            if (latch.isPresent()) {
                if (!latch.get().await(20, TimeUnit.SECONDS)) {
                    logger.warn("20 seconds elapsed and qconfig file change loading not finish, perhaps something wrong");
                }
                LONG_POLLING_EXECUTOR.execute(this);
            } else {
                long emptyCheckDelay;
                if (initialed.compareAndSet(false, true)) {
                    emptyCheckDelay = 3 * 1000L;
                } else {
                    emptyCheckDelay = 30 * 1000L;
                }
                LONG_POLLING_EXECUTOR.schedule(this, emptyCheckDelay, TimeUnit.MILLISECONDS);
            }
        } catch (Exception e) {
            logger.info("long-polling check update error", e);
            long delay = LONG_POLLING_RANDOM.nextInt(60 * 1000);
            LONG_POLLING_EXECUTOR.schedule(this, delay, TimeUnit.MILLISECONDS);
        }
    }

    void start() {
        LONG_POLLING_EXECUTOR.execute(this);
    }

    private Optional<CountDownLatch> reLoading() throws Exception {
        Map<Meta, VersionProfile> map = Maps.newHashMap();

        for (FileStore store : usedConfigs.values()) {
            if (!store.getFeature().isAutoReload()) {
                continue;
            }

            boolean hasOverride = store.checkOverride(OVERRIDE_CHECK_INTERVAL);
            if (hasOverride) {
                continue;
            }

            AbstractDataLoader.Version ver = localVersions.get(store.getMeta());
            map.put(store.getMeta(), ver == null ? VersionProfile.ABSENT : ver.updated.get());
        }

        if (map.isEmpty()) return Optional.absent();

        // 通过长轮询,获取最新版本。
        TypedCheckResult remote = CLIENT.longPollingCheckUpdate(map).get();
        // 通知配置变更
        return this.callback.onChanged(map, remote);
    }

    interface ConfigChangedCallback {
        Optional<CountDownLatch> onChanged(Map<Meta, VersionProfile> map, TypedCheckResult changed);
    }
}
AbstractDataLoader#onChanged
abstract class AbstractDataLoader implements DataLoader {
    private static final Logger LOG = LoggerFactory.getLogger(AbstractDataLoader.class);

    private static final ConcurrentMap<Meta, Version> VERSIONS = new ConcurrentHashMap<Meta, Version>();

    private static final ConcurrentMap<String, FileStore> USED_CONFIGS = new ConcurrentHashMap<String, FileStore>();

    //qconfig的配置变更listener在这个线程池里执行
    private static final Executor EXECUTOR = new ThreadPoolExecutor(1, Integer.MAX_VALUE, 30L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), new NamedThreadFactory("qconfig-worker#"));

    private static final LongPoller LONGPOLLER = new LongPoller(USED_CONFIGS, VERSIONS, new LongPoller.ConfigChangedCallback() {
        @Override
        public Optional<CountDownLatch> onChanged(Map<Meta, VersionProfile> map, TypedCheckResult changed) {
            // 配置加载(核心)
            return loadIfUpdated(map, changed);
        }
    });

    ...
}

4、拉取配置

4.1、逻辑描述

当Client端与Server端建立长轮询后,如果有新版本通知,则Client端会执行配置加载操作。拉取配置时,会首先判断版本号是否小于远端版本号,如果小于则判断本地是否有该版本数据,如果没有则拉取远端配置数据。

4.3、代码位置

abstract class AbstractDataLoader implements DataLoader {
    
    //qconfig的配置变更listener在这个线程池里执行
    private static final Executor EXECUTOR = new ThreadPoolExecutor(1, Integer.MAX_VALUE, 30L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), new NamedThreadFactory("qconfig-worker#"));

    private static final LongPoller LONGPOLLER = new LongPoller(USED_CONFIGS, VERSIONS, new LongPoller.ConfigChangedCallback() {
        @Override
        public Optional<CountDownLatch> onChanged(Map<Meta, VersionProfile> map, TypedCheckResult changed) {
            return loadIfUpdated(map, changed);
        }
    });
    
    ...

    private static Optional<CountDownLatch> checkUpdates(Map<Meta, VersionProfile> versions) throws Exception {
        if (versions == null || versions.isEmpty()) return Optional.absent();

        // 获取新版本
        TypedCheckResult remote = CLIENT.checkUpdate(versions).get();

        // 加载配置数据
        return loadIfUpdated(versions, remote);
    }

    /**
     * 配置加载(核心)
     * @param versions
     * @param remote
     * @return
     */
    private static Optional<CountDownLatch> loadIfUpdated(Map<Meta, VersionProfile> versions, TypedCheckResult remote) {
        final CountDownLatch latch = new CountDownLatch(versions.size());

        for (Map.Entry<Meta, VersionProfile> entry : versions.entrySet()) {
            final Meta key = entry.getKey();
            final Version localVersion = VERSIONS.get(key);
            VersionProfile remoteVersion = remote.getResult().get(key);
            loadIfUpdated(key, localVersion, remoteVersion, latch);
        }

        return Optional.of(latch);
    }

    private static void loadIfUpdated(Meta fileMeta, Version localVersion, VersionProfile remoteVersion, CountDownLatch latch) {
        if (localVersion == null) {
            latch.countDown();
            return;
        }

        if (localVersion.updated.get().needUpdate(remoteVersion)) {
            // 加载配置(本地查不到就查远程)
            updateFile(fileMeta, remoteVersion, latch);
        } else {
            latch.countDown();
            if (remoteVersion != null && remoteVersion.getVersion() <= Constants.PURGE_FILE_VERSION) {
                FileStore.purgeAllRelativeFiles(fileMeta);
            }
            localVersion.setLoaded();
        }
    }

    private static void updateFile(final Meta meta, final VersionProfile newVersion, final CountDownLatch latch) {
        // 本地文件是否能查找到?
        if (foundInLocal(newVersion, meta)) {
            EXECUTOR.execute(new Runnable() {
                @Override
                public void run() {
                    updateVersion(meta, newVersion, latch, null);
                    setLoaded(meta);
                }
            });

            return;
        }

        // 本地查不到就走下面的逻辑
        final FileStore fileStore = USED_CONFIGS.get(meta.getKey());
        // 远端拉取
        final ListenableFuture<Snapshot<String>> future = CLIENT.loadData(meta, newVersion, fileStore == null ? Feature.DEFAULT : fileStore.getFeature());
        future.addListener(new Runnable() {
            public void run() {
                try {
                    Snapshot<String> snapshot = future.get();
                    try {
                        FileStore.storeData(meta, newVersion, snapshot);
                    } catch (Throwable e) {
                        LOG.warn("缓存配置到本地磁盘失败", meta);
                        latch.countDown();
                        return;
                    }
                    updateVersion(meta, newVersion, latch, snapshot);
                } catch (Throwable e) {
                    LOG.warn("获取文件错误!", e);
                } finally {
                    setLoaded(meta);
                }
            }
        }, EXECUTOR);
    }
    
}

6、配置注入

6.1、逻辑描述

当配置发生变化时,会首先将变化的配置信息存储至本地文件,然后触发配置变更逻辑,通知注册的监听器。

6.2、代码位置

AbstractDataLoader#updateVersion
abstract class AbstractDataLoader implements DataLoader {
    
    private static final ConcurrentMap<Meta, Version> VERSIONS = new ConcurrentHashMap<Meta, Version>();

    private static final ConcurrentMap<String, FileStore> USED_CONFIGS = new ConcurrentHashMap<String, FileStore>();

    //qconfig的配置变更listener在这个线程池里执行
    private static final Executor EXECUTOR = new ThreadPoolExecutor(1, Integer.MAX_VALUE, 30L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>(), new NamedThreadFactory("qconfig-worker#"));

    private static void updateVersion(final Meta meta, final VersionProfile newVersion, CountDownLatch latch, Snapshot<String> snapshot) {
        Version ver = VERSIONS.get(meta);

        if (ver == null) {
            VERSIONS.putIfAbsent(meta, new Version(VersionProfile.ABSENT));
            ver = VERSIONS.get(meta);
        }

        VersionProfile uVer = ver.updated.get();
        boolean versionChanged = uVer.needUpdate(newVersion) && ver.updated.compareAndSet(uVer, newVersion);
        latch.countDown();
        if (versionChanged) {
            FileStore store = USED_CONFIGS.get(meta.getKey());
            if (store != null)
                versionChanged(store, snapshot);
        }
    }

    private static void versionChanged(FileStore store, Snapshot<String> snapshot) {
        VersionProfile version = VERSIONS.get(store.getMeta()).updated.get();
        try {
            store.setVersion(version, snapshot);
        } catch (Exception e) {
            LOG.warn("文件载入失败: meta: {}, version: {}", store.getMeta(), version, e);
        }
    }
}
    
class FileStore<T> {
    
    synchronized void setVersion(VersionProfile version, Snapshot<String> snapshot) throws Exception {
        String data;

        if (snapshot != null) {
            data = snapshot.getContent();
        } else {
            data = loadSnapshot(version);
        }

        // 执行数据merge
        data = templateTool.merge(meta.getFileName(), data);

        T t;
        try {
            t = conf.parse(data);
        } catch (Throwable e) {
            configLogger.log(ConfigLogType.PARSE_REMOTE_ERROR, meta, version.getVersion(), e);
            throw new RuntimeException(e);
        }

        FileVersion current = currentVersion.get();
        FileVersion newVer = new FileVersion(FileVersion.Type.remote, version);

        if (FileVersion.needUpdate(current, newVer) && currentVersion.compareAndSet(current, newVer)) {

            if (storeAtLocal(snapshot)) {
                saveToConfigRepository(meta, newVer.getVersion().getVersion(), data);
                File versionFile = getVersionFile();
                atomicWriteFile(versionFile, Long.toString(version.getVersion(), 10) + COMMA + version.getProfile());
            }

            //触发配置变更逻辑
            boolean success = conf.setData(t);

            log.info("use remote file, name={}, version={}", meta.getFileName(), version);
            String message = Constants.EMPTY;
            if (!success) {
                message = "listener error";
            }
            configLogger.log(ConfigLogType.USE_REMOTE_FILE, meta, version.getVersion(), message);
            purge(version);
            purgedFiles.remove(meta);
        }
    }

}
AbstractConfiguration#setData
public abstract class AbstractConfiguration<T> implements Configuration<T> {

   /**
     * 配置变化setData
     * @param data
     * @return
     */
    boolean setData(T data) {
        return setData(data, true);
    }

   /**
     * 配置变化setData
     * @param data
     * @return
     */
    boolean setData(T data, boolean trigger) {
        synchronized (current) {

            current.set(data);
            onChanged();

            if (!future.isDone()) {
                future.set(true);
            }

            // 数据变化触发器
            return triggers(data, trigger);
        }
    }

    private boolean triggers(T data, boolean trigger) {
        if (!trigger) return true;
        boolean result = true;
        for (ConfigListener<T> listener : listeners) {
            if (!trigger(listener, data)) result = false;
        }
        return result;
    }

    private boolean trigger(ConfigListener<T> listener, T data) {
        try {
            // 配置变化,通知注册的监听器,并触发监听器的load函数
            listener.onLoad(data);
            return true;
        } catch (Throwable e) {
            log.error("配置文件变更, 事件触发异常. data: {}", data, e);
            return false;
        }
    }
}

二、最后

《码头工人的一千零一夜》是一位专注于技术干货分享的博主,追随博主的文章,你将深入了解业界最新的技术趋势,以及在Java开发和安全领域的实用经验分享。无论你是开发人员还是对逆向工程感兴趣的爱好者,都能在《码头工人的一千零一夜》找到有价值的知识和见解。

懂得不多,做得太少。欢迎批评、指正。

posted @ 2024-02-25 22:49  码头工人  阅读(381)  评论(0编辑  收藏  举报