当前位置: 首页 > news >正文

第06讲:为何各大开源框架专宠 SPI 技术?

在此前的内容中,已经详细介绍了 SkyWalking Agent 用到的多种基础技术,例如,Byte Buddy、Java Agent 以及 OpenTracing 中的核心概念。本课时将深入介绍 SkyWalking Agent 以及 OAP 中都会使用到的 SPI 技术。

JDK SPI 机制

SPI(Service Provider Interface)主要是被框架开发人员使用的一种技术。例如,使用 Java 语言访问数据库时我们会使用到 java.sql.Driver 接口,每个数据库厂商使用的协议不同,提供的 java.sql.Driver 实现也不同,在开发 java.sql.Driver 接口时,开发人员并不清楚用户最终会使用哪个数据库,在这种情况下就可以使用 Java SPI 机制为 java.sql.Driver 接口寻找具体的实现。

当服务的提供者提供了一种接口的实现之后,需要在 Classpath 下的 META-INF/services/ 目录里创建一个以服务接口命名的文件,此文件记录了该 jar 包提供的服务接口的具体实现类。当某个应用引入了该 jar 包且需要使用该服务时,JDK SPI 机制就可以通过查找这个 jar 包的 META-INF/services/ 中的配置文件来获得具体的实现类名,进行实现类的加载和实例化,最终使用该实现类完成业务功能。

下面通过一个简单的示例演示 JDK SPI 的基本使用方式,示例如下:

首先我们需要创建一个 Log 接口,来模拟日志打印的功能:

public interface Log {void log(String info);
}

接下来提供两个实现 —— Logback 和 Log4j,分别代表两个不同日志框架的实现,如下所示:

public class Logback implements Log {@Overridepublic void log(String info) {System.out.println("Logback:" + info);}
}

public class Log4j implements Log {
    @Override
    public void log(String info) {
        System.out.println(“Log4j:” + info);
    }
}

在项目的 resources/META-INF/services 目录下添加一个名为 com.xxx.Log 的文件,这是 JDK SPI 需要读取的配置文件,具体内容如下:

com.xxx.impl.Log4j
com.xxx.impl.Logback

最后创建 main() 方法,其中会加载上述配置文件,创建全部 Log 接口实现的实例,并执行其 log() 方法,如下所示:

public class Main {public static void main(String[] args) {ServiceLoader<Log> serviceLoader = ServiceLoader.load(Log.class);Iterator<Log> iterator = serviceLoader.iterator();while (iterator.hasNext()) {Log log = iterator.next();log.log("JDK SPI"); }}
}
// 输出如下:
// Log4j:JDK SPI
// Logback:JDK SPI

JDK SPI源码分析

通过上述示例,我们可以看到 JDK SPI 的入口方法是 ServiceLoader.load()  方法,接下来我将对其具体实现进行深入分析。

在 ServiceLoader.load() 方法中,首先会尝试获取当前使用的 ClassLoader(获取当前线程绑定的 ClassLoader,查找失败后使用 SystemClassLoader),然后调用 reload() 方法,调用关系如下图所示:

在 reload() 方法中首先会清理 providers 缓存(LinkedHashMap 类型的集合),该缓存用来记录 ServiceLoader 创建的实现对象,其中 Key 为实现类的完整类名,Value 为实现类的对象。之后创建 LazyIterator 迭代器,该迭代器用于读取 SPI 配置文件并实例化实现类对象。

ServiceLoader.reload() 方法的具体实现,如下所示:

// 缓存,用来缓存 ServiceLoader创建的实现对象
private LinkedHashMap<String,S> providers = new LinkedHashMap<>();

public void reload() {
    providers.clear(); // 清空缓存
    lookupIterator = new LazyIterator(service, loader); // 迭代器
}

在前面的示例中,main() 方法中使用的迭代器底层就是调用了 ServiceLoader.LazyIterator  实现的。Iterator 接口有两个关键方法:hasNext() 方法和 next() 方法,这里的 LazyIterator 中的next() 方法最终调用的是其 nextService() 方法,hasNext() 方法最终调用的是 hasNextService() 方法,调用关系如下图所示:

首先来看 LazyIterator.hasNextService() 方法,该方法主要负责查找 META-INF/services 目录下的 SPI 配置文件,并进行遍历,大致实现如下所示:

private static final String PREFIX = "META-INF/services/";
Enumeration<URL> configs = null;
Iterator<String> pending = null;
String nextName = null;

private boolean hasNextService() {
    if (nextName != null) {
        return true;
    }
    if (configs  null) {
        // PREFIX前缀与服务接口的名称拼接起来,就是META-INF目录下定义的SPI配
        // 置文件(即示例中的META-INF/services/com.xxx.Log)
        String fullName = PREFIX + service.getName();
        // 加载配置文件
        if (loader 
 null)
            configs = ClassLoader.getSystemResources(fullName);
        else
            configs = loader.getResources(fullName);
    }
    // 按行SPI遍历配置文件的内容
    while ((pending == null) || !pending.hasNext()) { 
        if (!configs.hasMoreElements()) {
            return false;
        }
        // 解析配置文件
        pending = parse(service, configs.nextElement()); 
    }
    nextName = pending.next(); // 更新 nextName字段
    return true;
}

接下来,在 hasNextService() 方法中完成 SPI 配置文件的解析之后,再来看 LazyIterator.nextService() 方法,该方法负责实例化 hasNextService() 方法读取到的实现类,其中会将实例化的对象放到 providers 集合中缓存起来,核心实现如下所示:

private S nextService() {String cn = nextName;nextName = null;// 加载 nextName字段指定的类Class<?> c = Class.forName(cn, false, loader);if (!service.isAssignableFrom(c)) { // 检测类型fail(service, "Provider " + cn  + " not a subtype");}S p = service.cast(c.newInstance()); // 创建实现类的对象providers.put(cn, p); // 将实现类名称以及相应实例对象添加到缓存return p;
}

在 main() 方法中使用的迭代器的底层实现介绍完了,我们再来看一下其使用的真正迭代器,核心实现如下:

public Iterator<S> iterator() {return new Iterator<S>() {// knownProviders用来迭代 providers缓存Iterator<Map.Entry<String,S>> knownProviders= providers.entrySet().iterator();

        public boolean hasNext() {
            // 先走查询缓存,缓存查询失败,再通过 LazyIterator加载
            if (knownProviders.hasNext()) 
                return true;
            return lookupIterator.hasNext();
        }

        public S next() {
            // 先走查询缓存,缓存查询失败,再通过 LazyIterator加载
            if (knownProviders.hasNext())
                return knownProviders.next().getValue();
            return lookupIterator.next();
        }
        // 省略remove()方法
    };

JDK SPI 在 JDBC 中的应用

了解了 JDK SPI 实现的原理之后,我们来看实践中 JDBC 是如何使用 JDK SPI 机制加载不同数据库厂商的实现类。

JDK 中只定义了一个 java.sql.Driver 接口,具体的实现是由不同数据库厂商来提供的。这里以 MySQL 提供的 JDBC 实现包为例进行分析。

在 mysql-connector-java-*.jar 包中的 META-INF/services 目录下,有一个 java.sql.Driver 文件中只有一行内容,如下所示:

com.mysql.cj.jdbc.Driver

在使用 mysql-connector-java-*.jar 包连接 MySQL 数据库的时候,我们会用到如下语句创建数据库连接:

String url = "jdbc:xxx://xxx:xxx/xxx";
Connection conn = DriverManager.getConnection(url, username, pwd);

DriverManager 是 JDK 提供的数据库驱动管理器,其中的代码片段,如下所示:

static {loadInitialDrivers();println("JDBC DriverManager initialized");
}

在调用 getConnection() 方法的时候,DriverManager 类会被 Java 虚拟机加载、解析并触发 static 代码块的执行,在 loadInitialDrivers() 方法中通过 JDK SPI 扫描 Classpath 下  java.sql.Driver 接口实现类并实例化,核心实现如下所示:

private static void loadInitialDrivers() {String drivers = System.getProperty("jdbc.drivers")// 使用 JDK SPI机制加载所有 java.sql.Driver实现类ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);Iterator<Driver> driversIterator = loadedDrivers.iterator();while(driversIterator.hasNext()) {driversIterator.next();}String[] driversList = drivers.split(":");for (String aDriver : driversList) { // 初始化Driver实现类Class.forName(aDriver, true,ClassLoader.getSystemClassLoader());}
}

在 MySQL 提供的 com.mysql.cj.jdbc.Driver 实现类中,同样有一段 static 静态代码块,这段代码会创建一个 com.mysql.cj.jdbc.Driver 对象并注册到 DriverManager.registeredDrivers 集合中( CopyOnWriteArrayList 类型),如下所示:

static {java.sql.DriverManager.registerDriver(new Driver());
}

在 getConnection() 方法中,DriverManager 从该 registeredDrivers 集合中获取对应的 Driver 对象创建 Connection,核心实现如下所示:

private static Connection getConnection(String url, java.util.Properties info, Class<?> caller) throws SQLException {// 省略 try/catch代码块以及权限处理逻辑for(DriverInfo aDriver : registeredDrivers) {Connection con = aDriver.driver.connect(url, info);return con;}
}

Dubbo 对 JDK SPI 的改进

通过前面的分析可以发现,JDK SPI 在查找具体实现类的过程中,需要遍历 SPI 配置文件中定义的所有实现类,该过程中会将这些实现类全部实例化。如果 SPI 配置文件中定义了多个实现类,而我们只需要其中一个实现类时,就会生成不必要的对象。

Dubbo 为了解决上述问题,自己设计了一套 SPI 实现,但是思想与 JDK SPI 机制类似。作为思路的扩展,这里简单介绍一下 Dubbo SPI 的实现原理(SkyWalking 使用是 JDK SPI 而不是 Dubbo SPI )。

首先,Dubbo 将 SPI 配置文件改成了 KV 格式,例如:

dubbo=org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol

其中 key 就是一个简单的标记,当我们在为一个接口查找具体实现类时,可以指定 key 来选择具体实现,例如,这里指定 key 为 dubbo,Dubbo SPI 就知道我们要的是:org.apache.dubbo.rpc.protocol.dubbo.DubboProtocol 这个实现类。

Dubbo SPI 核心实现是 ExtensionLoader(位于 dubbo-common 模块中的 extension 包中),功能类似于 JDK SPI 中的 java.util.ServiceLoader,其使用方式如下所示:

Protocol protocol = ExtensionLoader.getExtensionLoader(Protocol.class).getExtension("dubbo");
// 很明显,在查找 Protocol这个接口的实现类时,还指定了"dubbo"这个key

ExtensionLoader.getExtensionLoader() 方法会根据接口类型从缓存中查找相应的 ExtensionLoader 实现,核心实现如下:

private static final ConcurrentMap<Class<?>, ExtensionLoader<?>> EXTENSION_LOADERS = new ConcurrentHashMap<>();

public static <T> ExtensionLoader<T> getExtensionLoader(Class<T> type) {
    ExtensionLoader<T> loader = 
         (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    if (loader == null) {
        EXTENSION_LOADERS.putIfAbsent(type,
               new ExtensionLoader<T>(type));
        loader = (ExtensionLoader<T>) EXTENSION_LOADERS.get(type);
    }
    return loader;
}

查找到接口对应的 ExtensionLoader 对象之后,会调用 getExtension() 方法,再根据传入的 key 查找相应的实现类,最终将其实例化后返回:

// 缓存,记录了 key到实现类对象Holder之间的映射关系
private final ConcurrentMap<String, Holder<Object>> cachedInstances = new ConcurrentHashMap<>();

public T getExtension(String name) {
    Holder<Object> holder = getOrCreateHolder(name);
    Object instance = holder.get();
    if (instance  null) { // double-check防止并发问题
        synchronized (holder) {
            instance = holder.get();
            if (instance 
 null) {
                // createExtension()方法中完成了 SPI配置文件的查找以及实现类
                // 的实例化,具体实现与 JDK SPI原理类似,其中还会处理 Dubbo中
                // 自定义的一些注解,不再展开分析
                instance = createExtension(name);
                holder.set(instance);
            }
        }
    }
    return (T) instance;
}

总结

本课时首先介绍了 JDK SPI 机制的原理,并通过 Log 示例演示了 JDK SPI 的使用方式,然后深入到 ServiceLoader 的源码中分析了 JDK SPI 的实现方式,接下来介绍了 JDBC 4.0 如何使用 JDK SPI 机制加载数据库驱动类,最后介绍了 Dubbo 对 JDK SPI 的改进。


相关文章:

第06讲:为何各大开源框架专宠 SPI 技术?

在此前的内容中&#xff0c;已经详细介绍了 SkyWalking Agent 用到的多种基础技术&#xff0c;例如&#xff0c;Byte Buddy、Java Agent 以及 OpenTracing 中的核心概念。本课时将深入介绍 SkyWalking Agent 以及 OAP 中都会使用到的 SPI 技术。 JDK SPI 机制 SPI&#xff08…...

[Unity] No.1 Single单例模式

单例模式 1. 基础 定义&#xff1a;单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象且作用相同时&#xff0c;为了防止频繁地创建对象使得内存飙升&#xff0c;单例模式可以让程序仅在内存中创建一个对象&#xff0c;让所有需要调用的地…...

【chatGPT知识分享】Flutter web 性能优化基础入门

简介 Flutter 是 Google 在这里插入代码片公司推出的跨平台移动应用开发框架&#xff0c;支持 Android、iOS 和 Web 等多个平台。Flutter Web 是 Flutter 在 Web 平台上的应用&#xff0c;可以开发具有良好用户体验的网站。但是&#xff0c;由于 Web 环境的特殊性&#xff0c;…...

探索Qt折线图之美:一次详尽的多角度解析

探索Qt折线图之美&#xff1a;一次详尽的多角度解析 第一章&#xff1a;Qt折线图的基本概念与应用场景&#xff08;Basic Concepts and Applications of Qt Line Charts&#xff09;1.1 Qt折线图简介&#xff08;Introduction to Qt Line Charts&#xff09;1.2 Qt折线图的应用…...

minio集群部署,4台服务器+1台nginx

4台主机1台nginx负载均衡 分布式Minio里所有的节点需要有同样的access秘钥和secret秘钥&#xff0c;即&#xff1a;用户名和密码 分布式Minio存放数据的磁盘目录必须是空目录 分布式Minio官方建议生产环境最少4个节点&#xff0c;因为有N个节点&#xff0c;得至少保证有N/2的节…...

实例分割算法BlendMask

实例分割算法BlendMask 论文地址&#xff1a;https://arxiv.org/abs/2001.00309 github代码&#xff1a;https://github.com/aim-uofa/AdelaiDet 我的个人空间&#xff1a;我的个人空间 密集实例分割 ​ 密集实例分割主要分为自上而下top-down与自下而上bottom-up两类方法…...

多线程、智能指针以及工厂模式

目录 一、unique_lock 二、智能指针 &#xff08;其实是一个类&#xff09; 三、工厂模式 一、unique_lock 参考文章【1】&#xff0c;了解unique_lock与lock_guard的区别。 总结:unique_lock使用起来要比lock_guard更灵活&#xff0c;但是效率会第一点&#xff0c;内存的…...

初探 VS Code + Webview

本文作者为 360 奇舞团前端开发工程师 介绍 VSCode 是一个非常强大的代码编辑器&#xff0c;而它的插件也非常丰富。在开发中&#xff0c;我们经常需要自己编写一些插件来提高开发效率。本文将介绍如何开发一个 VSCode 插件&#xff0c;并在其中使用 Webview 技术。首先介绍一下…...

Codeforces Round 864 (Div. 2)(A~D)

A. Li Hua and Maze 给出两个不相邻的点&#xff0c;最少需要堵上几个方格&#xff0c;才能使得两个方格之间不能互相到达。 思路&#xff1a;显然&#xff0c;对于不邻任何边界的方格来说&#xff0c;最少需要的是4&#xff0c;即上下左右都堵上&#xff1b;邻一个边界就-1&a…...

第3章-运行时数据区

此章把运行时数据区里比较少的地方讲一下。虚拟机栈&#xff0c;堆&#xff0c;方法区这些地方后续再讲。 转载https://gitee.com/youthlql/JavaYouth/tree/main/docs/JVM。 运行时数据区概述及线程 前言 本节主要讲的是运行时数据区&#xff0c;也就是下图这部分&#xff0c…...

delta.io 参数 spark.databricks.delta.replaceWhere.constraintCheck.enabled

总结 默认值true 你写入的df分区字段必须全部符合覆盖条件 .option("replaceWhere", "c2 == 2") false: df1 overwrite tb1: df1中每个分区的处理逻辑: - tb1中存在(且谓词中匹配)的分区,则覆盖 - tb1中存在(谓词中不匹配)的分区,则append - tb1中不存…...

Redis知识点

1. Redis-常用数据结构 Redis提供了一些数据结构供我们往Redis中存取数据&#xff0c;最常用的的有5种&#xff0c;字符串&#xff08;String&#xff09;、哈希(Hash)、列表&#xff08;list&#xff09;、集合&#xff08;set&#xff09;、有序集合&#xff08;zset&#xf…...

经典数据结构之2-3树

2-3树定义 2-3树&#xff0c;是最简单的B-树&#xff0c;其中2、3主要体现在每个非叶子节点都有2个或3个子节点&#xff0c;B-树即是平衡树&#xff0c;平衡树是为了解决不平衡树查询效率问题&#xff0c;常见的二叉平衡书有AVL树&#xff0c;它虽然提高了查询效率&#xff0c…...

Numpy从入门到精通——节省内存|通用函数

这个专栏名为《Numpy从入门到精通》&#xff0c;顾名思义&#xff0c;是记录自己学习numpy的学习过程&#xff0c;也方便自己之后复盘&#xff01;为深度学习的进一步学习奠定基础&#xff01;希望能给大家带来帮助&#xff0c;爱睡觉的咋祝您生活愉快&#xff01; 这一篇介绍《…...

Docker-compose 启动 lnmp 开发环境

GitHub传送阵 docker-lnmp 项目帮助开发者快速构建本地开发环境&#xff0c;包括Nginx、PHP、MySQL、Redis 服务镜像&#xff0c;支持配置文件和日志文件映射&#xff0c;不限操作系统&#xff1b;此项目适合个人开发者本机部署&#xff0c;可以快速切换服务版本满足学习服务新…...

《android源码阅读四》Android系统源码整编、单编并运行到虚拟机

1、编译环境 《安装Ubuntu系统》《android源码下载》 2、整编源码 进入Android源码根目录 cd AOSP初始化环境 source build/envsetup.sh清除缓存 make clobber选择编译目标 // 选择编译目标 lunch // 因为本次是在虚拟机中运行&#xff0c;这里使用x86 lunch aosp_x86_6…...

深度学习技巧应用8-各种数据类型的加载与处理,并输入神经网络进行训练

大家好,我是微学AI,今天给大家介绍一下深度学习技巧应用8-各种数据类型的加载与处理,并输入神经网络进行训练。在模型训练中,大家往往对各种的数据类型比较难下手,对于非结构化数据已经复杂的数据的要进行特殊处理,这里介绍一下我们如何进行数据处理才能输入到模型中,进…...

【笔试】备战秋招,每日一题|20230415携程研发岗笔试

前言 最近碰到一个专门制作大厂真题模拟题的网站 codefun2000&#xff0c;最近一直在上面刷题。今天来进行2023.04.15携程研发岗笔试&#xff0c;整理了一下自己的思路和代码。 比赛地址 A. 找到you 题意&#xff1a; 给定一个仅包含小写字母的 n n n\times n nn 的矩阵…...

【unity专题篇】—GUI(IMGUI)思维导图详解

&#x1f468;‍&#x1f4bb;个人主页&#xff1a;元宇宙-秩沅 &#x1f468;‍&#x1f4bb; hallo 欢迎 点赞&#x1f44d; 收藏⭐ 留言&#x1f4dd; 加关注✅! &#x1f468;‍&#x1f4bb; 本文由 秩沅 原创 &#x1f468;‍&#x1f4bb; 收录于专栏&#xff1a;uni…...

【C++ Metaprogramming】0. 在C++中实现类似C#的泛型类

两年前&#xff0c;笔者因为项目原因刚开始接触C&#xff0c;当时就在想&#xff0c;如果C有类似C#中的泛型限定就好了&#xff0c;能让代码简单许多。我也一度认为&#xff1a; 虽然C有模板类&#xff0c;但是却没办法实现C#中泛型特有的 where 关键词&#xff1a; public c…...

IDEA运行Tomcat出现乱码问题解决汇总

最近正值期末周&#xff0c;有很多同学在写期末Java web作业时&#xff0c;运行tomcat出现乱码问题&#xff0c;经过多次解决与研究&#xff0c;我做了如下整理&#xff1a; 原因&#xff1a; IDEA本身编码与tomcat的编码与Windows编码不同导致&#xff0c;Windows 系统控制台…...

【WiFi帧结构】

文章目录 帧结构MAC头部管理帧 帧结构 Wi-Fi的帧分为三部分组成&#xff1a;MAC头部frame bodyFCS&#xff0c;其中MAC是固定格式的&#xff0c;frame body是可变长度。 MAC头部有frame control&#xff0c;duration&#xff0c;address1&#xff0c;address2&#xff0c;addre…...

大型活动交通拥堵治理的视觉算法应用

大型活动下智慧交通的视觉分析应用 一、背景与挑战 大型活动&#xff08;如演唱会、马拉松赛事、高考中考等&#xff09;期间&#xff0c;城市交通面临瞬时人流车流激增、传统摄像头模糊、交通拥堵识别滞后等问题。以演唱会为例&#xff0c;暖城商圈曾因观众集中离场导致周边…...

解决Ubuntu22.04 VMware失败的问题 ubuntu入门之二十八

现象1 打开VMware失败 Ubuntu升级之后打开VMware上报需要安装vmmon和vmnet&#xff0c;点击确认后如下提示 最终上报fail 解决方法 内核升级导致&#xff0c;需要在新内核下重新下载编译安装 查看版本 $ vmware -v VMware Workstation 17.5.1 build-23298084$ lsb_release…...

【解密LSTM、GRU如何解决传统RNN梯度消失问题】

解密LSTM与GRU&#xff1a;如何让RNN变得更聪明&#xff1f; 在深度学习的世界里&#xff0c;循环神经网络&#xff08;RNN&#xff09;以其卓越的序列数据处理能力广泛应用于自然语言处理、时间序列预测等领域。然而&#xff0c;传统RNN存在的一个严重问题——梯度消失&#…...

蓝桥杯 2024 15届国赛 A组 儿童节快乐

P10576 [蓝桥杯 2024 国 A] 儿童节快乐 题目描述 五彩斑斓的气球在蓝天下悠然飘荡&#xff0c;轻快的音乐在耳边持续回荡&#xff0c;小朋友们手牵着手一同畅快欢笑。在这样一片安乐祥和的氛围下&#xff0c;六一来了。 今天是六一儿童节&#xff0c;小蓝老师为了让大家在节…...

【碎碎念】宝可梦 Mesh GO : 基于MESH网络的口袋妖怪 宝可梦GO游戏自组网系统

目录 游戏说明《宝可梦 Mesh GO》 —— 局域宝可梦探索Pokmon GO 类游戏核心理念应用场景Mesh 特性 宝可梦玩法融合设计游戏构想要素1. 地图探索&#xff08;基于物理空间 广播范围&#xff09;2. 野生宝可梦生成与广播3. 对战系统4. 道具与通信5. 延伸玩法 安全性设计 技术选…...

九天毕昇深度学习平台 | 如何安装库?

pip install 库名 -i https://pypi.tuna.tsinghua.edu.cn/simple --user 举个例子&#xff1a; 报错 ModuleNotFoundError: No module named torch 那么我需要安装 torch pip install torch -i https://pypi.tuna.tsinghua.edu.cn/simple --user pip install 库名&#x…...

基于TurtleBot3在Gazebo地图实现机器人远程控制

1. TurtleBot3环境配置 # 下载TurtleBot3核心包 mkdir -p ~/catkin_ws/src cd ~/catkin_ws/src git clone -b noetic-devel https://github.com/ROBOTIS-GIT/turtlebot3.git git clone -b noetic https://github.com/ROBOTIS-GIT/turtlebot3_msgs.git git clone -b noetic-dev…...

Ubuntu系统复制(U盘-电脑硬盘)

所需环境 电脑自带硬盘&#xff1a;1块 (1T) U盘1&#xff1a;Ubuntu系统引导盘&#xff08;用于“U盘2”复制到“电脑自带硬盘”&#xff09; U盘2&#xff1a;Ubuntu系统盘&#xff08;1T&#xff0c;用于被复制&#xff09; &#xff01;&#xff01;&#xff01;建议“电脑…...