内容提要:API GATEWAY & UI
原作者:Ewan Valentine
原文链接:https://ewanvalentine.io/microservices-in-golang-part-6/
引言
前文中我们从事件驱动架构出发,关注了 go-micro 和原生 go 中事件驱动的一般实现。这一节中我们将从 web 端出发,探寻客户端与微服务交互的方法。
micro 工具集 已经为我们提供了 web 端服务从外部直接调用我们内部 rpc 的方法。
我们将为货流平台创建一个用户界面,包括登录页面、创建 consignment 的接口。通过这节的学习我们可以将之前的内容全部联系起来。
RPC 的“文艺复兴”
REST 在 web 服务中已经制霸了很多年了,从它出现之后,就迅速地成为了客户机与服务器之间资源管理的主要方式。REST 的出现结束了 RPC 与 SOAP 的“混乱之治”,结束了它们那套过时且让人痛苦的实现方法。
你有体验过写 wsdl
文件吗? 🙄️
REST
REST 向我们提供了一种简单实用且统一的管理资源的方法,它使用 http 动词 (Put, Post, Delete, Get) 来更加明确地描述正在执行的操作类型。REST 鼓励我们使用 http 错误代码来更好的描述来自服务器的响应。
在大多数情况下,这种方法都表现的很好,但是再好的东西也是优缺点的,REST 在一些方面收到很多人的抱怨,这里就不展开说了。
RPC
用于在应用程序和服务器之间传递数据的三种模型:
SOAP 模型(面向消息)
RPC 模型(面向方法)
REST 模型(面向资源)
SOAP + WSDL 因为过于麻烦,用的人已经很少了。而在一段时间内,都是 REST 在 web 领域独领风骚,不过借着微服务发展的顺风车,RPC 风格也开始渐渐复兴。
REST 风格在管理多种不同资源时有很好的效果,但是微服务就其本质而言,通常只处理单一资源,所以我们就不需要在微服务的上下文中使用 RESTful 术语,相应的,我们可以更多的关注具体的操作与每个服务间的交互。
Micro
在整个系列文章中,我们大量用到了 go-micro 微服务框架,现在我们将会接触到 micro cli/toolkit,micro 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
的部分代码,主要是错误处理和一些命名惯例。
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
| 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(
micro.Name("shippy.auth"), micro.Version("latest"), )
srv.Init()
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
| 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; }
|
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
| 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)
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 {
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-service
和 email-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
$ 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 错误难以定位,找出这个问题花费了大量的时间。
如你所见,在请求中包含了我们想要路由的服务、服务中我们想要用的方法,以及我们希望用到的数据。
认证用户:
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
|
创建货船
同样的,在 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
| 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
| 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
| 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 来支持原作者。