登录
首页 >  数据库 >  MySQL

sagacity-sqltoy 缓存翻译功能整理,源码拆解

来源:SegmentFault

时间:2023-01-19 20:40:35 448浏览 收藏

在IT行业这个发展更新速度很快的行业,只有不停止的学习,才不会被行业所淘汰。如果你是数据库学习者,那么本文《sagacity-sqltoy 缓存翻译功能整理,源码拆解》就很适合你!本篇内容主要包括sagacity-sqltoy 缓存翻译功能整理,源码拆解,希望对大家的知识积累有所帮助,助力实战开发!

1.ORM 框架之 sqltoy

今天介绍一个让我觉得很特别、用起来特别舒服的 ORM 框架:sagacity-sqltoy,简称:sqltoy,这个框架完全国产,框架作者也是中国人。

这个框架我还和同事吐槽过,说框架太智能了,用了这个框架之后,我都不会使用 Mybatis 了,可想而知,这个框架有多优秀,推荐所有人可以学习一下,哪怕你不使用,学习一下这个框架的优点和特别的地方,也能学到很多。

2.sqltoy 是个什么框架

Github 开源地址:https://github.com/chenrenfei/sagacity-sqltoy

Gitee 开源地址:https://gitee.com/sagacity/sagacity-sqltoy

在线文档:https://chenrenfei.github.io/sqltoy/#/

关于 sqltoy 的介绍我从开源仓库上截取了一部分

SELECT
    i.staff_id,
    i.staff_name,
    i.sex_type,
    d1.DICT_NAME AS sex_type_name,
    i.post,
    d2.DICT_NAME AS post_name,
    i.create_by,
    d3.STAFF_NAME 
FROM
    sqltoy_staff_info i
    LEFT JOIN ( SELECT d.DICT_KEY, d.DICT_NAME FROM sqltoy_dict_detail d WHERE d.DICT_TYPE = "SEX_TYPE" ) d1 ON i.SEX_TYPE = d1.DICT_KEY
    LEFT JOIN ( SELECT d.DICT_KEY, d.DICT_NAME FROM sqltoy_dict_detail d WHERE d.DICT_TYPE = "POST_TYPE" ) d2 ON i.post = d2.DICT_KEY
    LEFT JOIN ( SELECT info.STAFF_ID, info.STAFF_NAME FROM sqltoy_staff_info info ) d3 ON i.create_by = d3.STAFF_ID 
WHERE
    i.staff_id = "S0003"

解释下这个 SQL,我要获取 S0003 的个人信息,sex_type、post、create_by 是 code 编码,需要转化为名称,方便前端展示。那我就需要去关联字典表和员工表,造成 SQL 需要进行三次关联。

而 sqltoy:

只需要在 xml 中的 sql 语句上配置 translate 缓存翻译功能就行了,code 编码则会自动转化为名称。

是不是简化了 sql,至于效率也不用担心,首先在 sql 层面省去了多表关联,code 翻译的结果值是从缓存中获取,效率更高。总的来看,提高了效率、简化了 sql 的复杂性,十分方便。

看完了案例,我们就来仔细看看关于缓存翻译的配置使用,以及作者没在文档中详细说的另外两种方式(service、rest)的缓存翻译。

3.1.缓存翻译初始化

缓存翻译的功能实现其实并不复杂,通过阅读源码能了解到缓存翻译的加载和使用。

例如:需要获取员工信息,顺便把员工的性别编码进行翻译,sqltoy 的流程如下:

  1. 根据业务 sql 去查询员工信息。
  2. jdbc 查询的结果值进行 aop 过滤,判断是否需要翻译。
  3. 需要翻译进入翻译逻辑,不需要进行结果值封装,返回结果。
  4. 进入翻译逻辑,按照翻译 sql 去查询字典值。
  5. 业务数据和翻译数据结果集,进行业务数据的 code 翻译,替换。
  6. 封装翻译之后的结果,返回 service 层。

总结一下,整个翻译就是用 Spring AOP 在底层对查询结果进行统一替换处理。

下面我们一起来看一下缓存翻译的加载流程,方便我们后面理解缓存翻译使用,这里建议大家去 Github 上把源码 clone 下来,打断点调两遍,会理解的更加透彻。

3.1.1 加载入口

org.sagacity.sqltoy.SqlToyContext 文件就是 sqltoy 框架加载主入口,这里加载的配置有:sqlToy 配置解析插件、实体对象管理器、翻译器插件、缓存管理器、统一公共字段赋值处理、延时检测时长、数据库方言参数、es的地址配置等等。然后翻译器插件就是缓存翻译。

在 SqlToyContext.initialize 初始化方法中找到翻译器的初始化方法,点进去。

这个方法的主要目的就是:配置缓存翻译、缓存路径、载入具体的缓存翻译配置。


cache 是缓存名称,名称必须要唯一(必填),datasource 是当前数据库数据源(非必填)。

第二步,在需要翻译的业务 sql 语句上,配置缓存翻译功能,并指定使用的缓存名称、需要翻译的 key 值。例如:

translate 可配置的属性列表:

  • cache:具体的缓存定义的名称
  • cache-type:一般针对数据字典,提供一个分类条件过滤
  • columns:sql中的查询字段名称,可以逗号分隔对多个字段进行翻译
  • cache-indexs:缓存数据名称对应的列,不填则默认为第二列(从0开始,1则表示第二列),例如缓存的数据结构是:key、name、fullName,则第三列表示全称

然后我们测试一下结果:

service 层,调用上面的业务 sql(getStaffInfoByStaffId):

  /**
  * 根据 ID 获取 vo
  *
  * @param staffId
  * @return
  */
  @Override
  public SqltoyStaffInfoVO getStaffInfoByStaffId(String staffId) {
    return this.sqlToyLazyDao.loadBySql("getStaffInfoByStaffId", new String[]{"staffId"}, new String[]{staffId}, SqltoyStaffInfoVO.class);
  }

test 层:

@Test
void testFive() {
   SqltoyStaffInfoVO vo = this.passwordService.getStaffInfoByStaffId("S0001");
   Assert.assertEquals("测试失败-性别",vo.getSexType(),"男");
   Assert.assertEquals("测试失败-职位类别",vo.getPost(),"管理岗");
   Assert.assertEquals("测试失败-职位等级",vo.getPostGrade(),"L10");
   System.out.printf("性别:%s,职位:%s,职位等级:%s",vo.getSexType(),vo.getPost(),vo.getPostGrade());
}

测试结果:

可以看到,断言测试通过,并且打印的日志显示,翻译的结果成功,成功把性别、职位类别、职位等级等编码翻译为中文。

3.2.2 service 缓存翻译

service 类型的缓存翻译,作者只是在 sqltoy-starter-showcase 模块项目中的 sqltoy-translate.xml 文件中提到过,具体的案例,在项目中我没有找到,以为功能没实现,作者说实现了,就自己研究了一下,测试了一遍翻译功能。

service 类型的缓存翻译我一开始认为有点多余,我的想法是,都有 sql 类型的缓存翻译了,干嘛多此一举弄一个 service,并且 service 的缓存翻译最终还是用 sql 获取数据。

仔细思考过后,我发现自己错了,存在就是合理的,我认为没有、多余,只是我没有使用场景,而作者开发 service 类型,肯定就是有使用场景的。

思考一番过后,说下我认为 service 类型的使用场景,在微服务系统中,A、B 两个系统是分开的,分别使用各自的库 A 库和 B 库,这个时候,我在 A 系统中查询一些业务数据,其中某个字段的编码所对应的 value 值并不在 A 库中,而是 B 库,这种场景下,数据是跨库的,无法关联查询,只能在业务代码中进行 B 库数据查询,进行编码转换。

而 service 类型的缓存翻译,则可以很顺利的解决这个问题,在 A 系统中通过 Fegin 调用 B 系统的 API,形成一个 service 方法,然后 A 系统在业务 sql 使用 service 类型的缓存翻译,就可以翻译 sql 中的编码了。

说起来有点绕,我们案例整起:

我这里没有开两个服务,而是在 service 里新写一个方法,然后在业务 sql 中调用这个方法,模拟跨服务的场景。

第一步,先把缓存方法书写好。依然是把员工ID翻译为员工姓名。

    /**
     * 获取所有员工信息记录
     *
     * @return
     */
    @Override
    public List queryStaffInof() {
        List findStaffInof = this.sqlToyLazyDao.findBySql("findStaffInof", new SqltoyStaffInfoVO());
        List list = new ArrayList(findStaffInof.size());
        findStaffInof.stream().forEach(e -> {
            Object[] arr = new Object[]{e.getStaffId(),e.getStaffName()};
            list.add(arr);
        });
        return list;
    }

这个方法可以看作是 A 系统中通过 Fegin 调用 B 系统的 API 返回的值。

方法返回类型是:List,这个是缓存翻译底层的限制,返回类型可以是:List>、List

找到对应的源码可以看到限制:

private static HashMap wrapCacheResult(Object target, TranslateConfigModel cacheModel) {
        if (target == null) {
            return null;
        } else if (target instanceof HashMap && ((HashMap)target).isEmpty()) {
            return null;
        } else if (target instanceof HashMap && ((HashMap)target).values().iterator().next().getClass().isArray()) {
            return (HashMap)target;
        } else {
            LinkedHashMap result = new LinkedHashMap();
            Object[] row;
            if (target instanceof HashMap) {
                if (!((HashMap)target).isEmpty()) {
                    Iterator iter;
                    Entry entry;
                    if (((HashMap)target).values().iterator().next() instanceof List) {
                        iter = ((HashMap)target).entrySet().iterator();

                        while(iter.hasNext()) {
                            entry = (Entry)iter.next();
                            row = new Object[((List)entry.getValue()).size()];
                            ((List)entry.getValue()).toArray(row);
                            result.put(entry.getKey(), row);
                        }
                    } else {
                        iter = ((HashMap)target).entrySet().iterator();

                        while(iter.hasNext()) {
                            entry = (Entry)iter.next();
                            result.put(entry.getKey(), new Object[]{entry.getKey(), entry.getValue()});
                        }
                    }
                }
            } else if (target instanceof List) {
                List tempList = (List)target;
                if (!tempList.isEmpty()) {
                    int cacheIndex = cacheModel.getKeyIndex();
                    int i;
                    int n;
                    List dataSet;
                    if (tempList.get(0) instanceof List) {
                        i = 0;

                        for(n = tempList.size(); i  1) {
                        dataSet = BeanUtil.reflectBeansToInnerAry(tempList, cacheModel.getProperties(), (Object[])null, (ReflectPropertyHandler)null, false, 0);
                        Iterator var12 = dataSet.iterator();

                        while(var12.hasNext()) {
                            Object[] row = (Object[])var12.next();
                            result.put(row[cacheIndex].toString(), row);
                        }
                    }
                }
            }

            return result;
        }
    }

wrapCacheResult 方法的参数:

  • target 就是 queryStaffInof() 方法(可以理解为 B 系统的方法)的返回值。
  • TranslateConfigModel 是缓存翻译的模型 Bean,Bean 里面有缓存翻译的相关基础属性,例如:

    • 缓存类型(sql,service,rest),
    • 数据源,
    • 缓存名称,
    • 自定义的 ServiceBean,
    • 自定义的 ServiceMethod,
    • rest 类型的 url 等等。

第二步,在 A 系统中的缓存翻译文件中,配置 service 类型的缓存,方法有参,无参,在缓存的 xml 中没去区别。。


  • service service 类的路径
  • method 具体调用方法
  • cache 缓存名称

第三步,A 系统的业务 sql 上配置 service 缓存。这里和 sql 类型的使用方式是一模一样的,service 方法如果有参数,也是通过 cache-type 属性传入。唯一不一样的地方就是 cache 改为 servcie 的缓存名称。

然后我们测试一下结果,调用的 service 层方法不变,只是把 service 对应的业务 sql 上的缓存由 sql 类型换为 servcie 类型。

test 层

@Test
void testEight() {
  SqltoyStaffInfoVO vo = this.passwordService.getStaffInfoByStaffId("S0001");
  Assert.assertEquals("测试失败-创建人姓名",vo.getCreateBy(),"张三");
  System.out.printf("创建人姓名:%s",vo.getCreateBy());
}

测试结果:

断言测试通过,并且打印的日志显示,翻译的结果成功,成功把创建人ID翻译为中文名称。

我补充一下,service 缓存是如何通过你配置的 service 和 method 就获取到缓存数据的?其实是通过配置的 service 反射调用 method 来获取数据的,这点可以在源码中找到。

这是缓存翻译的三种类型判断,根据类型调用不同的缓存数据获取方法,我们进入 service 类型看看,看下底层是不是反射。



TranslateFactory.getServiceCacheData() -> SqlToyContext.getServiceData() -> BeanUtil.invokeMethod() -> Method.invoke()。

从 servcie 类型调用 getServiceCacheData() 一直往下,会走到 Method.invoke(),可以证明 servcie 类型缓存翻译方法调用方式通过反射来进行的。

3.2.3 rest 缓存翻译

rest 缓存翻译,它和 servcie、sql 类型都不一样,rest 缓存翻译是通过 url 地址向第三方服务发起请求,获取所需要的缓存值或字典数据。

前面 service 缓存翻译可以跨服务,从 A 服务调用 B 服务的数据,而 rest 则可以跨系统,从 A 系统调用 B 系统的数据(当然, service 也可以做到,在本地 servcie 层通过 HttpClient 调用第三方服务和通过 Fegin 调用其他服务都是一样的)。

说下我认为 rest 缓存翻译的使用场景,假设我公司有两个单体应用 A 和 B,A 和 B 各自有各自的服务器、数据库、nginx,如果 A 需要调用 B 的数据字典,来翻译自己的数据里的某个字段。

这个场景用 service 也能做到,不过需要自己去写 HttpClient 部分的代码,而 rest 则在底层帮用户做好了,只需要提供调用 url 就行。

如果调用的 B 系统接口还有用户身份验证,也可以配置一个账号,进行请求认证。

说了这么多,人都整懵了,我们案例走起。

第一步,在缓存文件中,配置 rest 缓存。

  • url 就是你请求的第三方系统地址 (必填)
  • cache 缓存名称 (必填)
  • username 身份认证的用户名 (非必填)
  • password 身份认证的密码 (非必填)

用户名和密码两个属性,看请求的接口,接口需要进行身份认证,就需要加上,不需要认证,则可以没有。

其实到这里,rest 的缓存就配置好了,很简单的一个配置,使用就直接在业务 sql 上配置缓存,指定使用缓存名称为第一步的缓存名称就行了,和使用 sql 类型、service 类型的缓存翻译方式没有区别。

下面通过案例和 rest 类型的源码,来帮助大家更好的理解 rest 类型的原理和身份认证这部分,以及如果是带参数请求,第三方的接口如何接收参数。

这本地写了一个案例,翻译员工的岗位,用的缓存翻译,就是第一步中配置的 rest 翻译,带了一个字典编码作为参数。

然后我们看下 sqltoy 底层是如何调用第三方接口的的。

在 TranslateFactory 类下有 getCacheData 方法

getCacheData 方法是根据不同的缓存类型调用对应的方法获取缓存数据,然后返回上层,进行翻译值的替换。

我们进入 rest 类型的方法看看。

重点看下 332 行,这一行,拿到了请求的 url、username、password、请求参数 Key、请求参数 Value 等信息,通过封装的 HttpClient 进行发起了接口请求。

332 行之后的代码就是对接口响应的数据进行封装处理,把 String 字符串转为 JSON 格式,再转为 List 返回给上层调用方法。

我们继续进入封装的 doPost 方法看看。

重点看两个地方,75 -79 行,这里是设置身份认证的地方,另一个就是 84 - 92 行,这部分是设置请求参数,请求参数的封装用的是:UrlEncodedFormEntity,body 参数格式会转为“KEY1=VALUE1&KEY2=VALUE2&...”这种形式,服务端接收以后也要依据这种协议形式做处理。

好了,rest 类型的底层说完了,一起来看下服务端的代码,以及接收参数的处理。

在方法的第一行,是参数转换处理方法,第二行才是服务端的业务逻辑代码。

由于参数是通过 UrlEncodedFormEntity 方式传递的,我通过 HttpServletRequest 读取字符流,然后转为字符串,根据 = 号切割,获取 value 值,这个 value 值就是 rest 请求带过来字典参数。

整个 rest 配置流程和底层源码都讲完了,我们测试一遍,看看翻译功能对不对。我这里的测试,是把测试的服务,打成 jar 包,用 8082 端口启动,模拟 A、B 两个单体应用,下面是我的测试结果。

可以看到,(8080 A 系统)本地调用 servcie 方法获取员工信息,断言的结果是正确通过的,IDEA 控制台打印的日志显示员工岗位翻译成功。

然后在 (8082 B 系统)的日志上,能看到获取的参数 Value 和查询 sql 日志。

4.缓存翻译底层 Ehcache

这一节,主要说下缓存翻译的底层缓存和一些源码上的内容。

4.1 Ehcache

缓存翻译的缓存,底层实现是:Ehcache,这点在框架的源码能找到,源码位置在:org.sagacity.sqltoy.translate.cache 包下面。

红色部分是缓存翻译相关的文件,包括:解析缓存翻译的配置、定时检测缓存是否更新程序、缓存刷新检测、缓存翻译器、缓存相关 model、缓存实现等。

绿色部分就是缓存的实现,TranslateCacheManager 是抽象类,提供缓存接口规范。

TranslateEhcacheManager 是具体缓存实现类,继承抽象类,实现缓存具体功能。

从实现类中我们可以看到,实现类用的缓存是 CacheManager,而 CacheManager 就是 Ehcache 的缓存管理器。

在实现类重写的 init 方法可以看到,如果 CacheManager 是 null,就为 CacheManager 创建一个实例,提供给其他方法(put、getCache)使用。

4.2 缓存翻译的 AOP 原理

3.1.缓存翻译初始化 节我讲了,缓存翻译的工作流程,但只是进行文字描述,没用过这个框架的朋友可能会有点懵,这一小节,我通过源码来仔细梳理缓存翻译的工作流程。

缓存翻译是如何获取值的?又是如何把 code 编码替换为中文名称的?又是如何在翻译之后把结果集返回给我们的 service 层?这一小节具体解决这三个问题。

4.2.1 AOP 处理

我们从业务逻辑代码 servcie 层调用的 SqlToyLazyDao.loadBySql 方法进去,一直往下找:SqlToyDaoSupport.loadBySql -> SqlToyDaoSupport.loadByQuery。

第一行的 SqlToyConfig 是一个 sql 解析对象,里面有 sql 语句属性、参数 key、参数 value、数据库方言、翻译器、脱敏配置等,其实就是 sql.xml 解析出来的对象,里面包含当前 sql 的各种配置。

第二行就是拿到 sqltoyCofing 实例,去查询数据以及根据实例里的配置,去解析 sql 的转换、解析、翻译、脱敏、行转列的操作。进入 findByQuery 方法,看里面的具体操作。

方法的 750 - 755 行,操作的是把 sql 中的参数 key 替换为占位符号,并且把参数 values 按照顺序整理到 queryParam 实例中。

queryParam 的 sql 就是一个完整的 sql,例如:

sql select id,name,age from users u where u.name = ? and u.age ? 

而 paramsValue 就是 ?的数组 value :["java",21]。

然后我们看具体的调用 findBySql 方法,我本地用的 Mysql 数据库,所以我进入的是 Mysql 的实现方法。

一直往下找:MySqlDialect.findBySql -> DialectUtils.findBySql。

现在我们到底层了,相信读者对我标记出来的代码不陌生,JDBC 代码而已,根据 sql 查询数据,然后看 222 行,这里很重要,框架在这里把 JDBC 查询出来的实例 rs 进行了结果值封装、替换,然后把封装、替换之后的 rs 结果集返回给 service 层。

对这个功能有没有很熟悉?像不像 Spring AOP 切面处理?其实就是 AOP 原理,本来我没意识到是 AOP,后面在 sqltoy 的 QQ 群里,作者提了一句,我才意识到。

到这里就能知道我们的结果集为什么会进行 code 编码翻译,翻译为中文名称,因为框架底层对 rs(ResultSet) 集合进行了替换

然后我们继续看缓存翻译是如何获取值的?又是如何把 code 编码进行替换的?

4.2.2 缓存翻译是如何获取值的?又是如何把 code 编码进行替换的?

点击 222 行的 ResultUtils.processResultSet() 方法。

再进入 104 行的 getResultSet() 方法。

看到 288 行,这里通过

 sqlToyConfig.getTranslateMap()
方法获取当前运行的 sql 是否有解析到缓存翻译器。

如果你在业务 sql 上配置了

就有缓存翻译器,没配置就代表不需要翻译,就没有缓存翻译器。

然后看到 289 行,如果有缓存翻译器,就获取翻译器,并在 292 根据翻译器获取缓存数据,我们进入到

 sqlToyContext.getTranslateManager().getTranslates()
方法看看。

方法 139 行在循环翻译器,然后挨个处理每个翻译器,141 行,判断加载的缓存有没有翻译器里的缓存名称,有就把当前翻译器取出来,通过 143 行的 getCacheData 方法取获取缓存数据。

175 行,先是根据缓存名称取缓存数据,如果获取到的数据为 null 或为空,则通过

TranslateFactory.getCacheData()
方法去数据库获取数据,查到数据之后,放入缓存( 181 行)。

这就是

TranslateFactory.getCacheData()
,根据翻译器的类型,进入不同的缓存数据加载方法。

这里我们看 sql 类型方法。

283 行获取缓存上数据源,如果没获取到就获取当前 sql 解析的数据源,然后 288 行判断缓存有没有传入参数,也就是

  
cache-type 属性的值,有参数则构造一个带条件 QueryExecutor,调用
DialectFactory.getInstance().findByQuery() 
方法就又进入了 AOP 小节的讲的 findByQuery() 方法。

这个方法是不是有点眼熟?其实就是 service 层调用的底层方法,缓存器调用 findByQuery 方法,通过 JDBC 把缓存器解析的 sql 执行,获取到翻译数据。

缓存数据取到了,我们回到 getResultSet(),看到 293 行,缓存数据为 null 或为空,则把 288 行的缓存翻译器标记赋值为:false。

假设缓存数据不为空,缓存标记为 true,方法一直往下运行,找到 370 行(我标记的位置),先是判断缓存标记是否为 true,为真则进入 processResultRowWithTranslate 方法,我们进入该方法。

注意看方法上的注释:@todo 存在缓存翻译的结果处理。这就是我们要找的翻译方法,把 code 编码替换为中文名称。

这里我在本地断点,把方法的运行情况截图下来,方便大家理解各个参数的意思和值以及是如何替换的。

  • labelNames 是业务 sql 上的所有字段,例如业务 sql 为:select id,name from users,那 labelNames = [id,name]
  • fieldValue 是根据当前 labelNames 获取的 value值,例如当前 label 为 name,fieldValue 则为:张三,也就是业务 sql 查询得到的数据。
  • keyIndex 是方法参数 size( labelNames 的长度 ) 的循环当前值。

然后我们看下 translateKey() 方法,还是断点调试的截图。

方法第一行获取了需要翻译的 code 编码,然后判断是不是单值翻译,如果是多个值翻译,就进入 else 进行切割,然后循环翻译,我这里是单值。

cacheValues 是根据翻译的 code 编码获取到对应的缓存对象,如果没有获取到,则代表没查到这个 code 编码的中文信息,打印错误日志。对象存在,就根据翻译器的下标获取中文名称,赋值给 fieldValue 并返回。

这里说一下 translate.getIndex() 下标问题,下标默认是 1,可以在 更改 cache-indexs 属性,这个下标为 1 是什么意思呢?它代表的就是把 code 编码替换为缓存 sql 的哪一个字段。

translateKey() 方法把 code 替换为中文后,返回到 processResultRowWithTranslate() 方法,添加到 rowData(List 集合)实例中,然后逐步返回到 DialectUtils.findBySql(),最终返回到 service 层,被我们获取。

这就是完整的缓存翻译流程,从一开始的业务 sql 查询,到返回 rs AOP 处理,到判断是否有翻译器,到获取缓存数据,到翻译 code,再到翻译完成,返回到 rs AOP 处理位置,返回到 servcie,整个流程,就讲完了。

5.结尾

缓存翻译这个功能,是 sqltoy 框架最吸引我的地方,它的便捷性、性能、sql 简化和设计,都让我着迷。

这里我建议大家把 sqltoy 的源码 clone 下来,仔细看看,会看到很多我们用起来没注意的小细节,能帮助我们更好的理解 sqltoy。

最后,如果我的文章有帮助到大家或者认为写的不错,请分享给更多人,谢谢。

如文章有错误地方,欢迎大家留言或私信(boyguhui@qq.com),告知我,感激不尽。

好了,本文到此结束,带大家了解了《sagacity-sqltoy 缓存翻译功能整理,源码拆解》,希望本文对你有所帮助!关注golang学习网公众号,给大家分享更多数据库知识!

声明:本文转载于:SegmentFault 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>
评论列表