Microservices in Golang - Part6

内容提要:API GATEWAY & UI

原作者:Ewan Valentine

原文链接:https://ewanvalentine.io/microservices-in-golang-part-6/

引言

前文中我们从事件驱动架构出发,关注了 go-micro 和原生 go 中事件驱动的一般实现。这一节中我们将从 web 端出发,探寻客户端与微服务交互的方法。

micro 工具集 已经为我们提供了 web 端服务从外部直接调用我们内部 rpc 的方法。

我们将为货流平台创建一个用户界面,包括登录页面创建 consignment 的接口。通过这节的学习我们可以将之前的内容全部联系起来。

RPC 的“文艺复兴”

RESTweb 服务中已经制霸了很多年了,从它出现之后,就迅速地成为了客户机与服务器之间资源管理的主要方式。REST 的出现结束了 RPC 与 SOAP 的“混乱之治”,结束了它们那套过时且让人痛苦的实现方法。

你有体验过写 wsdl 文件吗? 🙄️

REST

REST 向我们提供了一种简单实用且统一的管理资源的方法,它使用 http 动词 (Put, Post, Delete, Get) 来更加明确地描述正在执行的操作类型。REST 鼓励我们使用 http 错误代码来更好的描述来自服务器的响应。

在大多数情况下,这种方法都表现的很好,但是再好的东西也是优缺点的,REST一些方面收到很多人的抱怨,这里就不展开说了。

RPC

用于在应用程序和服务器之间传递数据的三种模型:

  • SOAP 模型(面向消息)

  • RPC 模型(面向方法)

  • REST 模型(面向资源)

SOAP + WSDL 因为过于麻烦,用的人已经很少了。而在一段时间内,都是 RESTweb 领域独领风骚,不过借着微服务发展的顺风车,RPC 风格也开始渐渐复兴。

REST 风格在管理多种不同资源时有很好的效果,但是微服务就其本质而言,通常只处理单一资源,所以我们就不需要在微服务的上下文中使用 RESTful 术语,相应的,我们可以更多的关注具体的操作与每个服务间的交互。

Micro

在整个系列文章中,我们大量用到了 go-micro 微服务框架,现在我们将会接触到 micro cli/toolkitmicro toolkit 提供了 API 网关、sidecar、web 代理,以及一些其他很酷的功能,但是我们最主要用到的部分还是 API 网关。

API 网关允许我们将内部的 RPC 方法代理到 web 上,以 URL 的形式开放调用端口,客户端可以使用非常友好的 JSON rpc 对微服务中的方法进行调用。

使用

安装 micro toolkit

1
$ go get -u github.com/micro/micro

同时拉取 Docker 镜像:

1
$ docker pull microhq/micro

修改代码

修改 user-service 的部分代码,主要是错误处理和一些命名惯例。

  • user-service/main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// user-service/main.go
package main

import (
"log"

pb "github.com/EwanValentine/shippy-user-service/proto/auth"
"github.com/micro/go-micro"
_ "github.com/micro/go-plugins/registry/mdns"
)

func main() {

// 创建与数据库的连接
db, err := CreateConnection()
defer db.Close()

if err != nil {
log.Fatalf("Could not connect to DB: %v", err)
}

// 自动将用户数据类型转化为数据库的存储类型
// 每次服务重启之后,都会检查变动,并将数据迁移
db.AutoMigrate(&pb.User{})

repo := &UserRepository{db}

tokenService := &TokenService{repo}

// 创建服务
srv := micro.NewService(

// 注意:作者在这里使用了新仓库 shippy-user-service
// 将 protobuf 中的声明由 user 改为了 auth
// 此处依据自己的情况来修改,只需要注意
// 名称必须与protobuf中声明的包名一致
// API 参数也需要对应修改
micro.Name("shippy.auth"),
micro.Version("latest"),
)

// Init 方法会解析所有命令行参数
srv.Init()

// 获取 broker 实例
publisher := micro.NewPublisher("user.created", srv.Client())

pb.RegisterUserServiceHandler(srv.Server(), &service{repo, tokenService, publisher})

if err := srv.Run(); err != nil {
log.Fatalf("user service error: %v\n", err)
}
}

如果你在此处依照作者的代码进行了修改,那么相应的,你还需要修改以下文件中的对应内容。


  • user-service/proto/auth/auth.proto
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// shippy-user-service/proto/auth/auth.proto
syntax = "proto3";

package auth;

service Auth {
rpc Create(User) returns (Response) {}
rpc Get(User) returns (Response) {}
rpc GetAll(Request) returns (Response) {}
rpc Auth(User) returns (Token) {}
rpc ValidateToken(Token) returns (Token) {}
}

message User {
string id = 1;
string name = 2;
string company = 3;
string email = 4;
string password = 5;
}

message Request {}

message Response {
User user = 1;
repeated User users = 2;
repeated Error errors = 3;
}

message Token {
string token = 1;
bool valid = 2;
repeated Error errors = 3;
}

message Error {
int32 code = 1;
string description = 2;
}

  • user-service/handler.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// user-service/handler.go
package main

import (
"errors"
"fmt"
"log"

pb "github.com/EwanValentine/shippy-user-service/proto/auth"
micro "github.com/micro/go-micro"
"golang.org/x/crypto/bcrypt"
"golang.org/x/net/context"
)

const topic = "user.created"

type service struct {
repo Repository
tokenService Authable
Publisher micro.Publisher
}

func (srv *service) Get(ctx context.Context, req *pb.User, res *pb.Response) error {
user, err := srv.repo.Get(ctx, req.Id)
if err != nil {
return err
}
res.User = user
return nil
}

func (srv *service) GetAll(ctx context.Context, req *pb.Request, res *pb.Response) error {
users, err := srv.repo.GetAll(ctx)
if err != nil {
return err
}
res.Users = users
return nil
}

func (srv *service) Auth(ctx context.Context, req *pb.User, res *pb.Token) error {

// 对请求进行哈希
log.Println("Logging in with:", req.Email, req.Password)
user, err := srv.repo.GetByEmail(req.Email)
if err != nil {
return err
}
log.Println(user)

// 验证密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return err
}

token, err := srv.tokenService.Encode(user)
if err != nil {
return err
}
res.Token = token
return nil
}

func (srv *service) Create(ctx context.Context, req *pb.User, res *pb.Response) error {

log.Println("Creating user: ", req)

// 对密码进行哈希
hashedPass, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("error hashing password: %v", err)
}
req.Password = string(hashedPass)

// 新的 publisher 代码更简洁
if err := srv.repo.Create(req); err != nil {
return fmt.Errorf("error creating new user: %v", err)
}
res.User = req
if err := srv.Publisher.Publish(ctx, req); err != nil {
return fmt.Errorf("error publishing event: %v", err)
}
return nil
}


func (srv *service) ValidateToken(ctx context.Context, req *pb.Token, res *pb.Token) error {

// Decode token
claims, err := srv.tokenService.Decode(req.Token)
if err != nil {
return err
}

log.Println(claims)

if claims.User.Id == "" {
return errors.New("invalid user")
}

res.Valid = true

return nil
}

运行

现在可以使用 $ make build && make run 来运行 user-serviceemail-service 了,并且此时还需要执行:

1
2
3
4
5
6
$ docker run -p 8080:8080 \ 
-e MICRO_REGISTRY=mdns \
microhq/micro api \
--handler=rpc \
--address=:8080 \
--namespace=shippy

这行命令在本机的 8080 端口上运行了一个 micro api-gateway 的容器来处理 RPC 请求,并且规定了容器使用 mdns 作为本地的注册地址,这个和其他容器都是一样的。

最后,我们告诉容器使用 shippy 的名称空间 (namespace) ,这是我们所有服务名称的开头部分,如 shippy.auth 或者 shippy.email 等。这个设置非常重要,否则所有服务都会使用默认命名空间 go.micro.api ,这会使我们无法找到服务来代理。

####创建用户

创建用户:

1
$ curl -XPOST -H 'Content-Type: application/json' -d '{ "service": "shippy.user", "method": "Auth.Create", "request": { "user": { "email": "ewan.valentine89@gmail.com", "password": "testing123", "name": "Ewan Valentine", "company": "BBC" } } }' http://localhost:8080/rpc

译者注:

在本地直接运行 micro 也是可以的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ go get github.com/micro/micro  # 注意为 v1 版本

$ MICRO_API_HANDLER=rpc \
MICRO_API_NAMESPACE=shippy \
MICRO_API_REGISTRY=mdns \
MICRO_API_ENABLE_RPC=true \
MICRO_API_ADDRESS=":8080" \
micro api

# 容器运行
$ docker run -p 8080:8080 \
-e MICRO_REGISTRY=mdns \
-e MICRO_API_NAMESPACE=shippy \
-e MICRO_API_ENABLE_RPC=true \
-e MICRO_API_ADDRESS=":8080" \
microhq/micro api \
--handler=rpc \
--address=:8080 \
--namespace=shippy

看起来这样写很诡异,但是确实只有这样才能正常运行。因为经验欠缺,且 micro 错误难以定位,找出这个问题花费了大量的时间。

userservice

如你所见,在请求中包含了我们想要路由的服务、服务中我们想要用的方法,以及我们希望用到的数据。

认证用户:

1
2
3
$ curl -XPOST -H 'Content-Type: application/json' \ 
-d '{ "service": "shippy.auth", "method": "Auth.Auth", "request": { "email": "your@email.com", "password": "SomePass" } }' \
http://localhost:8080/rpc

创建订单

再次运行 consignment-service ,这里无需修改任何代码:

1
$ make build && make run

创建 consignment

1
2
3
4
5
6
7
8
9
10
$ curl -XPOST -H 'Content-Type: application/json' \ 
-d '{
"service": "shippy.consignment",
"method": "ConsignmentService.Create",
"request": {
"description": "This is a test",
"weight": "500",
"containers": []
}
}' --url http://localhost:8080/rpc

consignment

创建货船

同样的,在 vessel-service 中:

1
$ make build && make run

用户界面

接下来我们会用 React 来构建用户 UI 界面 (其实可以不管用什么前端工具都可以,只要发出的请求是一样的就行)。我们这里就直接使用 Facebook 的 react-create-app 库:

1
2
3
$ npm install -g react-create-app

$ react-creat-app shippy-ui

你会直接生成一个 React 应用的脚手架,填充自己的代码.


App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// shippy-ui/src/App.js
import React, { Component } from 'react';
import './App.css';
import CreateConsignment from './CreateConsignment';
import Authenticate from './Authenticate';

class App extends Component {

state = {
err: null,
authenticated: false,
}

onAuth = (token) => {
this.setState({
authenticated: true,
});
}

renderLogin = () => {
return (
<Authenticate onAuth={this.onAuth} />
);
}

renderAuthenticated = () => {
return (
<CreateConsignment />
);
}

getToken = () => {
return localStorage.getItem('token') || false;
}

isAuthenticated = () => {
return this.state.authenticated || this.getToken() || false;
}

render() {
const authenticated = this.isAuthenticated();
return (
<div className="App">
<div className="App-header">
<h2>Shippy</h2>
</div>
<div className='App-intro container'>
{(authenticated ? this.renderAuthenticated() : this.renderLogin())}
</div>
</div>
);
}
}

export default App;

用户认证组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
// shippy-ui/src/Authenticate.js
import React from 'react';

class Authenticate extends React.Component {

constructor(props) {
super(props);
}

state = {
authenticated: false,
email: '',
password: '',
err: '',
}

login = () => {
fetch(`http://localhost:8080/rpc`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
request: {
email: this.state.email,
password: this.state.password,
},
service: 'shippy.auth',
method: 'Auth.Auth',
}),
})
.then(res => res.json())
.then(res => {
this.props.onAuth(res.token);
this.setState({
token: res.token,
authenticated: true,
});
})
.catch(err => this.setState({ err, authenticated: false, }));
}

signup = () => {
fetch(`http://localhost:8080/rpc`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
request: {
email: this.state.email,
password: this.state.password,
name: this.state.name,
},
method: 'Auth.Create',
service: 'shippy.auth',
}),
})
.then((res) => res.json())
.then((res) => {
this.props.onAuth(res.token.token);
this.setState({
token: res.token.token,
authenticated: true,
});
localStorage.setItem('token', res.token.token);
})
.catch(err => this.setState({ err, authenticated: false, }));
}

setEmail = e => {
this.setState({
email: e.target.value,
});
}

setPassword = e => {
this.setState({
password: e.target.value,
});
}

setName = e => {
this.setState({
name: e.target.value,
});
}

render() {
return (
<div className='Authenticate'>
<div className='Login'>
<div className='form-group'>
<input
type="email"
onChange={this.setEmail}
placeholder='E-Mail'
className='form-control' />
</div>
<div className='form-group'>
<input
type="password"
onChange={this.setPassword}
placeholder='Password'
className='form-control' />
</div>
<button className='btn btn-primary' onClick={this.login}>Login</button>
<br /><br />
</div>
<div className='Sign-up'>
<div className='form-group'>
<input
type='input'
onChange={this.setName}
placeholder='Name'
className='form-control' />
</div>
<div className='form-group'>
<input
type='email'
onChange={this.setEmail}
placeholder='E-Mail'
className='form-control' />
</div>
<div className='form-group'>
<input
type='password'
onChange={this.setPassword}
placeholder='Password'
className='form-control' />
</div>
<button className='btn btn-primary' onClick={this.signup}>Sign-up</button>
</div>
</div>
);
}
}

export default Authenticate;

货物托运组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
// shippy-ui/src/CreateConsignment.js
import React from 'react';
import _ from 'lodash';

class CreateConsignment extends React.Component {

constructor(props) {
super(props);
}

state = {
created: false,
description: '',
weight: 0,
containers: [],
consignments: [],
}

componentWillMount() {
fetch(`http://localhost:8080/rpc`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
service: 'shippy.consignment',
method: 'ConsignmentService.Get',
request: {},
})
})
.then(req => req.json())
.then((res) => {
this.setState({
consignments: res.consignments,
});
});
}

create = () => {
const consignment = this.state;
fetch(`http://localhost:8080/rpc`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
service: 'shippy.consignment',
method: 'ConsignmentService.Create',
request: _.omit(consignment, 'created', 'consignments'),
}),
})
.then((res) => res.json())
.then((res) => {
this.setState({
created: res.created,
consignments: [...this.state.consignments, consignment],
});
});
}

addContainer = e => {
this.setState({
containers: [...this.state.containers, e.target.value],
});
}

setDescription = e => {
this.setState({
description: e.target.value,
});
}

setWeight = e => {
this.setState({
weight: Number(e.target.value),
});
}

render() {
const { consignments, } = this.state;
return (
<div className='consignment-screen'>
<div className='consignment-form container'>
<br />
<div className='form-group'>
<textarea onChange={this.setDescription} className='form-control' placeholder='Description'></textarea>
</div>
<div className='form-group'>
<input onChange={this.setWeight} type='number' placeholder='Weight' className='form-control' />
</div>
<div className='form-control'>
Add containers...
</div>
<br />
<button onClick={this.create} className='btn btn-primary'>Create</button>
<br />
<hr />
</div>
{(consignments && consignments.length > 0
? <div className='consignment-list'>
<h2>Consignments</h2>
{consignments.map((item) => (
<div>
<p>Vessel id: {item.vessel_id}</p>
<p>Consignment id: {item.id}</p>
<p>Description: {item.description}</p>
<p>Weight: {item.weight}</p>
<hr />
</div>
))}
</div>
: false)}
</div>
);
}
}

export default CreateConsignment;

注意:我在 public/index.html 中也加入了 Twitter 的 Bootstrap,包括一些可用的 css 文件,见仓库.

现在执行 $ npm start 应该可以浏览器自动打开这个页面,现在就可以通过 web 页面直接注册、登陆以及新鲜货运订单了。

注意在开发者模式中观察一下前端是如何远程调用方法并且从不同的微服务中获取数据的。

总结

好的,这就是第六节的所有内容了!

任何漏洞,错误或者反馈,欢迎你通过邮件[告诉我](mailto: ewan.valentine89@gmail.com)。


觉得这系列文章对您有帮助,可以请原作者喝杯咖啡,Cheers! https://monzo.me/ewanvalentine.

或者通过 Patreon 来支持原作者。