租辆酷车小程序开发(三)—— 微服务与存储设计

微服务

打通GRPC后我们需要划分后端的微服务
传统模式是一整个大的服务,微服务是将其划分为多个小的服务,特性如下:

  • 快速开发/迭代
  • 自动化部署
  • 独立部署
  • 错误隔离
  • 负载均衡
  • 出错处理
  • 链路跟踪
  • 服务发现
  • 服务治理
  • 动态扩容
  • 熔断,降级

微服务的初衷是想实现快速开发、独立部署和错误隔离,但是将一个服务拆分为多个微服务会提高系统的复杂性,因此需要自动化部署、负载均衡、出错处理、链路跟踪、服务发现、服务治理,在此基础上可以相对方便的实现动态扩容和熔断降级

微服务划分

微服务的划分:分的不够细等服务庞大后再分就比较困难了,分的太细了后续再整合服务也很困难,划分步骤:

  1. 划分领域
  2. 确定上下文边界
  3. 上下文映射
  4. 确定微服务边界

租辆酷车微服务划分,每个虚框是一个微服务
在这里插入图片描述

登陆服务

删除server下的文件只保留go.mod和go.sum,新建auth目录
小程序登录文档:https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/login.html

开发者服务器:就是我们要做的登录微服务
appid:建立小程序的id
appid+appsecret:认证开发者
openId:针对小程序,每个用户的openId都不同
unionId:针对开发者或企业,同一个开发者的所有小程序中的同一个用户的unionId都相同
自定义登录态:用户token,通过token可以查询用户的openid,token可以过期,过期之后需要重新获取code

登录微服务就是实现这个流程

编写proto生成代码


首先要先写proto,写完proto才能生成前后端的代码
auth目录下新建api目录,api下新建auth.proto

  • code:登录请求的code
  • access_token:返回给用户的token
  • expires_in:token过期时间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
syntax = "proto3";
package auth.v1;
option go_package = "coolcar/auth/api/gen/v1;authpb";

message LogiRequest {
string code = 1;
}

message LoginResponse {
string access_token = 1;
int32 expires_in = 2;
}

service authService{
rpc Login (LoginRequest) returns (LoginResponse);
}

api目录下新建auth.yaml
body: “*” 表示body中会携带我们的请求(code)

1
2
3
4
5
6
7
8
type: google.api.service
config_version: 3

http:
rules:
- selector: auth.v1.authService.Login
post: /v1/auth/login
body: "*"

server目录下新建脚本执行;
(注意这里有一个坑,我用的是windows系统在使用以下命令生成js文件首部添加’import…’时再生成ts文件会报错,考虑是乱码问题,这里可以直接用生成的js文件生成ts文件,再手动添加import到js文件中;还有其他个别错误可以不使用变量直接把路径复制到命令中执行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$PROTO_PATH="./auth/api"
$GO_OUT_PATH="./auth/api/gen/v1"
mkdir -p $GO_OUT_PATH

protoc -I =$PROTO_PATH --go_out=plugins=grpc,paths=source_relative:$GO_OUT_PATH auth.proto
protoc -I =$PROTO_PATH --grpc-gateway_out=paths=source_relative,grpc_api_configuration=$PROTO_PATH/auth.yaml:$GO_OUT_PATH auth.proto

$PBTS_BIN_DIR="../wx/miniprogram/node_modules/.bin"
$PBTS_OUT_DIR="../wx/miniprogram/service/proto_gen/auth"
mkdir -p $PBTS_OUT_DIR
# 生成js文件
../wx/miniprogram/node_modules/.bin/pbjs -t static -w es6 $PROTO_PATH/auth.proto --no-create --no-encode --no-decode --no-verify --no-delimited -o $PBTS_OUT_DIR/auth_pb_tmp.js
echo 'import * as $protobuf from "protobufjs";\n' > $PBTS_OUT_DIR/auth_pb.js
cat $PBTS_OUT_DIR/auth_pb_tmp.js >> $PBTS_OUT_DIR/auth_pb.js
rm $PBTS_OUT_DIR/auth_pb_tmp.js
# 生成ts文件
../wx/miniprogram/node_modules/.bin/pbts -o $PBTS_OUT_DIR/auth_pb.d.ts $PBTS_OUT_DIR/auth_pb.js

需要把auth_pb.js也交到代码库,修改gitignore
!**/wx/miniprogram/service/proto_gen/**/*.js


实现服务

在auth(微服务)目录下新建auth(处理逻辑)目录,再新建auth.go,实现AuthServiceServer

auth.pb.go中的代码如下:
在这里插入图片描述

实现接口:
在这里插入图片描述

使用zap打日志,go get go.uber.org/zap

auth.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package auth

import (
"context"
authpb "coolcar/auth/api/gen/v1"

"go.uber.org/zap"
)

type Service struct {
Logger *zap.Logger
}

func (s *Service) Login(c context.Context, req *authpb.LoginRequest) (*authpb.LoginResponse, error) {
s.Logger.Info("received code", zap.String("code", req.Code))
return &authpb.LoginResponse{AccessToken: "token for " + req.Code, ExpiresIn: 7200}, nil
}

启动grpc服务

auth微服务目录下新建main.go,启动一个grpc服务

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
package main

import (
authpb "coolcar/auth/api/gen/v1"
"coolcar/auth/auth"
"log"
"net"

"go.uber.org/zap"
"google.golang.org/grpc"
)

func main() {
logger, err := zap.NewDevelopment()
if err != nil {
log.Fatalf("cannot create logger: %v", err)
}

lis, err := net.Listen("tcp", ":8081")
if err != nil {
logger.Fatal("cannot listen", zap.Error(err))
}

s := grpc.NewServer()
authpb.RegisterAuthServiceServer(s, &auth.Service{Logger: logger})
err = s.Serve(lis)
logger.Fatal("cannot server", zap.Error(err))
}

启动grpc gateway服务

在server下新建gateway目录新建main.go,启动一个grpc gateway服务

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
package main

import (
"context"
authpb "coolcar/auth/api/gen/v1"
"log"
"net/http"

"github.com/grpc-ecosystem/grpc-gateway/runtime"
"google.golang.org/grpc"
)

func main() {
c := context.Background()
c, cancel := context.WithCancel(c)
defer cancel()

mux := runtime.NewServeMux(runtime.WithMarshalerOption(
runtime.MIMEWildcard, &runtime.JSONPb{
EnumsAsInts: true,
OrigName: true,
},
))
err := authpb.RegisterAuthServiceHandlerFromEndpoint(
c,
mux,
"localhost:8081",
[]grpc.DialOption{grpc.WithInsecure()},
)
if err != nil {
log.Fatalf("cannot register auth service: %v", err)
}
log.Fatal(http.ListenAndServe(":8080", mux))
}

小程序端发送请求

在小程序端app.ts发送登录请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
wx.login({
success: res => {
wx.request({
url: 'http://localhost:8080/v1/auth/login',
method: 'POST',
data: {
code: res.code,
} as auth.v1.ILoginRequest,
success: console.log,
fail: console.error,
})
// 发送 res.code 到后台换取 openId, sessionKey, unionId
},
})

成功将code发送到了对应的服务,并获得了返回
在这里插入图片描述
在这里插入图片描述
同时auth.Service也打出了日志
在这里插入图片描述

重新格式化一下success的日志,直接使用consolo.log的输出为:
在这里插入图片描述
格式化输出为:

1
2
3
4
success: res => {
const loginResp: auth.v1.ILoginResponse = auth.v1.LoginResponse.fromObject(camelcaseKeys(res.data as object))
console.log(loginResp)
},

在这里插入图片描述

获取openid

在这里插入图片描述
在服务实现中将其抽象为一个接口
在这里插入图片描述


实现接口
需要向微信接口发送请求获取openid,有两种方法,一种是使用go的httpclient直接发送请求(麻烦)
另一种使用微信go语言客户端,先安装第三方库go get github.com/medivhzhan/weapp/v2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package wechat

import (
"fmt"

"github.com/medivhzhan/weapp/v2"
)

type service struct {
Appid string
AppSecret string
}

func (s *service) Resolve(code string) (string, error) {
resp, err := weapp.Login(s.Appid, s.AppSecret, code)
if err != nil {
return "", fmt.Errorf("weapp.Login: %v", err)
}
if err := resp.GetResponseError(); err != nil {
return "", fmt.Errorf("weapp response error: %v", err)
}
return resp.OpenID, nil
}

appid:project.config.json中的appid
appsecret:在微信公众平台的开发管理中获取

但是我们不能直接将这个id传入Login方法,这样就直接写死了,需要进行配置化


接下来配置appid和secret
我们将appid定义在启动grpc服务的main.go文件中,这里面的值都是搭环境的一些代码,所有配置的参数我们在后期都会进行配置化通过命令行参数或环境变量的方法让外界进行提供。

AppSecret作为用户身份的象征不能明文存放,用session_key进行加密

在这里插入图片描述

4.3 push github


MongoDB数据库

sql vs no-sql

sql:MySQl,PostgreSQL
优点:

  • 成熟
  • 熟悉
  • 丰富的生态
  • 一致性保证(事务)

缺点:

  • 性能欠缺(保证一致性)
  • Object-Relational Mapping

用途:

  • 遗留系统
  • ToB系统

no-sql:MongoDB,HBase,ElasticSearch
MongoDB优点:

  • 以JSON文档的形式存储
  • 丰富的查询能力
  • 性能

MongoDB缺点:

  • 事务支持差
  • 不能Join

用途:

  • 快速开发
  • ToC系统
  • Serverless云开发的宠儿
    • Firebase
    • Leancloud
    • 腾讯云开发

使用docker启动MongoDB的启动

拉取镜像:docker pull ccr.ccs.tencentyun.com/coolcar/coolenv:latest

启动容器:docker run -p 18000:18000 -p 27017:27017 -e ICODE=J3295B0D5848C6421 ccr.ccs.tencentyun.com/coolcar/coolenv

(这里的ICODE是慕课网的动态验证码,这里拉取的是课程提供的镜像)

安装vscode插件

添加MongoDB的连接

可以新建一个playground执行测试一下
新建了一个test数据库,向sales表插入了一些数据,又从sales中查询了一些数据,又执行了一个聚合查询
这里插入的数据为json形式,没有任何具体的定义,只要是json文档就能存

这里每次操作前都需要使用use()选择数据库,可以使用copy connect string,删除这个链接再建立通过connection string连接,在之后加上我们要操作的数据库名,在这个连接操作就无需每次都选择数据库


MongoDB的CRUD操作

概念:

  • database:数据库
  • collection:表

插入

insertOne向数据库的account表插入一条记录,格式为json文件,其中_id是记录的id不能重复,如果不设置的话会随机给一个值

1
2
3
4
5
db.account.insertOne({
_id: "user456",
open_id: "123",
login_count: 0,
})

insertMany插入多条记录

1
2
3
4
5
6
7
db.account.insertMany([{
open_id: "456",
login_count: 0,
}, {
open_id: "789",
login_count: 0,
}])

删除

deleteOne删除_id为user456的记录

1
2
3
db.account.deleteOne({
_id: "user456"
})

查询

find返回符合条件的所有记录的数组

查找account表下的所有记录

1
db.account.find()

查找_id为”6618f503dcadf1d9f122ca46”的记录

1
2
3
db.account.find({
"_id":ObjectId("6618f503dcadf1d9f122ca46")
})

查找login_count为0的所有记录

1
2
3
db.account.find({
"login_count": 0
})

条件查找:查找login_count>3的记录

1
2
3
db.account.find({
login_count: { $gt: 3 }
})

查找login_count>3且open_id=”456”的记录

1
2
3
4
db.account.find({
login_count: { $gt: 3 },
open_id: "456"
})

查找login_count>3且open_id=”456” 或 login_count=0的记录

1
2
3
4
5
6
7
8
9
10
11
db.account.find({
$or: [
{
login_count: { $gt: 3 },
open_id: "456"
},
{
login_count: 0
}
]
})

查找profile字段下的age<=30的记录

1
2
3
db.account.find({
"profile.age": { $lte: 30 }
})

修改

update
第一个参数是查找的条件
第二个参数是update的字段
第三个参数是update的参数(可选)

将id为”6618f54b01dafe9c5db184da”的记录的login_count设置为1

1
2
3
4
5
6
7
8
db.account.update({
_id: ObjectId("6618f54b01dafe9c5db184da")

}, {
$set: {
login_count:1
}
})

不存在的字段会直接添加到记录中

1
2
3
4
5
6
7
8
9
10
11
db.account.updateOne({
_id: ObjectId("661a2aab836f6bc6d136b45e")
}, {
$set: {
profile: {
name: 'abc',
age: 28,
photo_url: 'https://example.com/123'
}
}
})

原子性问题:假设我们要对login_count+1

实现login_count+1

1
2
3
4
5
6
7
8
9
10
11
account = db.account.findOne({
_id: ObjectId("661a2aab836f6bc6d136b45e")
})

db.account.updateOne({
_id: ObjectId('661a2aab836f6bc6d136b45e')
}, {
$set: {
login_count: account.login_count + 1
}
})

此时如果两个人同时运行,最终的结果可能是login_count只加了1,该问题牵涉到了原子操作

MongoDB也有对事务的支持但是使用不多,如果需要频繁使用那还不如使用关系型数据库

使用$inc:{login_count:1}实现login_count+1,这里对一个记录的操作是原子性的

1
2
3
4
5
6
7
db.account.updateOne({
_id: ObjectId('661a2aab836f6bc6d136b45e')
}, {
$inc: {
login_count: 1
}
})

建立索引

根据profile.age建立索引,1是从小到大,-1是从大到小

1
2
3
db.account.createIndex({
"profile.age":1
})

MongoDB Playground 模拟用户登录

根据openid获取_id

先清空表:db.account.drop()

如果没有找到openid那么需要新插入此openid,并且返回_id
upsert:没有找到则插入新记录
new:返回更新后的数据

使用update也可以实现与findAndModify相同的功能但是update不会返回结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function resolveOpenID(open_id) {
return db.account.findAndModify({
query: {
open_id: open_id
},
update: {
$set: { open_id: open_id }
},
upsert: true,
new: true,
})
}

resolveOpenID('123')

原子性:MongoDB对单个记录是具有原子性的,但是如果没有找到记录那么该操作的原子性是针对upsert插入的新记录而言的,如果两个人同时插入两条不同的记录没有打破原子性,但是会有两条记录产生。解决方法是建立索引unique,此时无法插入相同的openid

1
2
3
4
5
db.account.createIndex({
open_id: 1
}, {
unique: true
})

使用Go语言操作MongoDB

拉取go语言客户端:go get go.mongodb.org/mongo-driver/mongo

初始化

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
c := context.Background()
// 建立数据库连接
mc, err := mongo.Connect(c, options.Client().ApplyURI("mongodb://localhost:27017/coolcar"))
if err != nil {
panic(err)
}
// 获取操作表
col := mc.Database("coolcar").Collection("account")
insertRows(c, col)
findRows(c, col)
}

插入

bson:MongoDB的内部二进制数据格式;.M相当于map

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func insertRows(c context.Context, col *mongo.Collection) {
// 插入数据
res, err := col.InsertMany(c, []interface{}{
bson.M{
"open_id": "123",
},
bson.M{
"open_id": "456",
},
})
if err != nil {
panic(err)
}
fmt.Printf("%+v", res)
}

执行结果:

查找

FindOne返回的值是指针,需要解码
字段名首字母需要大写
`bson:”_id”`表示将真实的字段_id解码为ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func findRows(c context.Context, col *mongo.Collection) {
res := col.FindOne(c, bson.M{
"open_id": "123",
})

var row struct {
ID primitive.ObjectID `bson:"_id"`
OpenID string `bson:"open_id"`
}
err := res.Decode(&row)
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", row)
}

执行结果:

查找多个记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func findRows(c context.Context, col *mongo.Collection) {
cur, err := col.Find(c, bson.M{})
if err != nil {
panic(err)
}
for cur.Next(c) {
var row struct {
ID primitive.ObjectID `bson:"_id"`
OpenID string `bson:"open_id"`
}
err := cur.Decode(&row)
if err != nil {
panic(err)
}
fmt.Printf("%+v\n", row)
}
}

执行结果:

实现微信登录数据绑定

将模拟用户登录中的resolveOpenID翻译为go语言代码
在auth/auth/auth.go中将获取到的openId转换为accountid
一般不在服务的实现中直接操作数据库,会做一个数据访问层
auth目录下新建/server/auth/dao目录,目录下新建mongo.go
col小写,不希望外界直接初始化,希望外界传入一个database

注意此处责任的划分,在这个文件中我们不知道具体的数据库是哪个,具体的数据库由外层main函数指定,该文件只负责这个数据库下的account表

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
package dao

import (
"context"
"fmt"

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)

// Mongo defines a mongo dao.
type Mongo struct {
col *mongo.Collection
}

// NewMongo creates a new mongo dao.
func NewMongo(db *mongo.Database) *Mongo {
return &Mongo{
col: db.Collection("account"),
}
}

// ResolveAccountID resolves an account id from open id.
func (m *Mongo) ResolveAccountID(c context.Context, openID string) (string, error) {
res := m.col.FindOneAndUpdate(c, bson.M{
"open_id": openID,
}, bson.M{
"$set": bson.M{
"open_id": openID,
},
}, options.FindOneAndUpdate().
SetUpsert(true).
SetReturnDocument(options.After))
if err := res.Err(); err != nil {
return "", fmt.Errorf("cannot findOneAndUpdate: %v", err)
}
var row struct {
ID primitive.ObjectID `bson:"_id"`
}
err := res.Decode(&row)
if err != nil {
return "", fmt.Errorf("cannot decode result: %v", err)
}
return row.ID.Hex(), nil
}

测试代码

测试代码的正确性,分为测试和联调,联调需要启动各种服务环境代价较大,我们可以先自己测试下,新建mongo_test.go
命名一般就是被测试文件名_test,包名与被测包一致,目录与被测文件一致

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
package dao

import (
"context"
"testing"

"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)

func TestResolveAccountID(t *testing.T) {
c := context.Background()
mc, err := mongo.Connect(c, options.Client().ApplyURI("mongodb://localhost:27017/coolcar"))
if err != nil {
t.Fatalf("cannot connect mongodb: %v", err)
}
m := NewMongo(mc.Database("coolcar"))
id, err := m.ResolveAccountID(c, "123")
if err != nil {
t.Errorf("faild resolve account id for 123: %v", err)
} else {
// 通过查询数据库可得123的正确返回值
want := "661b62391e93df8d38c44adc"
// %q会在字符串拼接的时候加引号
if id != want {
t.Errorf("resolve account id: want: %q, got:%q", want, id)
}
}
}

执行结果表示测试成功:

联调代码

服务:在auth.go中定义数据库操作

接入数据库:在main.go中接入数据库

启动auth和grpcgateway服务
在小程序发送请求

查询mongodb数据库

可以看到open_id由小程序计算得出并且返回了对应的_id
再重新刷新login返回的还是相同的记录,不会再新增加一条记录

功能已经实现了,但是代码质量不高还存在$set,open_id等没有实现强类型化,同时测试代码也写得不好

作者

ShiHaonan

发布于

2024-04-06

更新于

2024-04-14

许可协议

评论