xingpingz

xingpingz 查看完整档案

北京编辑北京航空航天大学  |  通信工程 编辑  |  填写所在公司/组织填写个人主网站
编辑

博学,审问,慎思,明辨,力行

个人动态

xingpingz 发布了文章 · 2018-03-30

k8s :kube-apiserver RESTful API 实现 - Storage

前言

了解 k8s 的同学都知道,kube-apiserver 对外提供 RESTful API 接口提供 查询,监听集群(资源)状态的服务,kube-apiserver 主要就做一件事,就是如何将 RESTful API (CREATE, DELETE, UPDATE, GET .etc)接口调用映射到对后端存储(比如 etcd)的(增删改查)访问,在设计的时候考虑到 k8s 是个快速迭代的开源项目,很多 API 接口(版本)可能在未来版本发生变化,因此如何设计一个扩展性强,耦合度低的架构应该是 Google 那帮货当初主要考虑的问题,所以才导致 kube-apiserver 本来相比 kube-scheduler 和 kube-controller-manager 应该简单的代码设计的巨复杂(个人观点)~

从 kube-apiserver 收到 RESTful API 请求到从 后端存储中获取(更新 .etc)到数据大概需要经过一下几层(非官方命名),各层之间通过 《接口》 交互(解偶)

RESTful API
||
<REST Operation Interface>
||
Storage
||
<Storage Backend Interface>
||
Sotrage Backend(etcd2,etcd3)

比如 Storage 和 Storage Backend 之间通过 Storage Backend Interface(参考k8s :kube-apiserver 访问 etcd 后端存储 )交互,Storage 和 RESTful API 之间通过 REST Operation Interface(增删改查 方法的封装)交互

Storage

Storage is a generic interface for RESTful storage services.
Resources which are exported to the RESTful API of apiserver need to implement this interface(原文注释,下同)
It is expected that objects may implement any of the below interfaces
所有想通过 RESTful API 暴露出去的资源都必须实现 Storage 接口,Storage 接口是个最小接口(单一职责),资源类可以根据自身情况实现其它各种接口

// kubernetes/staging/src/k8s.io/apiserver/pkg/registry/rest/rest.go
type Storage interface {
    New() runtime.Object
}

REST Operation Interface

StandardStorage is an interface covering the common verbs. Provided for testing whether a resource satisfies the normal storage methods.
Use Storage when passing opaque storage objects
StandardStorage

type StandardStorage interface {
    Getter
    Lister
    GreaterUpdater
    GracefulDeleter
    CollectionDeleter
    Watcher
}

StandardStorage 聚合了可以对 Storage 施加的操作(或者叫 Verb,动作),RESTful API根据该(子)接口测试 Storage 是否支持相关操作,然后注册相应的 API 接口,比如如果 Storage 支持 Delete 接口,就注册一个 HTTP method 为 DELETE 的方法到相应的资源路径

Storage 实现类

kubernetes/pkg/registry/core 目录下包含了各种 Storage 实现类,比如大家耳熟能详的 pod, service, endpoint, configmap, node 等等,各个资源的目录结构很相似,以 pod 为例

kubernetes/pkg/registry/core/pod
    rest
    storage
        storage.go <- Storage 实现
    doc.go
    strategy.go
    strategy_test.go

PodStorage

我们以 pod storage 为例来分析 storage 创建,首先是 pod storage 定义

type PodStorage struct {
    Pod *REST
    Binding *BindingREST
    Eviction *EvictionREST
    Status *StatusREST
    Log *podrest.LogREST
    Proxy *podrest.ProxyREST
    Exec *podrest.ExecREST
    Attach *podrest.AttachREST
    PortForward *podrest.PortForwardREST
}

这里又冒出一些新的类型 REST,BindingREST .etc,这些 XXXREST 才是"真正"的 Storage,对应具体的 RESTful endpoint

// REST implements a RESTStorage for pods
type REST struct {
    *genericregistry.Store
    proxyTransport http.RoundTripper
}

// BindingREST implements the REST endpoint for binding pods to nodes when etcd is in use
type BindingREST struct {
    store *genericregistry.Store
}

XXXREST 类类包含一个 genericregistry.Store 类型的字段,我们在k8s :kube-apiserver 访问 etcd 后端存储中分析过,它用于访问后端存储

PodStorage 通过 NewStorage 方法创建,各个 XXXREST 共享 Store

func NewStorage(optsGetter generic.RESTOptionsGetter, ...) {
    创建 genericregistry.Store
    store := &genericregistry.Store {
        ...
    }
    ...
    return PodStorage {
        Pod:      &REST{store, proxyTransport},
        Binding:  &BindingREST{store: store}
        ...
    }
}

Storage 注册

Storage 是如何"绑定"到 api 接口呢?这中间还涉及到一些数据结构(类),这里先列出绑定相关的代码:

// kubernetes/pkg/registry/core/rest/storage_core.go
func (c LegacyRESTStorageProvider) NewLegacyRESTStorage(...) {
    ...
    restStorageMap := map[string]rest.Storage {
        "pods": podStorage.Pod,
        "pods/attach": podStorage.Attach
        ...
    }
}

后续再详细分析

总结

本文介绍了 kube-apiserver 中 Storage 相关的一些概念,希望对大家阅读 k8s 源代码有所帮助

查看原文

赞 0 收藏 0 评论 1

xingpingz 发布了文章 · 2018-03-30

k8s :kube-apiserver 访问 etcd 后端存储

前言

本文介绍 kube-apiserver 是如何访问 etcd 后端存储

相关源代码主要在 kubernetes/staging/src/k8s.io/apiserver/pkg/storage

通用接口

Interface offers a common interface for object marshaling/unmarshaling operations and hides all the storage-related operations behind it(原文注释)

// kubernetes/vendor/k8s.io/apiserver/storage/interfaces.go
type Interface interface {
    Versioner() Versioner
    Create(...)
    Delete(...)
    Watch(...)
    WatchList(...)
    Get(...)
    GetToList(...)
    List(...)
    GuaranteedUpdate(...)
}

Interface 定义了后端存储的通用接口,主要是一些"增删改查"方法,这种面向接口编程,将实现和设计分离的设计提高了软件的可扩展性,降低了模块间的耦合度,比如只要我们提供 Interface 的具体实现,那么除了使用 etcd 作为后端存储之外,是不是也可以使用 consul/zookeeper 等分布式 kv 存储?

具体实现

Interface 接口目前有两个具体实现类,分别对应 etcd v2 和 etcd v3 两个不同的 etcd api 版本

// kubernetes/vendor/k8s.io/apiserver/pkg/storage/etcd/etcd_helper.go 
type etcdHelper struct {
    ...
}

// kubernetes/vender/k8s.io/apiserver/pkg/storage/etcd3/store.go
type store struct {
    ...
}

这两个实现类的名字差的也太大了。。。go 语言使用非侵入式的接口,所以从 类(结构)名上是看不出他们两和 storage.Interface 有半毛线关系的,但是只要它们实现了 storate.Interface 接口里面声明的方法他们就 "is-a" storage.Interface

创建

factory 包里的《工厂方法》Create 会根据配置 storagebackend.Config 创建相应的 storage.Interface

func Create(c storagebackend.Config) (storage.Interface, DestroyFunc, error) {
    swtich c.Type {
        case storagebackend.StorageTypeETCD2:
            return newETCD2Storage(c)
        case storagebackend.StorageTypeUnset, storageback.StorageTypeETCD3:
            return newETCD3Storage(c)
        default:
            return nil, nil, fmt.Errorf("unknown storage type: %s", c.Type)
    }
}

使用

storage.Interface 被 Store 类(这又是什么东东?)用来实现 rest.StandardStorage(RESTful 增删改查) 接口

// kubernetes/vendor/k8s.io/apiserver/pkg/registry/generic/registry/store.go
type Store struct {
    ...
    Storage storage.Interface
}

// Create inserts a new item according to the unique key from the object
func (e *Store) Create(ctx genericapirequest.Context, obj runtime.Object,
    includeUninitialized bool)(runtime.Object, error) {
    ...
    key, err := e.KeyFunc(ctx, name)
    ...
    out := e.NewFunc()
    if err := e.Storage.Create(ctx, key, obj, out, ttl); err != nil {
        ...
    }
    ...
}

总结

本介绍了 kube-apiserver 访问 etcd 后端存储相关的类(结构)和方法,用到的设计模式以及架构上的套路

查看原文

赞 0 收藏 0 评论 0

xingpingz 发布了文章 · 2018-03-27

Jetty : Embedded Server 启动流程 - 2

前言

本文接着 Jetty : Embedded Server 启动流程 - 1往下讲,上回说到 Server.start 调用 Connector.start 方法,开始接收和处理请求,Server 默认使用 Connector 的子类 SelectChannelConnector,所以我们重点来看看 SelectChannelConnector 的具体实现

类层次

AbstractLifeCycle
    AggregateLifeCycle
        AbstractConnector
            AbstractNIOConnector
                SelectChannelConnector

AbstractLifeCycle,AggregateLifeCycle 我们上文提到过,这里有两个新面孔:

  • AbstractConnector:Connector 抽象实现类
  • AbstractNIOConnector

AbstractConnector

上文讲过 Server.start 方法最终会调用 Connector.start 方法,AbstractConnector 使用《模板方法》模式在 doStart 方法中实现了 start 的基本流程

@Override
protected void doStart() throws Exception {
    if (_server == null) {
        throw new IllegalStateException("No server");
    }
    
    // 子类 override open 方法,打开 server socket
    open();

    // 如果没有指定 ThreadPool,默认使用 Server 的 ThreadPool
    if (_threadPool == null) {
        _threadPool = _server.getThreadPool();
        addBean(_threadPool, false);
    }
    ...
    synchronized (this) {
        _acceptorThreads = new Thread[getAcceptors()];
        for (int i = 0; i < _acceptorThreads.length; i++) {
            // 启动 acceptor 线程监听客户端连接
            if (!_threadPool.dispatch(new Acceptor(i))) {
                throw new IllegalStateException("!accepting");
            }
            ...
        }
    }
    ...
}

几个关键点:

  • 子类通过 override(覆盖)open 方法初始化 server socket
  • 通过 setAcceptors 方法可以设置 acceptor 线程数量
  • 如果没有特殊指定,acceptor 线程 和 请求处理线程在(Server)同一个线程池里头
  • Acceptor 类(实现了 Runnable 接口)实现了具体的 accept 方法

Acceptor

Acceptor 类是 AbstractConnector 类的内部类,它在 Run 方法里头调用 AbstractConnector.accept 方法接收客户端连接

private class Acceptor implements Runnable {
    public void run() {
        Thread current = Thread.currentThread();
        ...
        accept(_acceptor);
        ...
    }
}

accept 方法是个抽象方法,由 AbstractConnector 的子类提供具体实现

SelectChannelConnector

我们重点关注和启动流程相关的三个方法:open,doStart,accept 以及一个类 SelectorManager

open

上文提到 AbstractConnector 在 start 方法中调用 open 方法,子类 override open 方法打开 server socket

    public void open() throws IOException {
        synchronized(this) {
            if (_acceptChannel == null) {
                // Create a new server socket
                _acceptChannel = ServerSocketChannel.open();
                // Set to blocking mode,阻塞接收连接请求
                _acceptChannel.configureBlocking(true);

                // Bind the server socket to the local host and port
                _acceptChannel.socket().setReuseAddress(getReuseAddress());
                InetSocketAddress addr = getHost()==null ?
                    new InetSocketAddress(getPort()) :
                    new InetSocketAddress(getHost(), getPort());
                _acceptChannel.socket().bind(addr,getAcceptQueueSize());

                _localPort=_acceptChannel.socket().getLocalPort();
                if (_localPort<=0)
                    throw new IOException("Server channel not bound");

                addBean(_acceptChannel);
            }
        }
    }

accept

@Override
public void accept(int acceptorID) throws IOException {
    ServerSocketChannel server;
    synchronized(this) {
        server = _acceptChannel;
    }

    if (server!=null && server.isOpen() && _manager.isStarted()) {
        // 获取客户端 SocketChannel
        SocketChannel channel = server.accept();
        // 设置非阻塞模式(NIO)
        channel.configureBlocking(false);
        Socket socket = channel.socket();
        configure(socket);
        // 将 channel 注册到 SelectorManager(见下文)
        _manager.register(channel);
    }
}

通过 server.accept 获取到客户端 SocketChannel,并将它注册到 _manager(SelectorManager),这个 _manager 是啥?

SelectorManager

The Selector Manager manages and number of SelectSets to allow NIO Scheduling to scale to large numbers of connections
注意 SelectChannelConnector 在构造方法里将 _manager 作为 managed bean 添加到 bean registry 里,这样在 SelectChannelConnector 启动(start 方法被调用)的时候 SelectManager 也会跟着启动(参考上文)

public class SelectChannelConnector extends AbstractNIOConnector {
    private final SelectorManager _manager = new ConnectorSelectorManager();

    public SelectChannelConnector() {
        _manager.setMaxIdleTime(getMaxIdleTime());
        addBean(_manager, true);
        ...
    }
}

SelectManager doStart 方法,这里只保留方法主要逻辑

@Override
protected void doStart() throws Exception {
    _selectSet = new SelectSet[_selectSets];
    for (int i = 0; i < _select.length; i++) {
        _selectSet[i] = new SelectSet(i);
    }
    super.doStart();
    // start a thread to select
    for (int i = 0; i < getSelectSets(); i++) {
        final int id = i;
        // 提交 select runnable 到线程池
        boolean selecting = dispatch(new Runnalbe() {
            public void run() {
                ...
                LOG.debug("Starting {} on {}", Thread.currentThread(), this);
                while (isRunning()) {
                    try {
                        // 对注册的 channel 进行多路选择(select)
                        set.doSelect();
                    } catch (...) {
                        ...
                    }
                }
            }
        });
    }
}

doStart 方法启动 _selectSet 个线程监听 channel select 事件,我们回过头来看 SelectManager 的 register 方法

public void register(SocketChannel acceptChannel) {
    int s=_set++;
    if (s<0) {
        s=-s;
    }
    s=s%_selectSets;
    SelectSet set=_selectSet[s];
    set.addChange(acceptChannel);
    set.wakeup();
}

acceptChannel 被均匀分配(addChange)给 SelectSet

总结

到目前为止我们总结一下:

  • Server 启动 N 个 Accept 线程接收客户端连接
  • Server 启动 N 个 Select 线程对 SocketChannel 进行多路IO选择,每个线程执行 SelectSet 的 doSelect 方法
  • Server 接收到客户端连接后将 SocketChannel 注册到 SelectorManager
  • SelectorManager 将注册的 SocketChannel 均匀分配给各个 SelectSet,同时唤醒 Select 线程迎接新的客户端请求
查看原文

赞 0 收藏 0 评论 0

xingpingz 发布了文章 · 2018-03-24

k8s :kube-apiserver 启动流程 - 2

前言

文章字数一多,在线编辑不方便,本文是 k8s:kube-apiserver 启动流程的第2部分
传送门:k8s :kube-apiserver 启动流程 - 1

回顾

上回讲到 Run 方法:

// kubernetes/cmd/kube-apiserver/app.server.go
func Run(runOptions *options.ServerRunOptions, stopCh <-chan struct{}) error {
    ...
    server, err := CreateServerChain(runOptions, stopCh)
    if err != nil {
        return err
    }
    return server.PrepareRun().Run(stopCh)
}

目前系统中有以下 api server:

  • CustomResourceDefinitions
  • Master
  • APIAggregator

每个 api server 都对应一个 Config(配置)

  • apiextensionsapiserver.Config
  • master.Config
  • aggregatorapiserver.Config

CreateServerChain 的任务就是根据 ServerRunOptions 创建 XXXConfig,然后再用 XXXConfig 创建 api server,各个 api server 通过 GenericAPIServer 的 delegationTarget 字段组成《责任链》
以 Master api server 创建为例:

func CreateServerChain(
    runOptions *options.ServerRunOptions, stopCh <-chan struct{})
        (*genericapiserver.GenericAPIServer, error) {
    ...
    kubeAPIServerConfig, ... := CreateKubeAPIServerConfig(...)
    ...
    kubeAPIServer, err := CreateKubeAPIServer(kubeAPIServerConfig, 
        apiExtensionsServer.GenericAPIServer, sharedInformers, versionedInformers)
    ...
} 

下面将简要介绍 Master api server 的创建过程,主要分析 kube-apiserver 是如何将 资源对象(Node,Pod,Service 等)绑定到具体的 RESTful API,使得客户端可以通过 RESTful API 操作资源对象

如果是你会怎么做?

在大概看了一些源代码之后,我不禁问自己:如果是你来设计代码架构,你会怎么做?
例如给定一个实体 Student(Java 伪代码,下同),持久化在 etcd 里

public class Student {

    public int id;

    public String name;

    public String phone;
}

如何提供 RESTful api 接口提供对 Student 的 CRUD 操作? 设计代码框架使之适应所有的实体

api 接口示例:

PUT: /user?id=xxx&name=yyy&phone=zzz
DELETE: /user?id=xxx
POST: /user?id=xxx&name=yyy
GET: /user?id=xxx

我们分几步来考虑,首先考虑持久化,为了支持不同的持久化框架,或者即时我们就使用一种持久化框架也需要考虑框架版本匹配问题,这就需要将对持久化框架的基本操作进行抽象,抽取出接口 Backend

public interface Backend {

    String get(String key);

    void set(String key, String value);
}

然后我们有具体的实现类 EtcdBackend, ConsulBackend 以及 工厂类 BackendFactory

public class EtcdBackend implements Backend {

    public String get(String key) { ... }

    public void set(String key, String value) { ... }
}

public class ConsulBackend implements Backend {

    public String get(String key) { ... }

    public void set(String key, String value) { ... }
}

public class BackendFactory {

    Backend get(String name) { ... }
}

Backend 搞定了,现在我们需要一个 DAO(Data access object)来访问它

public class UserDao {

    private Backend backend;

    // CRUD 方法
    ...
}

我们注意到会有很多实体,他们都需要使用 Backend 接口访问后端存储,所以可以搞个基类 AbstractDao,将 backedn 字段移到基类里头

pubic class AbstractDao {

    private Backend backend;
}

public class User extends AbstractDao {

    // CRUD 方法
    ...
}

进一步观察,其实各个 DAO 的 CRUD 方法也有很多重复的(模版)代码,比如如果我们能够封装以下变化点:

  • 生成后端存储需要的key
  • 序列化和反序列化对象

DAO 中的 CRUD 方法可以进一步抽取到 AbstractDao 中,那些实在需要子类特例化的方法,可以通过《模版方法》模式来实现

public class AbstractDao {

    private Backend backend;

    // CRUD 方法
    ...
}

public class UserDao extends AbstractDao {

    // Template 方法
    ...
}

我们现在离最后的完工又近了一步,还剩一个问题,就是如何将 url 和 DAO 对应起来,这是一个映射问题,可以使用 map 来保持 url 对应的 DAO

map.put("/user", userDao)

以上只是一个简单的推导,k8s 的实现远比这个 demo 复杂的多,考虑到各种解耦和扩展性,下回将正式介绍 k8s 的实现

查看原文

赞 0 收藏 0 评论 0

xingpingz 发布了文章 · 2018-03-24

k8s :kube-apiserver 启动流程 - 1

前言

看 k8s 源代码有一段时间,总感觉在迷宫里乱窜,有时候觉得终于找到出口了,一下子又撞墙了,总结下来还是自己的内功不够深厚,本文是对 kube-apiserver Legacy(遗留,即将废弃)API 初始化流程(以及数据结构)的一个梳理,算是做个"路标",便于以后在"迷宫"中还能找到回来的路

kube-apiserver 主要功能是提供 api 接口给客户端访问 后端 etcd 存储,当然这中间不光是简单的 key/value 存储,为了方便扩展,kube-apiserver 设计了一套代码框架将 "资源对象" 映射到 RESTful API

本文梳理了一下 kube-apiserver 启动流程和相关数据结构

k8s 代码更新比较快,本文基于 k8s release-1.9.x 代码

数据结构

kube-apiserver 启动流程相关的(主要)数据结构

  • XXXOptions 命令行参数相关的类
  • XXXConfig 配置相关的类
  • XXXServer kube-apiserver 服务对象
  • XXXStorage 资源对象,比如 PosStorage,通过 XXXStorage 操作后端存储(etcd)

Options,Config 和 Server 对象的(大致)关系:
由 Options 对象创建(配置) Config 对象,然后由 Config 对象创建(配置) Server 对象

ServerRunOptions

ServerRunOptions 类封装了 kube-apiserver 命令行参数,这些参数按照不同的类别进行分组

// kubernetes/cmd/kube-apiserver/app/options/options.go
import (
    ...
    genericoptions "k8s.io/apiserver/pkg/server/options"
    ...
)

type ServerRunOptions struct {
    GenericServerRunOptions *genericoptions.ServerRunOptions
    Etcd                    *genericoptions.EtcdOptions
    SecureServing           *genericoptions.SecureServingOptions
    InsecureServing         *kubeoptions.InsecureServingOptions
    ...
}

我们重点看看 EtcdOptions,etcd 存储相关的配置,它和 kube-apiserver 如何访问后端 etcd 存储密切相关

EtcdOptions

StorageConfig 定义了 etcd 详细配置,比如 etcd 版本,key 公共前缀等

// kubernetes/vendor/k8s.io/apiserver/pkg/server/options/etcd.go
type EtcdOptions struct {
    StorageConfig storagebackend.Config
    EncryptionProviderConfigFilepath string
    ...
}

// kubernetes/vender/k8s.io/apiserver/storage/storagebackend/config.o
type Config struct {
    // Type defines the type of storage backend, e.g. "etcd2", "etcd3"
    Type string
    // Prefix is the prefix to all keys passed to storage.Interface methods.
    Prefix string
    // ServerList is the list of storage servers to connect with
    ...
}

创建

《工厂方法》NewEtcdOptions 用于创建 EtcdOptions

// kubernetes/cmd/kube-apiserver/app/options/options.go
func NewServerRunOptions() *ServerRunOptions {
    s := ServerRunOptions {
        ...
        Etcd: genericoptions.NewEtcdOptions(
            storagebackend.NewDefaultConfig(kubeoptions.DefaultEtcdPathPrefix, nil))
        ...
    }
}

ApplyWithStorageFactoryTo

EtcdOptions 有一个非常重要的方法 ApplyWithStorageFactoryTo 用于初始化 server.Config(服务配置类,见下文)的 RESTOptionsGetter 属性,该属性用于创建 etcd 后端存储(见下文)

// kubernetes/vendor/k8s.io/apiserver/pkg/server/options/etcd.go
func (s *EtcdOptions) ApplyWithStorageFactoryTo(
    factory server storage.StorageFactory, c *server.Config) error {
    s.RESTOptionsGetter = &storageFactoryRestOptionsFactory{
        Options: *s,
        StorageFactory: factory
    }
}

// kubernetes/pkg/kubeapiserver/options/storage_versions.go
const (
    DefaultEtcdPathPrefix = "/registry"
)

server.Config

Config is a structure used to configure a GenericAPIServer
server.Config 类用于配置和创建 GenericAPIServer(见下文)

// kubernetes/vender/k8s.io/apiserver/pkg/server/config.go
type Config struct {
    ...
    // 这个字段很重要,标记一下
    RESTOptionsGetter generic registry.RESTOptionsGetter
    ...
}

创建

《工厂方法》NewConfig 创建 server.Config 实例,该方法只是给 server.Config 的一些字段赋予一些默认值,《构建方法》BuildGenericConfig 对剩余的字段进行初始化

func BuildGenericConfig(s *options.ServerRunOptions, proxyTransport *http.Transport) (...) {
    //《工厂方法》
    genericConfig := genericapiserver.NewConfig(legacy scheme.Codecs)
    ...
    // 将 ServerRunOptions.SecureServing "应用" 到 server.Config
    if err := s.SecureServing.ApplyTo(genericConfig); err 1= nil {
        ...
    }
}

方法体中还有很多 ApplyTo 函数调用,基本都是将 ServerRunOptions 中的字段 "应用" 到 server.Config

GenericAPIServer

GenericAPIServer contains state for a Kubernetes cluster api server
GenericAPIServer 结构体包含 server "通用" 的状态(字段),一般做为字段内嵌在具体 server(Master, APIAggregator .etc)中,可以把 GenericAPIServer 看作一个基类

type GenericAPIServer struct {
    ...
}

Master

Master contains state for a Kubernetes cluster master/api server
Master 结构体包含 GenericAPIServer 的引用,下面将要介绍的几个其它的结构体也都具有类似的结构

type Master struct {
    GenericAPIServer *genericapiserver.GenericAPIServer
    ClientCARegistrationHook ClientCARegistrationHook
}

APIAggregator

APIAggregator contains state for a Kubernetes cluster master/api server

type APIAggregator struct {
    GenericAPIServer *genericapiserver.GenericAPIServer
    ...
}

kube-apiserver 启动流程

kube-apiserver 入口在 kubernetes/cmd/kube-apiserver/apiserver.go

PS:通过以下命令可以(单独)构建 kube-apiserver

# export GOPATH=/opt/kubernetes-src/
# cd $GOPATH/src/k8s.io/kubernetes
# make WHAT=cmd/kube-apiserver all
// kubernetes/cmd/kube-apiserver/apiserver.go
import(
    ...
    "k8s.io.kubernetes/cmd/kube-apiserver/app"
    ...
)

func main() {
    rand.Seed(time.Now().UTC().UnixNano())

    // 解析命令行参数
    s := options.NewServerRunOptions()
    s.AddFlags(pflag.CommandLine)
    flag.InitFlags()

    // 初始化 log
    logs.InitLogs()
    defer logs.FlushLogs()

    ...

    stopCh := server.SetupSignalHandler()
    if err := app.Run(s, stopCh); err != nil {
        fmt.FPrintf(os.Stderr, "%v\n", err)
        os.Exit(1)
    }
}

main 函数只是个壳子,解析完命令行参数后会调用 app 包中的 Run 函数

// kubernetes/cmd/kube-apiserver/app.server.go
func Run(runOptions *options.ServerRunOptions, stopCh <-chan struct{}) error {
    ...
    server, err := CreateServerChain(runOptions, stopCh)
    if err != nil {
        return err
    }
    return server.PrepareRun().Run(stopCh)
}

CreateServerChain 函数使用了《责任链》模式,多个 server 组成一个链条,紧密相连,给客户端提供 RESTful API 服务

查看原文

赞 0 收藏 0 评论 0

xingpingz 发布了文章 · 2018-03-21

Jetty : Embedded Server 启动流程 - 1

前言

本文基于 Jetty 8.1.x 版本简单介绍 Jetty Embedded Server 核心概念,线程模型,启动流程。以下代码片段摘自 Jetty 源代码 中的 example-jetty-embedded 模块的 OneServletContext.java

public class OneServletContext {
    public static void main(String[] args) throws Exception {
        Server server = new Server(8080);

        ServletContextHandler context = new ServletContextHandler(
        ServletContextHandler.SESSIONS);
        context.setContextPath("/");
        server.setHandler(context);

        ...
        
        // Serve some hello world servlets
        context.addServlet(new ServletHolder(new HelloServlet()),"/*");
        context.addServlet(new ServletHolder(
                new HelloServlet("Buongiorno Mondo")),"/it/*");
        context.addServlet(new ServletHolder(
                new HelloServlet("Bonjour le Monde")),"/fr/*");

        server.start();
        server.join();
    }
}

Embedded Jetty 使用起来很方便,少数几行代码就可以实现一个 http (Servlet)接口

  • 创建 Server 对象,设置要监听的端口 8080
  • 创建 ServletContextHandler 对象,ServletContextHandler "is-a" Handler(Request Handler),提供 ServletContext
  • 将 ServletContextHandler 关联到 Server(即 server.setHandler(context))
  • 向 ServletContextHandler 中添加 Servlet,以 ServletHolder 的形式
  • 启动 Server

核心概念

LifeCycle

The lifecycle(生命周期) interface for generic components,声明 start(启动),stop(停止),isRunning,isStarted(查询状态)等方法

AbstractLifeCycle

实现 LifeCycle 接口的抽象类,提供生命周期管理默认实现:例如通过 _state 属性保存组件状态,提供 start,stop 默认实现

public final void start() throws Exception {
    // 线程安全
    synchronized (_lock) {
        try {
            // 状态保护
            if (_state == _STARTED || _state == _STARTING) {
                return;
            }
            setStarting();
            // 将具体的 start 逻辑推迟到具体的子类实现
            doStart();
            setStarted();
        } catch (...) {
            ...
        }
    }
}

AggregateLifeCycle

An AggregateLifeCycle is an LifeCycle implementation for a collection of contained beans
AggregateLifeCycle 继承自 AbstractLifeCycle,管理一组 Bean 的生命周期

public class AggregateLifeCycle extends AbstractLifeCycle implements
    Destroyable, Dumpable {
    private final List<Bean> _beans = new CopyOnWriteArrayList<>();

    private boolean _started = false;

    @Override
    protected void doStart() throws Exception {
        for (Bean b: beans) {
            if (b._managed && b._bean instanceof LifeCycle) {
                LifeCycle l = (LefeCycle) b._bean;
                if (!l.isRunning()) {
                    // start managed bean
                    l.start();
                }
            }
        }
        _started =true;
        super.doStart();
    }
}

Server

类层次

AbstractLifeCycle
    AggregateLifeCycle
        AbstractHandler
            AbstractHandlerContainer
                HandlerWrapper
                    Server

Server 类是 Jetty 的《门面》类,包含:

  • Connector,接收客户端请求
  • Handler,处理客户端请求
  • ThreadPool,任务调度线程池

通过 Server 类提供的各种 set 属性方法可以定制 Connector, ThreadPool,如果没有特殊指定 Server 类会创建默认实现,比如默认的 Connector 为 SelectChannelConnector,默认的 ThreadPool 为 QueuedThreadPool

Connector

(主要)类层次:

Connector
    AbstractConnector
        AbstractNIOConnector
            SelectChannelConnector

Connector 接口及其实现类用于 接收客户端请求,(HTTP/SPDY)协议解析,最终调用 Server 中的(Request)Handler 处理请求,SelectChannelConnector 是 Server 默认使用的 Connector

public Server(int port) {
    setServer(this);

    Connector connector = new SelectChannelConnector();
    connector.setPort(port);
    setConnectors(new Connector[]{ connector });
}

Handler

Handler 或者叫作 Request Handler,用于处理客户端请求

public interface Handler extends LifeCycle, Destroyable {
    ...
    void handle(String target, Request baseRequest, HttpServletRequest request,
            HttpServletResponse response);
    ...
}

handle 方法的签名已经很接近 Servlet service 方法,这里的 target 通常是请求的 uri,用于根据 Servlet 配置规则找到具体的 Servlet 并调用其 service 方法

线程模型

Jetty Server 使用线程池来处理客户端请求,ThreadPool 接口定义了线程池基本操作,Server 默认使用 QueuedThreadPool 类,如果需要,可以通过 Server.setThreadPool 方法手动设置线程池
注:Jetty 9.x 线程模型比较复杂,Jetty 8.x 比较简单,代码好读一些~

QueuedThreadPool

QueuedThreadPool 类层次结构

ThreadPool
    SizedThreadPool
        QueuedThreadPool

线程池核心问题:

  • 线程管理:包括创建,销毁等
  • 任务分派:推或拉模型

线程管理

通过最小线程个数,最大线程个数,线程最大空闲时间 来动态创建,销毁线程

线程池初始化

// QueuedThreadPool.java
public class QueuedThreadPool {
    // 默认最大线程数
    private int _maxThreads = 254;
    // 默认最小线程数
    private int _minThreads = 8;
    
    ...

    @Override
    protected void doStart() throws Exception {
        ...
        int threads = _threadsStarted.get();
        // 启动 _minThreads 个线程
        while (isRunning() && threads < _minThreads) {
            startThread(threads);
            threads = _threadStarted.get();
        }
    }
}

线程销毁

当线程空闲时间超过设定的阈值 _maxIdleTimeMs 而且线程池的线程个数高于 _minThreads 时,线程将从 Runnable run 方法中退出

# QueuedThreadPool.java
private Runnable _runnable = new Runnable() {
    public void run() {
        boolean shrink = false;
        ...
        try {
            Runnable job = _jobs.poll()
            while (isRunning()) {
                // job loop:从队列中拉取 job 并执行直到队列为空
                while (job != null && isRunning()) {
                    runJob(job);
                    job = _jobs.poll();
                }

                // idle loop
                try {
                    _threadsIdle.incrementAndGet();
                    while (isRunning() && job == null) {
                        // 如果 _maxIdleTimeMs < 0 就进入阻塞模式,直到成功拉取到 job
                        if (_maxIdleTimeMs <= 0) {
                            job = _jobs.take();
                        } else {
                            final int size = _threadsStarted.get();
                            // 如果线程个数少于 _minThreads 那么不需要回收线程
                            if (size > _minThreads) {
                                long last = _lastShrink.get();
                                long now = System.currentTimeMillis();
                                if (last == 0 || (now - last) > _maxIdleTimeMs) {
                                    shrink = _lastShrink.compareAndSet(last, now) &&
                                        _threadsStarted.compareAndSet(size, size - 1);
                                    if (shringk) {
                                        // 退出 while 循环,即终止线程
                                        return;
                                    }
                                }
                            }
                            // 带 timeout 的 poll
                            job = idleJobPoll();
                        }
                    }
                }
            }
        } catch (...) {
        } finally {
        }
    }
}

任务分派

使用阻塞队列,工作线程从阻塞队列中主动拉取(poll)任务(job),QueuedThreadPool 默认使用 ArrayBlockingQueue,如果需要可以通过构造方法手动指定阻塞队列的具体实现

public class QueuedThreadPool {
    public QueuedThreadPool(BlockingQueue<Runnable> jobQ) {
        this();
        _jobs = jobQ;
        _jobs.clear();
    }

    public boolean dispatch(Runnable job) {
        if (isRunning()) {
            final int jobQ = _jobs.size();
            final int idle = getIdleThreads();
            // 将 job 放入队列中
            if (_jobs.offer(job)) {
                // 如果当前没有 idle 的线程,或者 队列中堆积的 job 比 idle 线程多那么尝试启动新的线程
                if (idle == 0 || jobQ > idle) {
                    int threads = _threadsStarted.get();
                    if (threads < _maxThreads) {
                        startThread(threads);
                    }
                }
                return true;
            }
        }
        return false;
    }
}

启动流程

Server 的启动开始于 start 方法的调用,如上文所述,Server 类继承自 AggregateServer,父类的 start 方法最终调用 Server 的 doStart 方法

Server start

@Override
protected void doStart() throws Exception {
    ...
    // 创建默认的 ThreadPool
    if (_threadPool == null) {
        setThreadPool(new QueuedThreadPool())
    }
    try {
        super.doStart();
    } catch(Throwable e) {...}
    // 启动 Connector,接收,处理请求
    if (_connectors != null && mex.size == 0) {
        for (int i = 0; i < _connectors.length; i++) {
            try {
                _connectors[i].start();
            } catch (Throwable e) {
                mex.add(e);
            }
        }
    }
    ...
}
查看原文

赞 0 收藏 0 评论 0

xingpingz 发布了文章 · 2018-03-17

k8s :构建系统

前言

大型软件(linux,android .etc)一般都有自己的构建系统,k8s 也不例外,本文简要介绍 k8s 构建系统

构建流程

release

以 quick-release 为例,在命令行执行以下命令

# make quick-release

make 在源代码根目录 Makefile 文件中定位到 quick-release 目标,该目标的动作是执行 build/release.sh 脚本

# kubernetes/Makefile
.PHONY: release-skip-tests quick-release
ifeq ($(PRINT_HELP),y)
release-skip-tests quick-release:
    @echo "$$RELEASE_SKIP_TESTS_HELP_INFO"
else
release-skip-tests quick-release: KUBE_RELEASE_RUN_TESTS = n
release-skip-tests quick-release: KUBE_FASTBUILD = true
release-skip-tests quick-release:
    build/release.sh <--- 执行 kubernetes/build/release.sh
endif

release.sh 将构建过程拆分成一个个步骤,每个步骤对应一个 shell function

# kubernetes/build/release.sh
...
kube::build::verify_prereqs
kube::build::build_image
kube::build::run_build_command make cross
...
kube::build::copy_output
kube::release::package_tarballs

verify_prereqs 对构建环境进行检查,比如是否缺少一些工具软件
build_image 创建构建需要的 docker 镜像 ???
run_build_command make cross 启动容器,运行 make cross
copy_output, package_tar 处理构建生成的各个文件

这里比较有意思的是 k8s 使用 docker 容器进行构建,可能是为了交叉编译吧

构建镜像

kube::build::build_image 方法构建基础镜像,同步 kubernetes 源代码到 data container(数据卷容器)


function kube::build::build_image() {
  mkdir -p "${LOCAL_OUTPUT_BUILD_CONTEXT}"
  # Make sure the context directory owned by the right user for syncing sources to container.
  chown -R ${USER_ID}:${GROUP_ID} "${LOCAL_OUTPUT_BUILD_CONTEXT}"

  cp /etc/localtime "${LOCAL_OUTPUT_BUILD_CONTEXT}/"
  # 准备镜像构建所需文件
  cp build/build-image/Dockerfile "${LOCAL_OUTPUT_BUILD_CONTEXT}/Dockerfile"
  cp build/build-image/rsyncd.sh "${LOCAL_OUTPUT_BUILD_CONTEXT}/"
  dd if=/dev/urandom bs=512 count=1 2>/dev/null | LC_ALL=C tr -dc 'A-Za-z0-9' | dd bs=32 count=1 2>/dev/null > "${LOCAL_OUTPUT_BUILD_CONTEXT}/rsyncd.password"
  chmod go= "${LOCAL_OUTPUT_BUILD_CONTEXT}/rsyncd.password"

  kube::build::update_dockerfile
  kube::build::set_proxy
  # 构建镜像
  kube::build::docker_build "${KUBE_BUILD_IMAGE}" "${LOCAL_OUTPUT_BUILD_CONTEXT}" 'false'
  ...
  # 构建数据卷镜像,注意 ensure 这个词~数据卷镜像是可以复用的
  kube::build::ensure_data_container
  # 同步 kubernetes 源代码到数据卷镜像
  kube::build::sync_to_container
}

k8s 构建过程中使用了以下几种容器:

  • 数据卷容器:存储 k8s 源代码,其它容器启动时通过 --volume-from 共享数据卷
  • rsyncd 容器:运行 rsyncd 服务(一种文件同步服务),将 k8s 源代码从 host 同步到数据卷容器中
  • 构建容器:运行构建命令

源代码同步

上文说到 k8s 构建的时候会启动一个容器运行 rsyncd 服务,将 k8s 源代码同步到数据卷容器,那么源代码码会被同步到哪里呢?

# kubernete/build/common.sh
function kube::build::sync_to_container() {
  kube::log::status "Syncing sources to container"

  kube::build::start_rsyncd_container

  kube::build::rsync \
    --delete \
    --filter='H /.git' \
    --filter='- /.make/' \
    --filter='- /_tmp/' \
    --filter='- /_output/' \
    --filter='- /' \
    --filter='H zz_generated.*' \
    --filter='H generated.proto' \
    "${KUBE_ROOT}/" "rsync://k8s@${KUBE_RSYNC_ADDR}/k8s/"
}

kube::build::rsync 方法将 KUBE_ROOT 目录下的源代码同步到 k8s@${KUBE_RSYNC_ADDR}/k8s/

查看 rsync 配置文件可以知道 k8s 这个虚拟目录对应的实际目录

# kubernetes/build/build-image/rsyncd.sh
...
VOLUME=${HOME}

cat <<EOF >"${CONFFILE}"
pid file = ${PIDFILE}
use chroot = no
log file = /dev/stdout
reverse lookup = no
munge symlinks = no
port = 8730
[k8s]
  numeric ids = true
  $USER_CONFIG
  hosts deny = *
  hosts allow = ${ALLOW} ${ALLOW_HOST-}
  auth users = k8s
  secrets file = ${SECRETS}
  read only = false
  path = ${VOLUME} <-- k8s 对应的路径 ${VOLUME} = ${HOME}
  filter = - /.make/ - /_tmp/
EOF

这个 HOME 变量一般指向 用户主目录,但是从 go语言工程目录结构 来看 HOME 应该指向类似 $GOPATH/src/k8s.io/kubernetes 的目录,所以经验和直觉告诉我们肯定有什么地方设置了 HOME 变量,通过搜索源代码,证实确实如此

# kubernetes/build/build-image/Dockerfile
...
ENV HOME /go/src/k8s.io/kubernetes
WORKDIR ${HOME}
...

总结

通过分析 k8s 构建系统,可以学习像 Google 这样的大厂是如何规划大型软件工程结构,构建,发布

查看原文

赞 0 收藏 1 评论 0

xingpingz 发布了文章 · 2018-03-15

k8s :部署

前言

k8s 集群部署是个老生常谈的问题,网上资料一大把,各路大神祭出了各种方案:

  • 使用 linux 发行版提供的包管理器(比如 centos yum)
  • 使用 第三方工具包(脚本)
  • 使用 第三方二次开发包

本文介绍在 浏览 k8s 源代码过程中发现的 k8s 的一种半手工的部署方法

源头

浏览源代码的过程中发现有这么个目录

# /opt/kubernetes-src/src/k8s.io/cluster/centos
drwxrwxr-x  4 xingpingz xingpingz  4096 12月  4 13:31 ./
drwxrwxr-x 21 xingpingz xingpingz  4096 12月  4 13:31 ../
-rwxrwxr-x  1 xingpingz xingpingz  4305 12月  4 11:37 build.sh*
-rwxrwxr-x  1 xingpingz xingpingz  1763 12月  4 11:40 config-build.sh*
-rwxrwxr-x  1 xingpingz xingpingz  4899 12月  4 13:31 config-default.sh*
-rw-rw-r--  1 xingpingz xingpingz   747 12月  4 11:37 config-test.sh
-rwxrwxr-x  1 xingpingz xingpingz  2739 12月  4 11:40 deployAddons.sh*
-rw-rw-r--  1 xingpingz xingpingz   175 12月  4 11:37 .gitignore
drwxrwxr-x  3 xingpingz xingpingz  4096 12月  4 11:37 master/
drwxrwxr-x  4 xingpingz xingpingz  4096 12月  4 11:37 node/
-rwxrwxr-x  1 xingpingz xingpingz 12319 12月  4 11:37 util.sh*

一看就知道这些 shell 脚本应该是用于在 centos 中自动下载,安装 k8s 的

查看一下配置文件 config-build.sh,这里可以配置要下载的 k8s 各个组件的版本

RELEASES_DIR=${RELEASES_DIR:-/tmp/downloads}

# Define docker version to use.
DOCKER_VERSION=${DOCKER_VERSION:-"1.12.1"}

# Define flannel version to use.
FLANNEL_VERSION=${FLANNEL_VERSION:-"0.6.1"}

# Define etcd version to use.
ETCD_VERSION=${ETCD_VERSION:-"3.0.9"}

# Define k8s version to use.
K8S_VERSION=${K8S_VERSION:-"1.3.7"}

DOCKER_DOWNLOAD_URL=\
"https://get.docker.com/builds/Linux/x86_64/docker-${DOCKER_VERSION}.tgz"

FLANNEL_DOWNLOAD_URL=\
"https://github.com/coreos/flannel/releases/download/v${FLANNEL_VERSION}/flannel-v${FLANNEL_VERSION}-linux-amd64.tar.gz"

ETCD_DOWNLOAD_URL=\
"https://github.com/coreos/etcd/releases/download/v${ETCD_VERSION}/etcd-v${ETCD_VERSION}-linux-amd64.tar.gz"

# TODO(#33726): switch to dl.k8s.io
K8S_CLIENT_DOWNLOAD_URL=\
"https://storage.googleapis.com/kubernetes-release/release/v${K8S_VERSION}/kubernetes-client-linux-amd64.tar.gz"
K8S_SERVER_DOWNLOAD_URL=\
"https://storage.googleapis.com/kubernetes-release/release/v${K8S_VERSION}/kubernetes-server-linux-amd64.tar.gz"

从 github 上下载的组件还好说,但是从 storage.googleapis.com 拉东西下来估计够呛

总结

很多时候答案就隐藏在源代码中,与其满世界搜索,不如静下心来在 源码 或 文档中到答案~

查看原文

赞 0 收藏 0 评论 0

xingpingz 发布了文章 · 2018-03-15

k8s :源代码导入

前言

最近在调研 Google kubernetes 开源的容器编排平台,刚好也在学习 Go 语言,所以想看看 Google 这样的大厂是怎么撸 Go 语言的,本文简单介绍如何下载 k8s 源代码,导入 Idea GoLand(对,我是搞 Java的~),对于这么庞大的项目,没有 IDE 看起来还是很费劲的,当然牛人除外

下载源代码

这个不用说了,地球村的人应该都知道

# mkdir -p /opt/kubernetes-src/src/k8s.io
# cd /opt/kubernetes-src/src/k8s.io/
# git clone https://github.com/kubernetes/kubernetes.git
# git checkout release-1.9

因为等下在 GoLand 里面会配置 Project GOPATH(每个工程私有的 GOPATH)为 /opt/kubernetes-src,所以在 /opt/kubernetes-src 下建了 src/k8s.io 目录,至于为什么目录名叫 k8s.io,这个翻翻代码中的 import 就明白了,如果你不想导入代码后出现各种找不到导入包(符号)的化^_-

导入 GoLand

在 GoLand 之前一直用的 Intellij + Go 插件,GoLand 出了之后立马下载下来体验,感觉还是不错的

  • 打开 GoLand
  • 选择 New Project
  • 将目标文件夹指向 /opt/kubernetes-src
  • 确认之后会提示文件夹不为空,是否继续,点击确定就行

慢慢等待 IDE 完成对源代码的索引

总结

本文介绍了如何将 k8s 源码导入到 GoLand,为后续深入学习源码做好准备

查看原文

赞 0 收藏 0 评论 1

xingpingz 发布了文章 · 2018-03-15

k8s :部署

前言

k8s 集群部署是个老生常谈的问题,网上资料一大把,各路大神祭出了各种方案:

  • 使用 linux 发行版提供的包管理器(比如 centos yum)
  • 使用 第三方工具包(脚本)
  • 使用 第三方二次开发包

本文介绍在 浏览 k8s 源代码过程中发现的 k8s 的一种半手工的部署方法

源头

浏览源代码的过程中发现有这么个目录

# /opt/kubernetes-src/src/k8s.io/cluster/centos
drwxrwxr-x  4 xingpingz xingpingz  4096 12月  4 13:31 ./
drwxrwxr-x 21 xingpingz xingpingz  4096 12月  4 13:31 ../
-rwxrwxr-x  1 xingpingz xingpingz  4305 12月  4 11:37 build.sh*
-rwxrwxr-x  1 xingpingz xingpingz  1763 12月  4 11:40 config-build.sh*
-rwxrwxr-x  1 xingpingz xingpingz  4899 12月  4 13:31 config-default.sh*
-rw-rw-r--  1 xingpingz xingpingz   747 12月  4 11:37 config-test.sh
-rwxrwxr-x  1 xingpingz xingpingz  2739 12月  4 11:40 deployAddons.sh*
-rw-rw-r--  1 xingpingz xingpingz   175 12月  4 11:37 .gitignore
drwxrwxr-x  3 xingpingz xingpingz  4096 12月  4 11:37 master/
drwxrwxr-x  4 xingpingz xingpingz  4096 12月  4 11:37 node/
-rwxrwxr-x  1 xingpingz xingpingz 12319 12月  4 11:37 util.sh*

一看就知道这些 shell 脚本应该是用于在 centos 中自动下载,安装 k8s 的

查看一下配置文件 config-build.sh,这里可以配置要下载的 k8s 各个组件的版本

RELEASES_DIR=${RELEASES_DIR:-/tmp/downloads}

# Define docker version to use.
DOCKER_VERSION=${DOCKER_VERSION:-"1.12.1"}

# Define flannel version to use.
FLANNEL_VERSION=${FLANNEL_VERSION:-"0.6.1"}

# Define etcd version to use.
ETCD_VERSION=${ETCD_VERSION:-"3.0.9"}

# Define k8s version to use.
K8S_VERSION=${K8S_VERSION:-"1.3.7"}

DOCKER_DOWNLOAD_URL=\
"https://get.docker.com/builds/Linux/x86_64/docker-${DOCKER_VERSION}.tgz"

FLANNEL_DOWNLOAD_URL=\
"https://github.com/coreos/flannel/releases/download/v${FLANNEL_VERSION}/flannel-v${FLANNEL_VERSION}-linux-amd64.tar.gz"

ETCD_DOWNLOAD_URL=\
"https://github.com/coreos/etcd/releases/download/v${ETCD_VERSION}/etcd-v${ETCD_VERSION}-linux-amd64.tar.gz"

# TODO(#33726): switch to dl.k8s.io
K8S_CLIENT_DOWNLOAD_URL=\
"https://storage.googleapis.com/kubernetes-release/release/v${K8S_VERSION}/kubernetes-client-linux-amd64.tar.gz"
K8S_SERVER_DOWNLOAD_URL=\
"https://storage.googleapis.com/kubernetes-release/release/v${K8S_VERSION}/kubernetes-server-linux-amd64.tar.gz"

从 github 上下载的组件还好说,但是从 storage.googleapis.com 拉东西下来估计够呛

总结

很多时候答案就隐藏在源代码中,与其满世界搜索,不如静下心来在 源码 或 文档中到答案~

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 34 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2015-07-25
个人主页被 1.8k 人浏览