首页技术文章正文

Go语言构建微服务一站式解决方案

更新时间:2018-06-07 来源:黑马程序员 浏览量:

1528343999478_1.png

开发单体式应用

假设你正准备开发一款与Uber和Hailo竞争的出租车调度软件,经过初步会议和需求分析,你可能使用传统的程序框架来生成你的项目,最终的程序架构如下图所示:

1528350583640_2.png

尽管也是模块化逻辑,但是最终它还是会打包并部署为单体式应用。具体的格式依赖于应用语言和框架。最终程序发布的时候也会被打包成单一的程序发布出来。

单体式应用的不足

不幸的是,这种简单方法却有很大的局限性。一个简单的应用会随着时间推移逐渐变大。在每次的sprint中,开发团队都会面对新“故事”,然后开发许多新代码。几年后,这个小而简单的应用会变成了一个巨大的怪物。这儿有一个例子,我最近和一个开发者讨论,他正在写一个工具,用来分析他们一个拥有数百万行代码的应用中JAR文件之间的依赖关系。我很确信这个代码正是很多开发者经过多年努力开发出来的一个怪物。

一旦你的应用变成一个又大又复杂的怪物,那开发团队肯定很痛苦。敏捷开发和部署举步维艰,其中最主要问题就是这个应用太复杂,以至于任何单个开发者都不可能搞懂它。因此,修正bug和正确的添加新功能变的非常困难,并且很耗时。另外,团队士气也会走下坡路。如果代码难于理解,就不可能被正确的修改。最终会走向巨大的、不可理解的泥潭。

另外,复杂而巨大的单体式应用也不利于持续性开发。今天,SaaS应用常态就是每天会改变很多次,而这对于单体式应用模式非常困难。另外,这种变化带来的影响并没有很好的被理解,所以不得不做很多手工测试。那么接下来,持续部署也会很艰难。

单体式应用另外一个问题是可靠性。因为所有模块都运行在一个进程中,任何一个模块中的一个bug,比如内存泄露,将会有可能弄垮整个进程。除此之外,因为所有应用实例都是唯一的,这个bug将会影响到整个应用的可靠性。

最后,单体式应用使得采用新架构和语言非常困难。比如,设想你有两百万行采用XYZ框架写的代码。如果想改成ABC框架,无论是时间还是成本都是非常昂贵的,即使ABC框架更好。因此,这是一个无法逾越的鸿沟。你不得不在最初选择面前低头。

那么如何应对呢?

微处理架构——处理复杂事物

许多公司,比如Amazon、eBay和NetFlix,通过采用微处理结构模式解决了上述问题。其思路不是开发一个巨大的单体式的应用,而是将应用分解为小的、互相连接的微服务。

一个微服务一般完成某个特定的功能,比如下单管理、客户管理等等。每一个微服务都是微型六角形应用,都有自己的业务逻辑和适配器。一些微服务还会发布API给其它微服务和应用客户端使用。其它微服务完成一个Web UI,运行时,每一个实例可能是一个云VM或者是Docker容器。

比如,一个前面描述系统可能的分解如下:


1528350625344_3.png

每一个应用功能区都使用微服务完成,另外,Web应用会被拆分成一系列简单的Web应用(比如一个对乘客,一个对出租车驾驶员)。这样的拆分对于不同用户、设备和特殊应用场景部署都更容易。

每一个后台服务开放一个REST API,许多服务本身也采用了其它服务提供的API。比如,驾驶员管理使用了告知驾驶员一个潜在需求的通知服务。UI服务激活其它服务来更新Web页面。所有服务都是采用异步的,基于消息的通讯。

微服务架构的好处

微服务架构模式有很多好处。首先,通过分解巨大单体式应用为多个服务方法解决了复杂性问题。在功能不变的情况下,应用被分解为多个可管理的分支或服务。每个服务都有一个用RPC-或者消息驱动API定义清楚的边界。微服务架构模式给采用单体式编码方式很难实现的功能提供了模块化的解决方案,由此,单个服务很容易开发、理解和维护。

第二,这种架构使得每个服务都可以有专门开发团队来开发。开发者可以自由选择开发技术,提供API服务。当然,许多公司试图避免混乱,只提供某些技术选择。然后,这种自由意味着开发者不需要被迫使用某项目开始时采用的过时技术,他们可以选择现在的技术。甚至于,因为服务都是相对简单,即使用现在技术重写以前代码也不是很困难的事情。

第三,微服务架构模式是每个微服务独立的部署。开发者不再需要协调其它服务部署对本服务的影响。这种改变可以加快部署速度。UI团队可以采用AB测试,快速的部署变化。微服务架构模式使得持续化部署成为可能。

最后,微服务架构模式使得每个服务独立扩展。你可以根据每个服务的规模来部署满足需求的规模。甚至于,你可以使用更适合于服务资源需求的硬件。

微服务架构的特性

1. 单一职责

微服务架构中的每个服务,都是具有业务逻辑的,符合高内聚、低耦合原则以及单一职责原则的单元,不同的服务通过“管道”的方式灵活组合,从而构建出庞大的系统。

2. 轻量级通信

服务之间通过轻量级的通信机制实现互通互联,而所谓的轻量级,通常指语言无关、平台无关的交互方式。


1528350653657_4.png

对于轻量级通信的格式而言,我们熟悉的 XML 和 JSON,它们是语言无关、平台无关的;对于通信的协议而言,通常基于 HTTP,能让服务间的通信变得标准化、无状态化。目前大家熟悉的 REST(Representational State Transfer)是实现服务间互相协作的轻量级通信机制之一。使用轻量级通信机制,可以让团队选择更适合的语言、工具或者平台来开发服务本身。

3. 独立性

每个服务在应用交付过程中,独立地开发、测试和部署。

在单块架构中所有功能都在同一个代码库,功能的开发不具有独立性;当不同小组完成多个功能后,需要经过集成和回归测试,测试过程也不具有独立性;当测试完成后,应用被构建成一个包,如果某个功能存在 bug,将导致整个部署失败或者回滚。

1528350688080_5.png

在微服务架构中,每个服务都是独立的业务单元,与其他服务高度解耦,只需要改变当前服务本身,就可以完成独立的开发、测试和部署。


1528350713034_6.png

4. 进程隔离

在微服务架构中,每个服务都是独立的业务单元,与其他服务高度解耦,只需要改变当前服务本身,就可以完成独立的开发、测试和部署。有时候我们会将重复的代码抽取出来封装成组件,在单块架构中,组件通常的形态叫做共享库(如 jar 包或者 DLL),但是当程序运行时,所有组件最终也会被加载到同一进程中运行。


1528350731300_7.png

在微服务架构中,应用程序由多个服务组成,每个服务都是高度自治的独立业务实体,可以运行在独立的进程中,不同的服务能非常容易地部署到不同的主机上。


1528350752831_8.png

既然要介绍微服务,就不得不介绍一下与微服务相关的技术。那么,接下来,我们一一做一下详细讲解。

protoBuf(Google旗下平台语言无关序列化数据协议)

1528350795113_9.png

protobuf是google旗下的一款平台无关,语言无关,可扩展的序列化结构数据格式。所以很适合用做数据存储和作为不同应用,不同语言之间相互通信的数据交换格式,只要实现相同的协议格式即同一 proto文件被编译成不同的语言版本,加入到各自的工程中去。这样不同语言就可以解析其他语言通过 protobuf序列化的数据。目前官网提供了 C++,Python,JAVA,GO等语言的支持。

protobuf语法定义

要想使用 protobuf必须得先定义 proto文件。所以得先熟悉 protobuf的消息定义的相关语法。下面就来介绍。

首先我们先定义一个 proto文件,结构如下:

message Article {

required int32 article_id=1;

optional string article_excerpt=2;

repeated string article_picture=3;

}

上面我们主要定义了一个消息,这个消息包括文章 ID,文章摘要,文章图片。下面给出消息定义的相关说明 :

n message是消息定义的关键字。

a) required表示是一个必须字段,必须相对于发送方,在发送消息之前必须设置该字段的值,对于接收方,必须能够识别该字段的意思。发送之前没有设置required字段或者无法识别required字段都会引发编解码异常,导致消息被丢弃。

b) Optional:表示是一个可选字段,可选对于发送方,在发送消息时,可以有选择性的设置或者不设置该字段的值。对于接收方,如果能够识别可选字段就进行相应的处理,如果无法识别,则忽略该字段,消息中的其它字段正常处理。---因为optional字段的特性,很多接口在升级版本中都把后来添加的字段都统一的设置为optional字段,这样老的版本无需升级程序也可以正常的与新的软件进行通信,只不过新的字段无法识别而已,因为并不是每个节点都需要新的功能,因此可以做到按需升级和平滑过渡。

c) Repeated:表示该字段可以包含0~N个元素。其特性和optional一样,但是每一次可以包含多个值。可以看作是在传递一个数组的值

d) int32和string是字段的类型。后面是我们定义的字段名。最后的 1,2,3则是代表每个字段的一个唯一的编号标签,在同一个消息里不可以重复。这些编号标签用与在消息二进制格式中标识你的字段,并且消息一旦定义就不能更改。需要说明的是标签在 1到15范围的采用一个字节进行编码。所以通常将标签 1到15用于频繁发生的消息字段。编号标签大小的范围是1到229 – 1。此外不能使用protobuf系统预留的编号标签(19000 -19999)

当然 protobuf支持更多的类型,比如 bool,double,float,枚举,也可以是其他定义过的消息类型譬如前面的消息 Article。支持的基本类型如下:


1528350869880_10.png

一般在我们的项目中肯定会有很多消息类型。我们总不能都定义在一个文件中。当一个 proto文件需要另一个 proto文件的时候,我们可以通过 import导入,就像下面这样:

import "article.proto";

message Book {

//定义消息体

}

protoBuf使用

protobuf的使用方法是将数据结构写入到 .proto文件中,使用 protoc编译器编译(间接使用了插件)得到一个新的 go包,里面包含 go中可以使用的数据结构和一些辅助方法。

Golang & protoBuf

1. $GOPATH/src/创建 myproto文件夹

2. myproto文件夹中创建 test.proto文件 (protobuf协议文件 )

syntax = “proto2”;

package myproto;

enum FOO {X = 17;};

message Test {

required string label = 1;

optional int32 type = 2 [default=77];

repeated int64 reps = 3;

optional group OptionalGroup = 4 {

required string RequiredFiled = 5;

}

}

3. 编译 :执行

protoc --go_out=. *.proto

生成 test.pb.go文件

4. 使用 protobuf做数据格式转换

package main

import (

"fmt"

"github.com/golang/protobuf/proto"

"myproto"

)

func main() {

test := &myproto.Test{

Label: proto.String("hello"),

Type: proto.Int32(17),

Reps: []int64{1, 2, 3},

Optionalgroup: &myproto.Test_OptionalGroup{

RequiredFiled: proto.String("good bye"),

},

}

//将Struct test 转换成 protobuf

data, err := proto.Marshal(test)

if err != nil {

fmt.Println("marshaling error: ", err)

}

//得到一个新的Test结构体 newTest

newTest := &myproto.Test{}

//将 data 转换成 Test结构体

err = proto.Unmarshal(data, newTest)

if err != nil {

fmt.Println("unmarshaling error: ", err)

}

//将newTest的字符串序列化打出

fmt.Println(newTest.String())

//得到type字段

if test.GetType() != newTest.GetType() {

fmt.Println("type is not equal")

}

//...

}

gRPC(Google定义的PRC协议标准)


1528350892459_11.png

gRPC是什么?

在 gRPC里客户端应用可以像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法,使得您能够更容易地创建分布式应用和服务。与许多 RPC系统类似, gRPC也是基于以下理念:定义一个服务,指定其能够被远程调用的方法(包含参数和返回类型)。在服务端实现这个接口,并运行一个 gRPC服务器来处理客户端调用。在客户端拥有一个存根能够像服务端一样的方法。 gRPC客户端和服务端可以在多种环境中运行和交互 -从 google内部的服务器到你自己的笔记本,并且可以用任何 gRPC支持的语言 来编写。所以,你可以很容易地用 Java创建一个 gRPC服务端,用 Go、 Python、Ruby来创建客户端。此外, Google最新 API将有 gRPC版本的接口,使你很容易地将 Google的功能集成到你的应用里。


1528350912991_12.png

使用 protocol buffers

gRPC默认使用protoBuf,这是 Google开源的一套成熟的结构数据序列化机制(当然也可以使用其他数据格式如 JSON)。正如你将在下方例子里所看到的,你用 proto files创建 gRPC服务,用 protoBuf消息类型来定义方法参数和返回类型。你可以在 Protocol Buffers文档找到更多关于 protoBuf的资料。

虽然你可以使用 proto2 (当前默认的 protocol buffers版本 ),我们通常建议你在 gRPC里使用 proto3,因为这样你可以使用 gRPC支持全部范围的的语言,并且能避免 proto2客户端与 proto3服务端交互时出现的兼容性问题,反之亦然。

你好 gRPC

现在你已经对 gRPC有所了解,了解其工作机制最简单的方法是看一个简单的例子。 Hello World将带领你创建一个简单的客户端 —— 服务端应用,向你展示:

n 通过一个protoBuf模式,定义一个简单的带有 Hello World方法的 RPC服务。

n 用你最喜欢的语言 (如果可用的话 )来创建一个实现了这个接口的服务端。

n 用你最喜欢的 (或者其他你愿意的 )语言来访问你的服务端。

go语言实现 gRPC远程调用

创建一个 protobuf package,如: my_rpc_proto; 在$GOPATH/src/下创建 go_lession/gRPC_test/my_rpc_proto/文件夹里面创建 protobuf协议文件 helloServer.proto

syntax = "proto3";

package my_rpc_proto;

// The HelloServer service definition.

service HelloServer {

// 第一个远程调用接口

rpc SayHello (HelloRequest) returns (HelloReply) {}

// 第二个远程调用接口

rpc GetHelloMsg (HelloRequest) returns (HelloMessage) {}

}

// The request message containing the user's name.

message HelloRequest {

string name = 1;

}

// The response message containing the greetings

message HelloReply {

string message = 1;

}

message HelloMessage {

string msg = 1;

}

在当前文件下,编译 helloServer.proto文件

protoc –go_out=plugins=grpc:./ *.proto

得到 helloServer.pb.go文件

1. gRPC-Server编写

package main

import (

"fmt"

"net"

pb "go_lession/gRPC_test/my_rpc_proto"

"golang.org/x/net/context"

"google.golang.org/grpc"

)

const (

port = ":18881"

)

type server struct{}

//实现RPC SayHello 接口

func (this *server) SayHello(ctx context.Context, in *pb.HelloRe

quest) (*pb.HelloReply, error) {

return &pb.HelloReply{Message: "hello" + in.Name}, nil

}

//实现RPC GetHelloMsg 接口

func (this *server) GetHelloMsg(ctx context.Context, in *pb.Hell

oRequest) (*pb.HelloMessage, error) {

return &pb.HelloMessage{Msg: "this is from server HAHA!"}, nil

}

func main() {

listen, err := net.Listen("tcp", port)

if err != nil {

fmt.Println("failed to listen : ", err)

return

}

//得到一个gRPC 服务句柄

srv := grpc.NewServer()

//将 server 结构体注册到 gRPC 服务

pb.RegisterHelloServerServer(srv, &server{})

//启动监听gRPC服务

if err := srv.Serve(listen); err != nil {

fmt.Println("failed to serve, ", err)

return

}

}

2. gRPC-Client编写

package main

import (

"fmt"

pb "go_lession/gRPC_test/my_rpc_proto"

"golang.org/x/net/context"

"google.golang.org/grpc"

)

const (

address = "localhost:18881"

clientName = "GreenHat"

)

func main() {

//了客户端连接服务器

conn, err := grpc.Dial(address, grpc.WithInsecure())

if err != nil {

fmt.Println("did not connetc : ", err)

return

}

defer conn.Close()

//获取一个 gRPC 句柄

c := pb.NewHelloServerClient(conn)

//远程调用 SayHello接口

r1, err := c.SayHello(context.Background(), &pb.HelloRequest{Name: clientName})

if err != nil {

fmt.Println("cloud not get Hello server ..", err)

return

}

fmt.Println("HelloServer resp: ", r1.Message)

//远程调用 GetHelloMsg接口

r2, err := c.GetHelloMsg(context.Background(), &pb.HelloRequest{Name: clientName})

if err != nil {

fmt.Println("cloud not get hello msg ..", err)

return

}

fmt.Println("HelloServer resp: ", r2.Msg)

}

运行 server,在运行 client

得到以下输出结果:

HelloServer resp: helloGreenHat

HelloServer resp: this is from server HAHA!

Consul(基于Go的服务发现工具)


1528350939444_13.png

Consul简介

Consul是什么

Consul是HashiCorp公司推出的开源工具,用于实现分布式系统的服务发现与配置。 Consul是分布式的、高可用的、可横向扩展的。它具备以下特性 :

l service discovery:consul通过DNS或者HTTP接口使服务注册和服务发现变的很容易,一些外部服务,例如saas提供的也可以一样注册。

l health checking:健康检测使consul可以快速的告警在集群中的操作。和服务发现的集成,可以防止服务转发到故障的服务上面。

l key/value storage:一个用来存储动态配置的系统。提供简单的HTTP接口,可以在任何地方操作。

l multi-datacenter:无需复杂的配置,即可支持任意数量的区域。

什么是服务发现

微服务的框架体系中,服务发现是不能不提的一个模块。我相信了解或者熟悉微服务的童鞋应该都知道它的重要性。这里我只是简单的提一下,毕竟这不是我们的重点。我们看下面的一幅图片:


1528350974460_14.png

图中,客户端的一个接口,需要调用服务A-N。客户端必须要知道所有服务的网络位置的,以往的做法是配置是配置文件中,或者有些配置在数据库中。这里就带出几个问题:

· 需要配置N个服务的网络位置,加大配置的复杂性

· 服务的网络位置变化,都需要改变每个调用者的配置

· 集群的情况下,难以做负载(反向代理的方式除外)


1528351015383_15.png

与之前一张不同的是,加了个服务发现模块。图比较简单,这边文字描述下。服务A-N把当前自己的网络位置注册到服务发现模块(这里注册的意思就是告诉),服务发现就以K-V的方式记录下,K一般是服务名,V就是IP:PORT。服务发现模块定时的轮询查看这些服务能不能访问的了(这就是健康检查)。客户端在调用服务A-N的时候,就跑去服务发现模块问下它们的网络位置,然后再调用它们的服务。这样的方式是不是就可以解决上面的问题了呢?客户端完全不需要记录这些服务网络位置,客户端和服务端完全解耦!


1528351034055_16.png

下面的例子有可能更有助于我们理解服务发现的形式:

例如邮递员去某公司一栋大楼投递快件,向门卫询问员工甲在哪一个房间,门卫拿起桌上的通讯录查询,告知邮递员员工甲在具体什么位置。假如公司来了一个员工乙,他想让邮递员送过来,就要先让门卫知道自己在哪一个房间,需要去门卫那边登记,员工乙登记后,当邮递员向门卫询问时,门卫就可以告诉邮递员员工乙的具体位置。门卫知道员工乙的具体位置的过程就是服务发现,员工乙的位置信息可以被看作服务信息,门卫的通讯录就是上文中提到的数据交换格式,此例中员工乙就是上文的已方,门卫就是服务发现的提供者。

以调试模式启动consul:

$ conusl agent –dev –bind=0.0.0.0

go_micro(基于Go的微服务框架)


1528351053368_17.png

依赖

我们需要一个发现服务器,这里 micro默认使用的 Consul,我们这里用之前安装部署好的 consul,用来做 go的micro服务发现

protoBuf作为Server端和Client端的数据交换格式。

下载 micro

go get github.com/micro/micro

hello micro

赶紧完成一个go_micro的微服务吧 ~

micro为我们提供的微服务框架如下

这里面有很多角色, Micro API, Customer API, Customer Service等等 ...

其中 Micro API是micro给我们提供的一个工具,是通过 RPC调用我们模块的 API和做一些负载均衡的作用,实际上 Customer API, Customer Service是一组微服务, Customer API收到 Micro API转发的请求后,将 RESTful转换成 protobuf通过 gRPC调用发送给 Customer Service做服务处理,然后又将返回 protobuf数据转换成 RESTful返回给用户。

在微服务架构中一般称 API为RPC GateWay

GRPC Gateway

此指南帮助我们使用 go-micro微服务中的 grpc gateway。

grpc-gateway是一个 protoc的一个插件。它基于 gRPC服务定义,并且提供一

个将 RESTful JSON API转换成 gRPC协议的反响代理服务。

我们使用 go-grpc去完成后台服务, go-grpc是一个为 client/server将go-micro

和gRPC结合的一个包裹。

代码案例

examples/grpc.

Create service proto-创建服务端protobuf

这里,我们的proto文件定义如下:

syntax = "proto3";

service Greeter {

rpc Hello(HelloRequest) returns (HelloResponse) {}

}

message HelloRequest {

string name = 1;

}

message HelloResponse {

string greeting = 2;

}

Write the service

1、 实现自定义接口

2、 初始化一个微服务

3、 注册Greeter 句柄

4、 启动服务

package main

import (

"context"

"fmt"

micro "github.com/micro/go-micro"

proto "github.com/micro/examples/service/proto"

)

type Greeter struct{}

func (g *Greeter) Hello(ctx context.Context, req *proto.HelloRequest, rsp *proto.HelloResponse) error {

rsp.Greeting = "Hello " + req.Name

return nil

}

func main() {

// Create a new service. Optionally include some options here.

service := micro.NewService(

micro.Name("greeter"),

)

// Init will parse the command line flags.

service.Init()

// Register handler

proto.RegisterGreeterHandler(service.Server(), new(Greeter))

// Run the server

if err := service.Run(); err != nil {

fmt.Println(err)

}

}

Run service

go run examples/service/main.go

Output

2016/03/14 10:59:14 Listening on [::]:50137

2016/03/14 10:59:14 Broker Listening on [::]:50138

2016/03/14 10:59:14 Registering node: greeter-ca62b017-e9d3-11e5-9bbb-68a86d0d36b6

Define a client-编写客户端

下面是客户端访问微服务的代码:

package main

import (

"context"

"fmt"

micro "github.com/micro/go-micro"

proto "github.com/micro/examples/service/proto"

)

func main() {

// Create a new service. Optionally include some options here.

service := micro.NewService(micro.Name("greeter.client"))

service.Init()

// Create new greeter client

greeter := proto.NewGreeterClient("greeter", service.Client())

// Call the greeter

rsp, err := greeter.Hello(context.TODO(), &proto.HelloRequest{Name: "John"})

if err != nil {

fmt.Println(err)

}

// Print response

fmt.Println(rsp.Greeting)

}

Run the client

go run client.go

Output

Hello John

QA

1 Go语言除了能开发区块链还能开发哪些领域?

Go语言作为一个开发效率高,天生支持高并发,同时又具备媲美C语言性能的语言,在未来一定是后端开发语言的最具有潜力的编程语言。目前很多企业的服务器架构也逐步在用Go语言重构。

Go语言目前主要涉及的领域有:高并发服务器开发、分布式开发、微服务开发、Web框架及应用开发、和区块链开发。

高并发服务器开发:

不用解释了,Go天生语法的并发支持和Goroutine协程的轻量级与调度器的优化,目前很多游戏公司主要服务开发语言最优选择一定是Golang.

分布式开发:

我们知道的两个分布式虚拟化明星:Docker、Kubernetes他们的开发实现语言都是Go语言。有人说是Docker捧红了分布式,实际上很多人并不知道,是Go捧红了Docker生态。

微服务开发:

Go的微服务框架居多,加上Docker对go的支持最好,所以go也是微服务开发的首选语言。

go的微服务框架有go-micro,go-kit。服务发现有go实现的Consul。微服务通信的RPC机制有google实现的gRPC,其中通信协议protobuf也是对go无缝衔接的。

Web框架及应用开发:

对于web,大家可能会想到java的Spring、python的Django。但是可能并不知道Go的Beego和Gin、Echo等web框架正在逐步侵蚀大型互联网公司。很多公司已经通过Beego来搭建web后台服务,因为Go的天生网络处理的流畅,让开发者在构建大型web的时候,更加喜欢了Go语言。

区块链开发:

我们所遇见的区块链应用项目或者相关框架几乎都是Go语言实现,或者对Go的支持最好。主流的Hyperledger Fabric 和以太坊也是目前企业正在大规模使用的开发框架。go的这种开发效率高和高性能,对于区块链这种注重网络通信和信息安全的技术,更是不可或缺的。

2 Go语言难易程度如何?

Go语言学习起来完全可以零基础入门。Google在创建Go语言的最初定义为:简单快乐的开发高性能系统语言。可见go语言并不是很难。


本文版权归黑马程序员C/C++与网络攻防学院所有,欢迎转载,转载请注明作者出处。谢谢!

作者:黑马程序员C/C++与网络攻防培训学院

首发:http://c.itheima.com/

分享到:
在线咨询 我要报名
和我们在线交谈!