Kubernetes API 多版本和序列化

前言

三年前在分析Kubernete APIServer时,就经常遇到两个东西,一个是Scheme,一个是Codec,当时对它们并不是很理解,也没有去细究,但是后来越来越多的能够遇见它们,尤其是在做Kubernetes API相关的开发时,Scheme的出镜率很高,于是查了下资料才知道,原来他们跟Kubernetes的API多版本和序列化有关,而API多版本又属于Kubernetes API的重要特性,它跟一般应用的多版本API还不太一样,有它自己的特色,因此搞懂它的相关概念和实现原理是相当有必要的。从前面介绍apiserver的系列文章上也可以看到,因为Kubernetes要处理很多种资源,可以说是包罗万象,所以它做了很多的抽象,而API多版本则是在这些抽象之上再度抽象的艺术,因此还是比较难以理解的,但是这些设计都是随着时间的推移逐渐演进出来的,有它的合理性,甚至当理解了它的机制之后,会发现它的设计之美,仿佛是一件艺术品。毕加索说,艺术是揭示真理的谎言,就让我们拨开API多版本的层层迷雾,去探寻下它的本质。

功能介绍

Kubernetes API多版本这个特性跟很多其他应用的多版本API很不一样,首先就是它有分组的概念,即Group,因为Kubernetes有很多的资源,不太好统一管理,所以采取分而治之的方式,以组的方式去管理,而它的多版本是跟组关联的,即一个组可以同时有多个版本,即Version,而组内的资源种类,即Kind,通过多版本的方式去迭代进化,这三者在Kubernetes中经常合起来表示某个版本的某种资源,即 GroupVersionKind,简称 GVK;其次就是多版本之间的资源对象可以互相转换,这个是什么意思呢?即底层数据是同一份数据,但是根据调用API的版本不同,可以转换成对应版本的资源对象,比如有一个资源它现在有三个版本的API同时存在:v1, v1beta1, v1beta2,你通过调用v1beta1版本的API,创建了一个该对象,那你通过v1, v1beta1, v1beta2的API,均可以将该对象读出来,或者做其他的操作,那这个特性有什么用呢?谁会去把v1beta1版本的对象转成v1版本来用呢?这是我看到这个特性时,脑海中的第一个问题,通过阅读官方文档可以知道,其实这个特性完全是为了保持API的兼容而设计的,正常情况下,没有人会混着版本去用,它发挥作用的地方主要在升级迭代时,要知道随着API的迭代开发,API会逐渐GA进入到稳定版本,那beta版,以及alpha版则会在某一个阶段被移除,这时候,你用beta版API创建的资源对象,仍然能够用稳定版API来操作,这样就实现了无缝升级。

我们来举个例子体验下,在1.28版本的Kubernetes中,有一个用来做流控的API,叫FlowSchema,目前有两个版本,v1beta2和v1beta3,我们以v1beta2创建一个FlowSchema对象,然后使用v1beta2和v1beta3分别将这个对象读出来:

1. 使用v1beta2 api创建该对象:

apiVersion: flowcontrol.apiserver.k8s.io/v1beta2
kind: FlowSchema
metadata:
  name: test
spec:
  matchingPrecedence: 1000
  priorityLevelConfiguration:
    name: exempt
  rules:
    - nonResourceRules:
      - nonResourceURLs:
          - "/healthz"
          - "/livez"
          - "/readyz"
        verbs:
          - "*"
      subjects:
        - kind: Group
          group:
            name: "system:unauthenticated"
kubectl apply -f flowschema.yml

2. 使用v1beta2读取:

kubectl get flowschema.v1beta2.flowcontrol.apiserver.k8s.io test -o yaml

或者

curl -H "Accept: application/yaml" http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/v1beta2/flowschemas/test

输出:

Warning: flowcontrol.apiserver.k8s.io/v1beta2 FlowSchema is deprecated in v1.26+, unavailable in v1.29+; use flowcontrol.apiserver.k8s.io/v1beta3 FlowSchema
apiVersion: flowcontrol.apiserver.k8s.io/v1beta2
kind: FlowSchema
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"flowcontrol.apiserver.k8s.io/v1beta2","kind":"FlowSchema","metadata":{"annotations":{},"name":"test"},"spec":{"matchingPrecedence":1000,"priorityLevelConfiguration":{"name":"exempt"},"rules":[{"nonResourceRules":[{"nonResourceURLs":["/healthz","/livez","/readyz"],"verbs":["*"]}],"subjects":[{"group":{"name":"system:unauthenticated"},"kind":"Group"}]}]}}
  creationTimestamp: "2023-11-05T02:17:34Z"
  generation: 2
  name: test
  resourceVersion: "52473"
  uid: d70bf2e9-3773-4cab-ae4d-b5e060009beb
spec:
  matchingPrecedence: 1000
  priorityLevelConfiguration:
    name: exempt
  rules:
  - nonResourceRules:
    - nonResourceURLs:
      - /healthz
      - /livez
      - /readyz
      verbs:
      - '*'
    subjects:
    - group:
        name: system:unauthenticated
      kind: Group
status:
  conditions:
  - lastTransitionTime: "2023-11-05T02:25:45Z"
    message: This FlowSchema references the PriorityLevelConfiguration object named
      "exempt" and it exists
    reason: Found
    status: "False"
    type: Dangling

3. 使用v1beta3读取:

kubectl get flowschema.v1beta3.flowcontrol.apiserver.k8s.io test -o yaml

或者

curl -H "Accept: application/yaml" http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/v1beta3/flowschemas/test

输出:

Warning: flowcontrol.apiserver.k8s.io/v1beta3 FlowSchema is deprecated in v1.29+, unavailable in v1.32+
apiVersion: flowcontrol.apiserver.k8s.io/v1beta3
kind: FlowSchema
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"flowcontrol.apiserver.k8s.io/v1beta2","kind":"FlowSchema","metadata":{"annotations":{},"name":"test"},"spec":{"matchingPrecedence":1000,"priorityLevelConfiguration":{"name":"exempt"},"rules":[{"nonResourceRules":[{"nonResourceURLs":["/healthz","/livez","/readyz"],"verbs":["*"]}],"subjects":[{"group":{"name":"system:unauthenticated"},"kind":"Group"}]}]}}
  creationTimestamp: "2023-11-05T02:17:34Z"
  generation: 2
  name: test
  resourceVersion: "52473"
  uid: d70bf2e9-3773-4cab-ae4d-b5e060009beb
spec:
  matchingPrecedence: 1000
  priorityLevelConfiguration:
    name: exempt
  rules:
  - nonResourceRules:
    - nonResourceURLs:
      - /healthz
      - /livez
      - /readyz
      verbs:
      - '*'
    subjects:
    - group:
        name: system:unauthenticated
      kind: Group
status:
  conditions:
  - lastTransitionTime: "2023-11-05T02:25:45Z"
    message: This FlowSchema references the PriorityLevelConfiguration object named
      "exempt" and it exists
    reason: Found
    status: "False"
    type: Dangling

可以看到,使用两个版本读出来的对象,几乎完全一样,除了apiVersion字段有区别,metadata字段中的resourceVersionuid字段都一样,说明他们其实是同一个对象,我们可以直接查看etcd的数据库来确认下:

# etcdctl --endpoints  http://127.0.0.1:2379  get / --prefix --keys-only | grep test
/registry/flowschemas/test

# etcdctl --endpoints  http://127.0.0.1:2379  get /registry/flowschemas/test
/registry/flowschemas/test
k8s
2
$flowcontrol.apiserver.k8s.io/v1beta3
FlowSchema


test"*$d70bf2e9-3773-4cab-ae4d-b5e060009beb2bB
0kubectl.kubernetes.io/last-applied-configuration{"apiVersion":"flowcontrol.apiserver.k8s.io/v1beta2","kind":"FlowSchema","metadata":{"annotations":{},"name":"test"},"spec":{"matchingPrecedence":1000,"priorityLevelConfiguration":{"name":"exempt"},"rules":[{"nonResourceRules":[{"nonResourceURLs":["/healthz","/livez","/readyz"],"verbs":["*"]}],"subjects":[{"group":{"name":"system:unauthenticated"},"kind":"Group"}]}]}}

,api-priority-and-fairness-config-consumer-v1Apply$flowcontrol.apiserver.k8s.io/v1betaFieldsV1:
{"f:status":{"f:conditions":{"k:{\"type\":\"Dangling\"}":{".":{},"f:lastTransitionTime":{},"f:message":{},"f:reason":{},"f:status":{},"f:type":{}}}}}Bstatus
kubectl-client-side-applyUpdate$flowcontrol.apiserver.k8s.io/v1betaFieldsV1:
{"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}}},"f:spec":{"f:matchingPrecedence":{},"f:priorityLevelConfiguration":{"f:name":{}},"f:rules":{}}}BR

exempt"C
!
Group
system:unauthenticated
*/healthz2/livez2/readyz

DanglingFal"Found*]This FlowSchema references the PriorityLevelConfiguration object named "exempt" and it exists"

可以看到存到数据库中实际上只有一条记录,但是却可以读出来两个不同的版本,说明在调用不同版本的API读取对象时,肯定是经历了某种转换。

这个例子其实还不太好,因为两个版本的 FlowSchema 对象的字段是完全一样的,如果两个版本之间有字段的差异,可能更能说明问题,但是由于现在 Kubernetes 发展的已经很成熟了,各个API都已经趋于成熟,都逐渐的把beta版的API给移除了,或者beta版跟ga版几乎没有差异,beta的存在仅仅是为了能够兼容一下旧版本的应用,还有一些组是有alpha版本的,但是它是用来孵化新功能的,还没有成熟到进入beta或者ga的阶段,所以在beta或者ga版本的API中,还不存在alpha版中的对象,可以使用较旧版本的Kubernetes,肯定会有同一个对象在不同版本中同时存在的情况,而且可能会有字段的差异,或者关注下当前版本的alpha功能,未来肯定会经历beta, ga的迭代。

通过上面的例子,我们大概感受了下API多版本的功能,此外,还有个多协议的功能点,即用户可以选择API返回的数据的格式,比如上例中,我们通过命令行的-o选项或者api请求的Accept Header参数,来指定了希望返回yaml格式的数据,于是apiserver会将API资源给序列化成yaml格式返回给客户端,除了yaml格式,apiserver还支持序列化成json以及protobuf格式,例如:

curl -H "Accept: application/json" http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/v1beta3/flowschemas/test
curl -H "Accept: application/vnd.kubernetes.protobuf" http://127.0.0.1:8001/apis/flowcontrol.apiserver.k8s.io/v1beta3/flowschemas/test

k8s
2
$flowcontrol.apiserver.k8s.io/v1beta3
FlowSchema


test"*$d70bf2e9-3773-4cab-ae4d-b5e060009beb252473bB
0kubectl.kubernetes.io/last-applied-configuration{"apiVersion":"flowcontrol.apiserver.k8s.io/v1beta2","kind":"FlowSchema","metadata":{"annotations":{},"name":"test"},"spec":{"matchingPrecedence":1000,"priorityLevelConfiguration":{"name":"exempt"},"rules":[{"nonResourceRules":[{"nonResourceURLs":["/healthz","/livez","/readyz"],"verbs":["*"]}],"subjects":[{"group":{"name":"system:unauthenticated"},"kind":"Group"}]}]}}

,api-priority-and-fairness-config-consumer-v1Apply$flowcontrol.apiserver.k8s.io/v1betaFieldsV1:
{"f:status":{"f:conditions":{"k:{\"type\":\"Dangling\"}":{".":{},"f:lastTransitionTime":{},"f:message":{},"f:reason":{},"f:status":{},"f:type":{}}}}}Bstatus
kubectl-client-side-applyUpdate$flowcontrol.apiserver.k8s.io/v1betaFieldsV1:
{"f:metadata":{"f:annotations":{".":{},"f:kubectl.kubernetes.io/last-applied-configuration":{}}},"f:spec":{"f:matchingPrecedence":{},"f:priorityLevelConfiguration":{"f:name":{}},"f:rules":{}}}BR

exempt"C
!
Group
system:unauthenticated
*/healthz2/livez2/readyz

DanglingFal"Found*]This FlowSchema references the PriorityLevelConfiguration object named "exempt" and it exists"

从上面的功能介绍来看,Kubernetes能够做到这个程度的API兼容,真的是很良心的,难怪它会一统江湖,Kubernetes社区将API的兼容性看得很重要,除了每个API都有一个进化迭代的过程之外,一旦API进入到GA稳定版阶段,后续对它的修改一定是不能破坏兼容性的,关于API兼容性的更多内容,可以阅读下社区的这个文档:Changing the API

OK,看了上面的功能介绍和示例,我们心里可能会有一些困惑:

  1. 不同版本之间是如何转换的?有两个版本还好说,那如果是有很多版本,难道要两两组合下吗,这会不会太傻了?
  2. 实际存储到etcd中的是什么版本的?上面的例子中,是通过v1beta2 API存进去的对象,可是通过etcdctl查看数据库中的数据,怎么好像是存储的v1beta3版本的?
  3. 实际存储到etcd中的数据是什么格式的?从上面的例子中,可以看到,使用etcdctl和curl protobuf格式查看到的数据,好像长的是一样的,难道直接是存储的protobuf格式的?

好,带着这些问题,我们进入下一个小节,来看看它的实现原理。

原理介绍

主要来说说版本是怎么转换的,为了方便各个版本之间互相转换,APIServer引入了一个内部版本的概念,每个API对象都有一个对应的内部版本,它是一个特殊的版本,是在APIServer内部对各个API对象进行处理时使用的数据结构(Struct),而不是使用的某个具体版本的数据结构(Struct),当该API对象跟外部交互时,则会从内部版本转换成对应的具体版本,我们称之为外部版本,这个“外部”其实包含两个地方:一个是通过HTTP协议跟客户端交互时,会将其转换成客户端请求的版本,一个是将该对象存储到数据库时,数据库会将其保存成某个版本的数据结构(Struct)。这个内部版本,有点类似于中间版本的概念,不论你请求的是哪个版本,都是那个版本跟内部版本之间互相转换,具体版本之间是不会直接进行转换的,这样就将一个网状的结构,转换成了星状的结构,减少了数据处理的维度,每个版本的API对象,只需要申明自己怎么跟内部版本进行转换就可以了。

还有就是将某个对象存储到数据库时,并不是存储的内部版本的数据结构,而是存储的某个具体版本的数据结构,一般是该API对象最稳定版本的数据结构,比如某个API对象同时有两个版本,v1和v1beta1,那么不论通过哪个版本请求过来的,最终存储到数据库中的,都是v1版本对应的数据结构,反过来,当你从数据库读出来对应的数据之后,APIServer则首先会将其从v1版本转换成内部版本,然后再进行其他的处理。当然,也有可能存储的不是最稳定的版本,而是某个中间版本,比如v1, v1beta1, v1beta2,可能它存的是v1beta2版本的数据结构,即次稳定版本,这种情况,一般都处在升级迭代的过程中,保证应用的兼容性,经历几个版本迭代之后,还是最终切到v1版本的数据结构上去。

OK,我们还是以上面的FlowSchema为例,来看看它不同版本之间转换的一个过程,先来看看FlowSchema各个版本的数据结构定义:

v1beta2的数据结构:

# k8s.io/api/flowcontrol/v1beta2/types.go

type FlowSchema struct {
    metav1.TypeMeta `json:",inline"`
    // `metadata` is the standard object's metadata.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
    // +optional
    metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
    // `spec` is the specification of the desired behavior of a FlowSchema.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
    // +optional
    Spec FlowSchemaSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"`
    // `status` is the current status of a FlowSchema.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
    // +optional
    Status FlowSchemaStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`
}

// FlowSchemaSpec describes how the FlowSchema's specification looks like.
type FlowSchemaSpec struct {
    // `priorityLevelConfiguration` should reference a PriorityLevelConfiguration in the cluster. If the reference cannot
    // be resolved, the FlowSchema will be ignored and marked as invalid in its status.
    // Required.
    PriorityLevelConfiguration PriorityLevelConfigurationReference `json:"priorityLevelConfiguration" protobuf:"bytes,1,opt,name=priorityLevelConfiguration"`
    // `matchingPrecedence` is used to choose among the FlowSchemas that match a given request. The chosen
    // FlowSchema is among those with the numerically lowest (which we take to be logically highest)
    // MatchingPrecedence.  Each MatchingPrecedence value must be ranged in [1,10000].
    // Note that if the precedence is not specified, it will be set to 1000 as default.
    // +optional
    MatchingPrecedence int32 `json:"matchingPrecedence" protobuf:"varint,2,opt,name=matchingPrecedence"`
    // `distinguisherMethod` defines how to compute the flow distinguisher for requests that match this schema.
    // `nil` specifies that the distinguisher is disabled and thus will always be the empty string.
    // +optional
    DistinguisherMethod *FlowDistinguisherMethod `json:"distinguisherMethod,omitempty" protobuf:"bytes,3,opt,name=distinguisherMethod"`
    // `rules` describes which requests will match this flow schema. This FlowSchema matches a request if and only if
    // at least one member of rules matches the request.
    // if it is an empty slice, there will be no requests matching the FlowSchema.
    // +listType=atomic
    // +optional
    Rules []PolicyRulesWithSubjects `json:"rules,omitempty" protobuf:"bytes,4,rep,name=rules"`
}

v1beta3的数据结构:

# k8s.io/api/flowcontrol/v1beta3/types.go

type FlowSchema struct {
    metav1.TypeMeta `json:",inline"`
    // `metadata` is the standard object's metadata.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
    // +optional
    metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`
    // `spec` is the specification of the desired behavior of a FlowSchema.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
    // +optional
    Spec FlowSchemaSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"`
    // `status` is the current status of a FlowSchema.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
    // +optional
    Status FlowSchemaStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`
}

type FlowSchemaSpec struct {
    // `priorityLevelConfiguration` should reference a PriorityLevelConfiguration in the cluster. If the reference cannot
    // be resolved, the FlowSchema will be ignored and marked as invalid in its status.
    // Required.
    PriorityLevelConfiguration PriorityLevelConfigurationReference `json:"priorityLevelConfiguration" protobuf:"bytes,1,opt,name=priorityLevelConfiguration"`
    // `matchingPrecedence` is used to choose among the FlowSchemas that match a given request. The chosen
    // FlowSchema is among those with the numerically lowest (which we take to be logically highest)
    // MatchingPrecedence.  Each MatchingPrecedence value must be ranged in [1,10000].
    // Note that if the precedence is not specified, it will be set to 1000 as default.
    // +optional
    MatchingPrecedence int32 `json:"matchingPrecedence" protobuf:"varint,2,opt,name=matchingPrecedence"`
    // `distinguisherMethod` defines how to compute the flow distinguisher for requests that match this schema.
    // `nil` specifies that the distinguisher is disabled and thus will always be the empty string.
    // +optional
    DistinguisherMethod *FlowDistinguisherMethod `json:"distinguisherMethod,omitempty" protobuf:"bytes,3,opt,name=distinguisherMethod"`
    // `rules` describes which requests will match this flow schema. This FlowSchema matches a request if and only if
    // at least one member of rules matches the request.
    // if it is an empty slice, there will be no requests matching the FlowSchema.
    // +listType=atomic
    // +optional
    Rules []PolicyRulesWithSubjects `json:"rules,omitempty" protobuf:"bytes,4,rep,name=rules"`
}

内部结构

# kubernetes/pkg/apis/flowcontrol/types.go

type FlowSchema struct {
    metav1.TypeMeta
    // `metadata` is the standard object's metadata.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata
    // +optional
    metav1.ObjectMeta
    // `spec` is the specification of the desired behavior of a FlowSchema.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
    // +optional
    Spec FlowSchemaSpec
    // `status` is the current status of a FlowSchema.
    // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
    // +optional
    Status FlowSchemaStatus
}

type FlowSchemaSpec struct {
    // `priorityLevelConfiguration` should reference a PriorityLevelConfiguration in the cluster. If the reference cannot
    // be resolved, the FlowSchema will be ignored and marked as invalid in its status.
    // Required.
    PriorityLevelConfiguration PriorityLevelConfigurationReference
    // `matchingPrecedence` is used to choose among the FlowSchemas that match a given request. The chosen
    // FlowSchema is among those with the numerically lowest (which we take to be logically highest)
    // MatchingPrecedence.  Each MatchingPrecedence value must be ranged in [1,10000].
    // Note that if the precedence is not specified, it will be set to 1000 as default.
    // +optional
    MatchingPrecedence int32
    // `distinguisherMethod` defines how to compute the flow distinguisher for requests that match this schema.
    // `nil` specifies that the distinguisher is disabled and thus will always be the empty string.
    // +optional
    DistinguisherMethod *FlowDistinguisherMethod
    // `rules` describes which requests will match this flow schema. This FlowSchema matches a request if and only if
    // at least one member of rules matches the request.
    // if it is an empty slice, there will be no requests matching the FlowSchema.
    // +listType=set
    // +optional
    Rules []PolicyRulesWithSubjects
}

从上面的代码可以看到,v1beta2和v1beta3目前的数据结构是一模一样的,并且都位于k8s.io/api这个第三方库中,只是所在的目录不同而已,而内部版本的数据结构的字段跟他们也是一样的,只是没有带用来做序列化的tag,并且内部结构是位于kubernetes本身的代码目录树中的,并没有以第三方库的形式暴露出去。

OK,我们先来看下创建过程中的版本转换以及序列化过程,如下图:

HTTP请求到了APIServer,会由该资源API对应的Handler来处理,第一步就是根据HTTP请求的Content-Type Header中标记的数据类型,比如是json还是protobuf,来将字节类型的 req.body 反序列化为对应版本的数据结构的对象实例,第二步,又会将具体版本的对象转换成内部版本的对象,第三步,在存数据库的时候,又将内部版本的对象转换成了稳定版本的数据结构的对象实例,并且将其序列化为protobuf格式的数据,将其存到数据库中。在存数据库的时候,默认是使用protobuf格式,可以通过配置项 --storage-media-type 来更改存储的格式,支持 json/yaml/protobuf 三种格式。

再来看看读取的过程,如下图:

读的过程,其实正好跟写的过程相反,当请求某一个版本的API对象时,首先会从数据库中读出字节类型的数据,然后将其反序列化为内部数据结构的对象实例,然后再转换成对应的版本,然后再根据请求中的Accept Header来决定将其序列化为哪种数据格式,返回给客户端。

在创建时,因为要把创建结果返回给客户端,其实也走了一遍读的流程,跟单独去读过程是类似的,上面没有再画出来了。

总结

本篇文章介绍了API多版本功能及其实现原理,在实现原理中,最核心的就是引入了一个内部版本的概念,让各个版本跟内部版本之间进行转换,从而能够使用一份数据,去服务多个版本。

所以,从上面的原理分析可以看到,这个API多版本的重点,是如何在多个版本之间进行转换以及进行序列化,这就涉及到代码层面的分析了,也就是Scheme和Codec发挥作用的地方,有兴趣的可以继续看下Scheme和Codec的分析文章:

热门相关:黜龙   韩娱之影帝   都市剑说   星界游民   龙骑战机