求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Modeler   Code  
会员   
 
  
 
 
     
   
分享到
Cloud Foundry中gorouter源码分析
 
作者 @莲子弗如清,火龙果软件    发布于 2013-10-25
 

在Cloud Foundry v1版本中,router作为路由节点,转发所有进入Cloud Foundry的请求。由于开发语言为ruby,故router接受并处理并发请求的能力受到语言层的限制。虽然在v1版本中,router曾经有过一定的优化,采用lua脚本代替原先的ruby脚本,由lua来分析请求,使得一部分请求不再经过ruby代码,而直接去DEA访问应用,但是,一旦router暴露在大量的访问请求下,性能依旧是不尽如人意。

为了提高Cloud Foundry router的可用性,Cloud Foundry开源社区不久前推出了gorouter。gorouter采用现阶段比较新颖的go作为编程语言,并重新设计了原有的组件架构。由于go语言本身的特性,gorouter处理并发请求的能力大大超过了router,甚至在同种实验环境下,性能是原先router的20倍左右。

由于gorouter的高性能,笔者也抱着期待的心态去接触go,当然还有gorouter。本文不会从go语言语法的角度入手gorouter,所以有一些go语言的基础再来看本文,是有必要的。本文主要是对gorouter的源码的简单解读,另外还包含一些笔者对gorouter的看法。

gorouter的程序组织形式

首先,先从gorouter的程序组织形式入手,可见下图:

以下简单介绍其中一些重要文件的功能:

common:common意指通用,所以该文件夹中也是一些比较通识的概念定义,比如varz,healthz,component等,以及关于项目过程的一些基本操作定义。

config:顾名思义,该文件夹中的文件为gorouter组件的配置文件。

log:定义gorouter的log形式定义。

proxy:作为一个代理处理外界进入Cloud Foundry的所有请求。

registry:处理组件或者DEA中应用到gorouter来注册uri的事件,另外还负责请求访问应用时查找应用真实IP,port。

route:主要定义在rigistry中需要使用到的三个数据结构:endpoint,pool和uris。

router:程序的主入口,main函数所在处。

stats:主要负责一些应用记录的状态,还有一些其他零碎的东西,比如定义一个堆。

util:其中一般是工具源码,在这里只负责给gorouter进程写pid这件事。

varz:主要涉及varz信息的处理,其实就是gorouter组件状态的查阅。

router.go: 主要定义了router的数据结构,及其实例初始化的过程,还有最终运行的流程。

gorouter的功能

gorouter的功能主要可以分为三个部分:负责接收Cloud Foundry内部组件及应用uri注册以及注销的请求,负责转发所有外部对Cloud Foundry的访问请求,负责提供gorouter作为一个组件的状态监控。

接受uri注册及注销请求

当Cloud Foundry内一个组件需要提供HTTP服务的时候,那么这个组件则必须将自己的uri和IP一起注册到gorouter处,典型的有,Cloud Foundry中Service Gateway与Cloud Controller通过HTTP建立连接的,另外Cloud Controller也需要对外提供HTTP服务,所以这些组件必须在gorouter中进行注册,以便可以顺利通信或访问。

除了平台级的组件uri注册,最常见的是应用级的应用uri注册,也就是在Cloud Foundry中新部署应用时,应用所在的DEA会向gorouter发送一个uri,IP和port的注册请求。gorouter收到这个请求后,会添加该记录,并保证可以解析外部的URL访问形式。当然,反过来,当一个应用被删除的时候,为了不浪费Cloud Foundry内部的uri资源,Cloud Foundry会将该uri从gorouter中注销,随即gorouter在节点处删除这条记录。

转发对Cloud Foundry的访问请求

gorouter接受到的访问请求大致可以分为三种:外部请求有:用户对应用的访问请求,用户对Cloud Foundry内部资源的管理请求;内部的请求有:内部组件之间通过HTTP的各类通信。

虽然说请求的类型可以分为三种,但是gorouter对于这些请求的操作都是一致的,找到相应的uri,提取出相应的IP和port,然后进行转发。需要注意的是,在原先版本的router中,router只能接收HTTP请求,然而现在gorouter中,已经考虑了TCP连接,以及websocket。

提供组件监控

Cloud Foundry都有自己的状态监控,可以通过HTTP访问。这主要是每个组件在启动的时候,都作为一个component向Cloud Foundry进行注册,注册的时候带有很多关于自身组件的信息,同时也启动了一个HTTP server。

gorouter的初始化及启动流程

Router对象实例的创建与初始化

gorouter的启动过程主要在router.go文件中,在该文件中,首先定义创建一个Router实例的操作并进行初始化,另外还定义了Router实例的开始运行所做的操作。

在router.go文件中,首先需要是Router结构体的定义:

type Router struct {
	config     *config.Config
	……
}
        随后又定义了Router实例的初始化:
func NewRouter(c *config.Config) *Router {
	router := &Router{
		config: c,
	}
        ……
	return router
}

还有就是定义了Router实例开始运行时所做的操作:

func (router *Router) Run() {
        ……
}

查看源码可以发现Router结构体有以下几个属性:

config:负责传入gorouter所需要的配置信息

proxy:一个代理对象,负责完成请求的转发

mbusClient:作为gorouter中的nats_client,负责与Cloud Foundry的消息中间件NATS通信

registry:作为gorouter中的注册模块,完成Cloud Foundry中注册或注销请求的处理

varz:处理gorouter自身作为一个组件的状态监控

component:gorouter作为一个组件的信息,将自身的信息存入该component对象

在初始化Router对象实例的时候,都是通过传入的config文件中的配置文件来完成初始化。首先通过创建一个Router实例,并初始化该实例的配置信息:

router := &Router{
		config: c,
	}

然后通过读取该配置属性的信息逐步完成Router实例其他属性的初始化。

创建完Router实例对象router之后,router首先做的是创建一个用来与Cloud Foundry中与nats_server建立联机的nats_client: mbusClient:

router.establishMBus()

然后分别初始化了router对象的registry,varz,proxy:

router.registry = registry.NewRegistry(router.config, router.mbusClient)
	router.registry.StartPruningCycle()

	router.varz = varz.NewVarz(router.registry)
	router.proxy = proxy.NewProxy(router.config, router.registry, router.varz)

接着较为重要的是:router.component的创建和执行启动操作:

router.component = &vcap.VcapComponent{
		Type:        "Router",
		Index:       router.config.Index,
		Host:        host,
		Credentials: []string{router.config.Status.User, router.config.Status.Pass},
		Config:      router.config,
		Varz:        varz,
		Healthz:     healthz,
		InfoRoutes: map[string]json.Marshaler{
			"/routes": router.registry,
		},
	}
	vcap.StartComponent(router.component)

最后返回了router实例对象之后,创建与初始化工作即完成了。

Router实例对象的运行操作

在Router对象的函数run()中,几乎执行了所有的Router实例对象的运行操作。

1.router对象使用mbusClient周期性地去连接Cloud Foundry的nats_server:

for {
		err = router.mbusClient.Connect()
		if err == nil {
			break
		}
		log.Errorf("Could not connect to NATS: %s", err)
		time.Sleep(500 * time.Millisecond)
	}

2.router通过mbusClient将自己作为一个component注册到Cloud Foundry: router.RegisterComponent()

3.router订阅其他组件和应用要注册或注销的消息:

router.SubscribeRegister()
	router.HandleGreetings()
	router.SubscribeUnregister()

4.router通过SendStartMessage()发布router.start的消息:

router.SendStartMessage()

	// Send start again on reconnect
	router.mbusClient.OnConnect(func() {
		router.SendStartMessage()
	})

5.周期性的刷新活着的应用的app_id。

6.等待一个start信息的发送时间,以保证gorouter内registry映射表中已经有路由信息,以便而后在代理外部请求的时候,可以找到路由表的映射关系。所以,很显然大家可以发现,gorouter会将组件或者应用的uri注册信息存放在该自身的内存中,而gorouter关闭的时候,映射表中所有的信息丢失,每当重启的时候,需要通过发送一个start消息,然后靠订阅该消息的组件重新注册uri,从而获取所有的路由关系。

7.以TCP的方式监听本机的一个端口:

listen, err := net.Listen("tcp", fmt.Sprintf(":%d", router.config.Port))
	if err != nil {
		log.Fatalf("net.Listen: %s", err)
	}

8.写pid文件:util.WritePidFile(router.config.Pidfile)

9.创建proxy中Server结构体的实例server,并最终一个协程来执行这个server服务于刚才创建的Listen对象:

server := proxy.Server{Handler: router.proxy}
	go func() {	
		err := server.Serve(listen)
		if err != nil {
			log.Fatalf("proxy.Serve: %s", err)
		}
	}()

以上便是gorouter的router实例在运行时所需要作的操作,当然其中很多模块在实现功能的时候,还定义了其他的函数辅助实现,几乎都在router.go文件的函数定义部分,代码本身不难理解,可以对源码进行仔细阅读。

registry模块源码分析

registry模块接管的是Cloud Foundry中组件及应用的uri注册或者注销请求。从计算和存储的角度来分析该模块,即可发现该模块完成了请求的处理和自身内存路由表的设计与维护。

首先来分析一下registry的Registry对象:

type Registry struct {
	sync.RWMutex

	*steno.Logger

	*stats.ActiveApps
	*stats.TopApps

	byUri map[route.Uri]*route.Pool

	table map[tableKey]*tableEntry

	pruneStaleDropletsInterval time.Duration
	dropletStaleThreshold      time.Duration

	messageBus mbus.MessageBus

	timeOfLastUpdate time.Time
}

在该对象中,有两个非常重要的属性byUri和table。

可以看到byUri属性是一个map类型,map的key类型为route.Uri,value类型为*route.pool。那么现在去route/uris.go和route/pool.go中去看一下这些数据结构。uris.go中由定义 type Uri string,那说明它本身就是一个字符串类型,而后可以发现,这是主要域名的形式。而pool类型要稍显复杂,可以理解为是一个路由池,因为在实际情况中,如果DEA上的一个应用由多个实例的话,那么一个uri会对应于多个IP+port的组合。pool拥有一个属性为endpoints,该属性又是一个map类型,key为string,value为Endpoint,具体形式如下:

type Pool struct {
	endpoints map[string]*Endpoint
}

而Endpoint的定义在route/endpoint.go文件中,如下:

type Endpoint struct {
	sync.Mutex

	ApplicationId     string
	Host              string
	Port              uint16
	Tags              map[string]string
	PrivateInstanceId string
}

同样的table属性也是map类型,key为tableKey,value为*tableEntry,随后在相同文件中,有者两个属性的定义:

type tableKey struct {
	addr string
	uri  route.Uri
}

type tableEntry struct {
	endpoint  *route.Endpoint
	updatedAt time.Time
}

关于uri的注册,可以参看Resgister函数:

func (registry *Registry) Register(uri route.Uri, endpoint *route.Endpoint) {
	registry.Lock()
	defer registry.Unlock()

	uri = uri.ToLower()

	key := tableKey{
		addr: endpoint.CanonicalAddr(),
		uri:  uri,
	}

	var endpointToRegister *route.Endpoint

	entry, found := registry.table[key]
	if found {
		endpointToRegister = entry.endpoint
	} else {
		endpointToRegister = endpoint
		entry = &tableEntry{endpoint: endpoint}

		registry.table[key] = entry
	}

	pool, found := registry.byUri[uri]
	if !found {
		pool = route.NewPool()
		registry.byUri[uri] = pool
	}

	pool.Add(endpointToRegister)

	entry.updatedAt = time.Now()

	registry.timeOfLastUpdate = time.Now()
}

其中,lock()函数负责将registry对象上锁,随后的defer语句,表示当整个Register函数执行完毕后,有go语言来完成registry对象的解锁操作。

key为一个tableKey的实例,其中addr为"IP:port"形式的string值,同时创建一个Endpoint类型的endpointToRegister,对于需要注册的(uri,endpoint)组合,首先查看table属性中能都找到键为key的记录,如果找到,那说明该key(实为IP+port,uri的组合)已经存在于table中,所以将table中的记录赋值于endpointToRegister;如果没有找到,那说明该key还未存在于table中,属于一个全新的key,需要在table中相应的记录,则首先用请求中的endpoint赋值给endpointToRegister,然后在通过endpoint创建一个endtry对象,并使用语句:registry.table[key] = entry来实现最终在table中的存储。当gorouter需要解析域名的时候使用的是byUri数据结构,所以在注册的时候也要对byUri进行操作。首先通过请求中的uri来在byUri中查找是否存在该uri的路由表,如果没有找到的话,则需要新建一个路由池pool,在将整个路由池pool,映射到相应的域名上,也就是请求中的uri。随后还需要给该路由池pool添加endpointToRegister整个对象,由于pool的一条记录本身是map类型的,所以在执行添加时,以endpointToPoint的(IP+port)作为该记录的key,整个endpointToRegister作为value。最后再更新一些其他属性。

以上便是Register函数所做的一些工作,Unregister函数做的工作则是一些注销工作。

随后则是一些关于byUri的查找,主要由以下几种类型:

Lookup(uri route.Uri) 通过uri查找,返回pool.Sample(),其实也就是将pool随机返回一条记录,具体可以查看pool的Sample()函数。

LookupByPrivateInstanceId(uri route.Uri, p string) 通过 PrivateInstanceID查找,返回pool匹配该PrivateInstanceID的endpoint。

lookupByUri(uri route.Uri) 通过uri查找,返回整个路由池pool。

以上是关于uri的注册或者注销,另外gorouter还会对路由表进行一定的管理,主要是清理一些很陈旧的路由记录。首先在Router实例对象的初始化中就有陈旧路由信息的剪枝:router.registry.StartPruningCycle(),然后通过建立一个协程进行go registry.checkAndPrune(),通过中间一系列的操作之后,执行pruneStaleDroplets(),遍历table对象中所有的记录,并按条件进行剪枝。

proxy模块源码分析

server部分

可以说作为一个路由节点,proxy是其最为重要的功能,registry这样的模块,其实也是为了能够服务于proxy。对于Cloud Foundry来说,所有通过uri访问Cloud Foundry内部资源的请求,都需要路由节点gorouter作代理。gorouter的proxy模块,首先监听底层的网络端口,然后再将端口发来的请求进行uri解析,最终将请求转发至指定的Cloud Foundry内部节点处。

在proxy模块,实现过程几乎可以从源码中百分百的呈现。从代理流程来讲,proxy模块可以认为是一个方向代理server端,接收所有从nginx发来的请求,并把请求转发至Cloud Foundry内的某些组件处。从实现方式来看,proxy模块建立一条nginx发来请求的连接,根据请求的内部具体信息,做相应的HTTP处理,最终构建该请求的response信息,并通过刚才的连接,将response信息返回给Nginx,当然Nginx最后也会把请求返回给发起请求的用户。其中,刚才提到的相应的HTTP处理,也就是如何将请求发给Cloud Foundry内的某些组件,并接收返回的信息。

粗略划分的话,proxy模块可以分为server端的实现与proxy代理流程的实现。首先从源码入手,第一个需要了解的自然是一些proxy模块中server的重要数据结构,位于server.go文件中。

conn:代表一条连到proxy模块中server上的连接,或者说是从Nginx到gorouter的连接。其中,有需要访问的远程目标的地址,该连接连上的server对象等。

type conn struct {
	remoteAddr string            // network address of remote side
	server     *Server           // the Server on which the connection arrived
	rwc        net.Conn          // i/o connection
	lr         *io.LimitedReader // io.LimitReader(rwc)
	buf        *bufio.ReadWriter // buffered(lr,rwc), reading from bufio->limitReader->rwc
	hijacked   bool              // connection has been hijacked by handler
}

request:代表请求对象,其中包括http类型中的请求对象,也包含一个response返回信息的对象。

type request struct {
	*http.Request
	w *response
}

response:代表从server端返回去的一条response的HTTP信息,其中包括这条返回信息返回时的承载的连接,还有很多关于该HTTP响应的属性值。

type response struct {
	conn *conn

	reqWantsHttp10KeepAlive bool
	reqMethod               string
	reqProtoAtLeast10       bool
	reqProtoAtLeast11       bool
	reqExpectsContinue      bool
	reqContentLength        int64

	chunking      bool        // using chunked transfer encoding for reply body
	wroteHeader   bool        // reply header has been written
	wroteContinue bool        // 100 Continue response was written
	header        http.Header // reply header parameters
	written       int64       // number of bytes written in body
	contentLength int64       // explicitly-declared Content-Length; or -1
	status        int         // status code passed to WriteHeader
	
	closeAfterReply bool

	requestBodyLimitHit bool
}

server:代表接收请求,转发请求的server端,其中包含一个远程地址,另外还有一个非常重要的handler对象,用来处理HTTP请求,如果对Nginx源码熟悉的话,对Handler这个模块应该不会陌生。另外,这里的handler其实就是proxy.go文件中定义的proxy结构体。

type Server struct {
	Addr           string        // TCP address to listen on, ":http" if empty
	Handler        http.Handler  // handler to invoke, http.DefaultServeMux if nil
	ReadTimeout    time.Duration // maximum duration before timing out read of the request
	WriteTimeout   time.Duration // maximum duration before timing out write of the response
	MaxHeaderBytes int           // maximum size of request headers, DefaultMaxHeaderBytes if 0
}

源码本身是从结构体入手,并进行方法定义,为了便于理解,以下采用请求流程的方式对源码进行解读。

要想对server结构有初步的了解,那必须从server的函数Server()入手。简单代码形式如下(已省略部分异常处理等代码):

func (srv *Server) Serve(l net.Listener) error {
	defer l.Close()
	var tempDelay time.Duration // how long to sleep on accept failure
	for {
		rw, e := l.Accept()
		……
		c, err := srv.newConn(rw)
		……
		go c.serve()
	}
	panic("not reached")
}

该Serve()函数的发起者为Server实例对象,传入的参数为对端口的监听对象。在执行该函数的时候,defer方法显性的定义了关于函数执行完毕后所需要处理的后续工作。关于server的具体执行操作,不难理解的服务器端需要不断轮询端口,并对端口处发来的请求进行相应的处理。在这里的代码实现即为一个for循环,在该循环中,首先server实例对象accept一个连接。这里的原理和socket的实现很类似,首先作为一个server端,先去监听listen,某一个端口,然后去accept这个端口发来的连接请求,也就是说,一旦有连接请求发来的话,server便会去accept该请求;然后作为一个client端,所需要做的操作就是去给server端的某一端口发送连接请求,如果有server监听了这个端口,那么它可以accept该连接请求;最后双方可以通信。

首先,server通过代码 rw, e := l.Accept() 实现对监听端口请求的接受;然后server再对这个net.Conn类型的rw,进行处理,最后生成一个新的连接,也就是上面涉及到的conn结构对象;接着,server创建一个协程来完成这条连接上的请求。可以发现的是,由于在gorouter中server对象只有一个,所以所有的请求都是经过这个server的,那在for循环中,server会接受很多的请求,创建很多的连接,然后对于对于一个连接上的请求,又会创建一个协程来完成,如果不借助协程的高并发处理能力,几乎不能应对大负载。

以上是对Server实例对象执行时的代码入口的解读,当真正处理请求的时候,是在go c.serve()处,其中go代表这开辟一个协程,c.serve()则是处理的具体实现。

以下是对serve()函数的分析,首先来看函数中的主要代码:

func (c *conn) serve() {
	defer func() {
		……
		}
	}()

	for {
		req, w, err := c.readRequest()
		……
		// Expect 100 Continue support
		if req.expectsContinue() {
			……
		} else if req.Header.Get("Expect") != "" {
			……
		}

		handler := c.server.Handler
		……
		handler.ServeHTTP(w, req.Request)
		……
		req.finishRequest()
		if w.closeAfterReply {
			break
		}
	}
	c.close()
}

defer关键字依旧是表示随后定义的函数是做来为serve()方法作善后处理。接着是一个for循环,在该for循环中,首先从连接中读取一个请求,然后对该请求的某些属性进行查阅并处理,接着创建一个handler,最后由该handler来处理HTTP请求,并结束一个请求,如果该请求是一个一次连接,那么关于该连接,如果该请求处于长连接上,则继续for循环的下一次迭代,继续从连接中读取请求并处理。

现在我们涉及函数中具体实现。 在for循环中,首先读取连接中的请求,代码形式为:req, w, err := c.readRequest(), 该实现,返回两个对象,一个为读取的请求,另一个为需要生成的response。在readRequest()函数中,代码的形式为(只显示主要部分):

func (c *conn) readRequest() (r *request, w *response, err error) {
	……
	var req *http.Request
	if req, err = http.ReadRequest(c.buf.Reader); err != nil {
		……
	}
	c.lr.N = noLimit

	req.RemoteAddr = c.remoteAddr

	w = new(response)
	w.conn = c

	r = new(request)
	r.Request = req
	r.w = w

	……
	return r, w, nil
}

可见,执行该函数的时候,首先通过http的函数ReadRequest()来实现从连接c中读取请求,然后通过该请求,分别创建一个request对象和response对象。需要注意的是代码w.conn=c,也就是在说创建玩reponse对象后,对对象属性初始化时,将response的连接熟悉感conn,依旧赋值为c,那么当server将该请求转发给Cloud Foundry内部组件处理后收到回复,并对回复再进行处理,来完成这里的response重写后,依旧通过之前的连接发回去。这样的实现显得更加高效,之前我一直在考虑,想nginx之类的反向代理服务器,接收请求转发请求,接收回复转发回复的流程,如果都需要重新创建连接来完成的话,连接的开销会巨大,对于一些有长连接需求的http请求,重建连接的机制会显得非常笨重。在这里的go语言实现中,由于自定义了response的结构体,又轻松地实现了连接捆绑,所以不需要考虑连接的重新创建,但是这样的方式肯定也会付出一定的代价,比如说内存的消耗等,因为每个response实例对象中,都会存放一个连接信息。

在serve()函数可以看到,在读取连接请求readRequest()后,对请求进行一些处理之后,会创建一个handler对象来实现HTTP请求的处理,由于该部分的实现在proxy.go文件中,所以本文稍后即会涉及,简单来讲就是给后台做代理,将请求发给后台,并接收后台的回复。

当获得后台的响应请求后,server随即执行finishRequest()函数,其主要的功能就是将返回的后台回复,写入需要返回给用户的response对象中。然后判断response对象中的属性closeAfterReply,如果为真,则表示之前的请求是一个一次请求,该请求表明,自身发出之后接收到回复之后,不会再发起请求,就算有,也情愿是在创建一个连接来实现,所以程序跳出for循环,关闭连接;如果为假的话,那说明请求需要在一条长连接上进行操作,换言之,在请求的回复发给用户后,用户还会有请求通过这条连接发给server,这样的话,无需关闭连接,只需有server继续对这条连接执行readRequest()函数即可。

proxy部分

相比较而言,proxy部分做的工作要比server部分少一些,它主要的工作就是解析请求的uri和转发请求。

关于解析请求的uri的工作,在函数Lookup()中实现:

func (proxy *Proxy) Lookup(request *http.Request) (*route.Endpoint, bool) {
	uri := route.Uri(hostWithoutPort(request))

	if _, err := request.Cookie(StickyCookieKey); err == nil {
		if sticky, err := request.Cookie(VcapCookieId); err == nil {
			routeEndpoint, ok := proxy.Registry.LookupByPrivateInstanceId(uri, sticky.Value)
			if ok {
				return routeEndpoint, ok
			}
		}
	}
	return proxy.Registry.Lookup(uri)
}

首先,找到请求中的host,然后对于该请求,检查是否有StickyCookieKey,如果有的话,直接从中获取sticky,再通过uri和sticky.value的组合找到相应的routeEndpoint,也就是请求的backend。这里可以稍微解释一下StickyCookieKey的作用。一旦一个请求中含有该cookie,而且能被解析到相应的uri和sticky值,也就是说这个请求,希望被处理的时候,能继续被上次处理过这个用户发出的请求的app instance上,这样的话,可以避免一些不必要的数据冲突等,或者减少DEA中 app instance的负载。如果没有找到cookie的话,那么proxy就老老实实通过host来找到相应的ip:port,如果一个host有多个instance实例的话,proxy会通过某种策略来决策由哪个Instance来服务。

在转发请求的时候,实现在函数ServeHTTP()方法中,在实现过程中,我们需要清楚其中的几个重要的方面即可:构建一个responseWriter并初始化某些属性,判断请求的类型并分别处理(TCP、websocket和HTTP),若为HTTP类型则通过transport.RoundTrip方法发送请求并接收响应,最后填写reponseWriter和设置cookie。这一部分的代码随着gorouter版本的更新会有一些形式上的不同,但是主要的功能和思想都是一致的。

以上就是对gorouter一些模块的源码分析。

 
相关文章

云计算的架构
对云计算服务模型
云计算核心技术剖析
了解云计算的漏洞
 
相关文档

云计算简介
云计算简介与云安全
下一代网络计算--云计算
软浅析云计算
 
相关课程

云计算原理与应用
云计算应用与开发
CMMI体系与实践
基于CMMI标准的软件质量保证
 
分享到
 
 


专家视角看IT与架构
软件架构设计
面向服务体系架构和业务组件的思考
人人网移动开发架构
架构腐化之谜
谈平台即服务PaaS
更多...   
相关培训课程

云计算
Windows Azure 云计算应用开发