尝试在一周内学习 c++、grpc、bazel、docker compose、google test、gmock,然后实现一个单终端登录系统。
需求说明:
设计一套单终端登录系统
1. 具备注册登录功能
2. 一个用户只能在一个设备上登录,切换终端登录时,其他已登录的终端会被踢出
grpc
bazel
Bazel: Building a C++ Project
docker 莱鸟教程
docker
项目的大致流程
思路很清晰,而现实很残酷,grpc 下载慢(多下几次),编译源码蹦出来各种坑(主要是依赖库没下好)。
嘴上一直说着不弄了,还好身体很诚实,一直坐着敲敲,终于整出了一个振奋人心的 “Hello world”。
用户表
CREATE TABLE `user` (
`id` bigint(20) NOT NULL COMMENT '主键id',
`mobile` varchar(255) NOT NULL COMMENT '手机号,用于登录',
`password` varchar(32) DEFAULT NULL COMMENT '加密密码',
`create_time` datetime(0) NULL COMMENT '创建时间',
`update_time` datetime(0) NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE INDEX `phone_idx`(`phone`) USING HASH,
INDEX()
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
token表
CREATE TABLE `billing`.`Untitled` (
`id` bigint(20) NOT NULL COMMENT '主键id',
`phone` varchar(255) NULL COMMENT '手机号',
`token` varchar(255) NULL COMMENT '加密后的 token',
`status` tinyint(3) NULL COMMENT '状态:0生效 1失效',
`expire_time` datetime(0) NULL COMMENT '过期时间',
`device` varchar(255) NULL COMMENT '设备号',
`create_time` datetime(0) NULL COMMENT '创建时间',
`update_time` datetime(0) NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE INDEX `phone_idx`(`phone`) USING HASH,
INDEX()
);
参考:常见web安全问题
Even though users may use strong passwords, attackers might be able to eavesdrop on their connections. Use HTTPS to avoid sending passwords (or any other sensitive data) over plain HTTP connections because they will be vulnerable to password sniffing.
使用 HTTPS 替代 HTTP,HTTPS 比 HTTP 多了一层 SSL 安全协议,其主要任务是负责压缩和加密。
PBKDF2 算法与 SHA256 哈希结合,这是 NIST 推荐的密码扩展机制。PBKDF2 算法通过多次 hash 来对密码进行加密。原理是通过 password 和 salt 进行 hash,然后将结果作为 salt 再与 password 进行 hash,多次重复此过程,生成最终的密文。
DK = PBKDF2(PRF, Password, Salt, c, dkLen)
PRF 是一个伪随机函数,可以简单的理解为 Hash 函数,它会输出固定长度的字符串。
Password 是用来生成密钥的原文密码。
Salt 是一个加密用的盐值。
c 是进行重复计算的次数。
dkLen 是期望得到的密钥的长度。
DK 是最后产生的密钥。
安全散列算法(Secure Hash Algorithm,缩写为SHA)是一个密码哈希函数家族,是FIPS所认证的安全散列算法。能计算出一个数字消息所对应到的,长度固定的字符串(又称消息摘要)的算法。
SHA家族:SHA-1(摘要长度为 160),SHA-2(SHA-224 、SHA-256、SHA-384、SHA-512),其中 224,256,384,512 是生成的消息摘要的长度。
SHA256算法原理详解
sha256的C++实现
(图片来源)
// 计算次数
#define COUNT = 2000;
// 最终加密密码长度
#define LENGTH = 32;
// 待加密密码
std::string password = "123456";
// 随机盐值
std::string salt = Encrypt::randomSalt();;
// 初始化Sha256对象
mySHA::Sha256 sha256;
// 经SHA256加密后的消息摘要
std::string message_digest = sha256.getHexMessageDigest(message);
// 初始化 Pbkdf2 对象
myPbkdf2::Pbkdf2 pbkdf2;
std::string dk = pbkdf2.getDk(message_digest,password,password,salt,COUNT,LENGTH);
账号每次登录成功后,给客户端下发一个 token,用于后续的接口调用。服务端通过对 token 进行校验来对用户进行认证。
token:对 phone 进行哈希,返回 32 位的字符串,返回客户端,再用 PBKDF2 算法加密保存到数据库。
通过 gRPC 框架的 stream 机制来实现下线通知的推送。
普通的 gRPC 是直接返回一个 Reply
对象,而流式响应可以通过方法返回多个 HelloReply
对象,对象流序列化后流式返回。
客户端发起一次普通的 RPC 请求,服务端通过流式响应,多次发送数据集。
客户端通过流式发起多次 RPC 请求给服务端,服务端发起一次响应给客户端
客户端以流式的方式发起请求,服务端同样以流式的方式响应请求。
基于服务端流式 RPC,向客户端推送下线通知。
通过 protobuf 定义客户端和服务端交换的数据格式,以及服务端与客户端之间的 RPC 调用接口。如下:user.proto
syntax = "proto3";
package user;
service User {
rpc SignUp(UserInfo) returns (UserReply) {}
rpc SignIn(UserInfo) returns (UserReply) {}
}
message UserInfo {
string phone = 1;
string password = 2;
String device = 3;
}
message UserReply {
int32 err = 1;
string msg = 2;
Data data = 3;
}
message Data {
string token = 1;
}
通过 protoc 工具生成客户端和服务端代码
protoc -I=./ --cpp_out=./ user.proto
protoc -I=./ --grpc_out=./ --plugin=protoc-gen-grpc=`which grpc_cpp_plugin` user.proto
当前目录下会生成四个文件:
user.pb.cc:the header which declares your generated message classes
user.pb.h:which contains the implementation of your message classes
user.grpc.pb.cc:the header which declares your generated service classes
user.grpc.pb.h:which contains the implementation of your service classes
std::string SignUp(const std::string& mobile, const std::string& password, const std::string device) {
UserRequest request;
request.set_mobile(mobile);
// md5 第一次加密,结合盐值
std::string salt = Encrypt::randomSalt();
std::string pwdHash = Encrypt::md5(password + salt);
request.set_password(pwdHash);
request.set_device(device);
UserReply reply;
ClientContext context;
Status status = stub_->SignUp(&context, request, &reply);
if (status.ok()) {
return "sign up success";
} else {
// 返回服务端回传的错误信息
return message;
}
}
std::string SignIn(const std::string& mobile, const std::string& password, const std::string device) {
UserRequest request;
// 密码加密
request.set_mobile(mobile);
std::string salt = Encrypt::randomSalt();
std::string pwdHash = Encrypt::md5(password + salt);
request.set_password(pwdHash);
request.set_device(device);
UserReply reply;
ClientContext context;
Status status = stub_->SignIn(&context, request, &reply);
// 根据状态码返回不同的信息
if (status.ok()) {
return "sign in success";
} else {
// 返回服务端回传的错误信息
return message;
}
}
ServerResult SignUp(ServerContext* context, const UserRequest* request,
UserReply* reply) override {
UserDB userDb;
ServerResult result;
User user userDb->getUser(request->phone(),pwdHash);
if(user != null){
result->setErr(1);
result->sermessage("user is exit, please sign in");
}
// PBKDF2算法加密密码
// 根据 phone 生成 32 位的 token,回传客户端
// PBKDF2算法加密token保存到数据库
}
ServerResult SignIn(ServerContext* context, const UserRequest* request,
UserReply* reply) override {
UserDB userDb;
ServerResult result;
User user userDb->getUser(request->phone(),pwdHash);
// 服务端校验 phone 和 password 是否正确
// PBKDF2算法加密token再与 DB 中的 token 比较
// 比较设备号
if(device != ole_device){
// 启动下线通知
}
}
Bazel的编译基于工作区(workspace)。工作区是一个存放了所有源代码和 Bazel 编译输出文件的目录,是整个项目的根目录。指定一个目录为 Bazel 的工作区,就只要在该目录下创建一个空的WORKSPACE 文件即可。
WORKSPACE 文件 用于指定当前文件夹就是一个 Bazel 的工作区,所以 WORKSPACE 文件总是存在于项目的根目录下。此外,WORKSPACE 文件还用于引入其他文件系统或网络的第三方库(因此在不依赖第三方库构建时,WORKSPACE基本没用,可以看到官网 examples 中的 WORKSPACE 是个空文件)
外部依赖:在 WORKSPACE 中引入外部依赖
- local_repository:本地
- git_repository:git仓库
- http_archive:网络下载
本项目的 WORKSPACE 文件
# load加载http.bzl工具,用于以http_archive的方式进行依赖的下载
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive(
# 下载一个压缩格式的 Bazel 仓库,并解压出来,然后绑定使用。BUILD 文件根据 name 引用
name = "com_github_grpc_grpc",
urls = [
"https://github.com/grpc/grpc/archive/v1.30.0.tar.gz",
],
# 用来消除前缀目录
strip_prefix = "grpc-1.30.0",
)
load("@com_github_grpc_grpc//bazel:grpc_deps.bzl", "grpc_deps")
grpc_deps()
http_archive(
name = "com_google_protobuf",
strip_prefix = "protobuf-3.12.3",
# Protocol Buffer 是一种轻便高效的结构化数据存储格式,可以用于结构化数据串行化,适用于RPC数据交换格式
urls = ["https://github.com/protocolbuffers/protobuf/archive/v3.12.3.tar.gz"],
)
BUILD 文件用于告诉 Bazel 怎么构建项目,如果工作区中的一个目录包含 BUILD 文件,那么它就是一个 package。一个 BUILD 文件包含了几种不同类型的指令。其中最重要的是编译指令,用于指导 Bazel 编译。BUILD 文件中的每一条编译指令被称为一个 target,它指向一系列的源文件和依赖,一个 target 也可以指向别的 target。
cc_binary(
# target 的名称
name = "user_client",
# 源文件
srcs = ["user_client.cpp",
"user_client.hpp",
"user.grpc.pb.cc",
"user.pb.cc",
"user.grpc.pb.h",
"user.pb.h"],
deps = ["@com_github_grpc_grpc//:grpc++", "@com_github_grpc_grpc//:grpc_plugin_support"]
)
cc_binary(
name = "user_server",
srcs = ["user.server.hpp",
"user_server.cpp",
"user.grpc.pb.cc",
"user.pb.cc",
"user.grpc.pb.h",
"user.pb.h"],
includes = ["grpc/", "./"],
deps = ["@com_github_grpc_grpc//:grpc++",
"@com_github_grpc_grpc//:grpc_plugin_support",
"@mysql_connector//:mysql_connector"]
)
首先进入到 user 目录下,执行一下命令
编译客户端
bazel build //main:user_client
编译服务端
bazel build //main:user_server
注意target中的//main:
是BUILD文件相对于WORKSPACE文件的位置,user-client 和 user-server
则是 BUILD 文件中命名好的 target 的名字。
一切都看似很正常,突然,bug 来了
error loading package '@com_github_grpc_grpc//': in /private/var/tmp/_bazel_vincentwen/617a9793a26c68f1cfb352d1cc00ccbb/external/com_github_grpc_grpc/bazel/grpc_build_system.bzl:
其中"…"表示编译该项目中的所有模块,包括依赖项目。
就有缘再来解决你了。
# 安装 tree
brew install tree
# 安装后在文件夹内执行
tree
Compose 是用于定义和运行多容器 Docker 应用程序的工具。通过 Compose,可以使用 YML 文件来配置应用程序需要的所有服务。然后,使用一个命令,就可以从 YML 文件配置中创建并启动所有服务。
Compose 使用的三个步骤:
使用 Dockerfile 定义应用程序的环境。
使用 docker-compose.yml 定义构成应用程序的服务,这样它们可以在隔离环境中一起运行。
最后,执行 docker-compose up 命令来启动并运行整个应用程序。
Dockerfile 文件
FROM ubuntu
ADD . /user_server
WORKDIR /user_server
RUN set -e; \
apt-get update; \
apt-get install -y curl gnupg; \
echo "deb [arch=amd64] https://storage.googleapis.com/bazel-apt stable jdk1.8" | tee /etc/apt/sources.list.d/bazel.list \
curl https://bazel.build/bazel-release.pub.gpg | apt-key add -; \
apt-get update; \
apt-get install -y bazel; \
apt-get install --only-upgrade -y bazel; \
bazel build ...; \
apt-get update; \
apt-get install build-essential;
Dockerfile 文件
FROM mysql
# ARGS
ARG CHANGE_SOURCE=false
# Change Timezone
ARG TIME_ZONE=UTC
ENV TIME_ZONE ${TIME_ZONE}
RUN ln -snf /usr/share/zoneinfo/$TIME_ZONE /etc/localtime && echo $TIME_ZONE > /etc/timezone
RUN apt-get -o Acquire::Check-Valid-Until=false update && apt-get -y upgrade
# Install Base Components
RUN apt-get install -y --no-install-recommends cron vim curl
version: '3'
services:
### user server container #########################################
server:
build: ./grpc/server
ports:
- "50051:50051"
depends_on:
- mysql
links:
- mysql:mymysql
restart: always
networks:
- default
### Mysql container #########################################
mysql:
build: ./mysql
ports:
- "3306:3306"
volumes:
- ./mysql/mysql.cnf:/etc/mysql/conf.d/mysql.cnf
privileged: true
environment:
- MYSQL_DATABASE=user
- MYSQL_USER=root
- MYSQL_PASSWORD=123456
- MYSQL_ROOT_PASSWORD=123456
restart: always
networks:
- default
通过 docker compose 启动
cd user
docker-compose up --build
通过 bazel 编译运行
cd user
bazel build ...
bazel-bin/main/user_client
正确流程:
1 用 bazel 编译 grpc 的 helloworld 项目,生成可执行文件
2 为 helloworld 的服务端创建镜像,部署到 docker,创建相应的容器
3 docker 启动 helloworld 的服务端容器,执行可执行文件
4 本地启动 helloworld 的客户端
上面调通后,开始加入注册登录逻辑,再一步步调试
踩坑
1 grpc 下载慢,依赖库过于庞大。bazel 编译 grpc项目不通过,占用了很久的时间,导致代码逻辑思考时间很少。
2 概念不清晰,要先弄清楚配置相互间的关系,便于改错,如 WORKSPACE 和 BUILD 还没理解透
3 本地编译失败后,部署到 docker 里编译,依旧编译失败
4 网络得好,不然疯狂踩坑