嘗試在一週內學習 c++、grpc、bazel、docker compose、google test、gmock,然後實現一個單終端登錄系統。
需求說明:
設計一套單終端登錄系統
1. 具備註冊登錄功能
2. 一個用戶只能在一個設備上登錄,切換終端登錄時,其他已登錄的終端會被踢出
1 部署環境,構思項目流程
項目的大致流程
- 部署 grpc,bazel
- 基於 grpc c++ 編寫命令行客戶端,採用 bazel 進行編譯
- 基於 grpc c++ 編寫服務端,採用 bazel 進行編譯
- 部署 docker,docker compose
- 創建 3 個鏡像,服務端 user_server、 mysql 和 redis,分別基於 user_server 鏡像 、mysq 鏡像和 redis 鏡像啓動容器
- 通過 bazel 編譯啓動客戶端 user_client
- docker compose 管理容器
然後現實總是很殘酷,grpc 下載慢(多下幾次),編譯源碼蹦出來各種坑(主要是依賴庫沒下好)。
嘴上一直說着不弄了,還好身體很誠實,一直坐着敲敲,終於整出了一個振奮人心的 “Hello world”。
2 用戶表設計
CREATE TABLE `user` (
`id` bigint(20) NOT NULL COMMENT '主鍵id',
`mobile` varchar(255) NOT NULL COMMENT '手機號,用於登錄',
`password` varchar(32) DEFAULT NULL COMMENT 'MD5(MD5(password明文+固定salt) + 隨機salt)',
`salt` varchar(10) DEFAULT NULL COMMENT '隨機鹽值,全局唯一',
`device` varchar(255) NOT NULL COMMENT '設備號',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3 安全性設計
參考:常見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 安全協議,其主要任務是負責壓縮和加密。
3.1 明文密碼兩次MD5處理
(1) 客戶端(第一次MD5)
用戶登錄時輸入明文密碼,生成一個固定 Salt,與該密碼拼裝,目的是用於混淆密碼。加固定鹽效果當然不如隨機鹽,但總比沒鹽好。然後進行第一次 MD5 處理,傳輸給服務器端。
password = MD5(明文密碼 + 固定Salt)
第一次 MD5 防止明文密碼在網絡傳輸時被盜。
String salt = "1a2b3c4d"
//明文密碼 + 固定Salt
String str = ""+salt.charAt(0)+salt.charAt(2) + inputPass +salt.charAt(5) + salt.charAt(4);
//客戶進行第一次MD5處理
String password = md5(str);
(2)服務端(第二次MD5)
服務器端接收到進行了第一次 MD5 處理後的密碼時,要生成一個隨機的 Salt,與密碼進行拼裝後再進行第二次 MD5,最後才寫入數據庫。隨機 Salt 要單獨存入數據表。
PASS = MD5(密碼 + 隨機Salt)
第二次 MD5 防止數據庫被入侵時,密碼被反查。
//第二次MD5,密碼+隨機Salt
String str = ""+salt.charAt(0)+salt.charAt(2) + formPass +salt.charAt(5) + salt.charAt(4);
4 單終端登錄控制
分佈式 session
SessionID 是容器生成的,多臺 應用服務器 就會有多個 SessionID,所以 SessionID 不易應用於解決分佈式 Session 共享問題。該項目決定使用全局唯一 id 作爲 token 來解決分佈式 session 問題。
用戶登錄
當用戶登錄成功時,服務端根據用戶的手機號 mobile 和設備號 device 生成一個 uuid,然後以 mobile 作爲 key,uuid 作爲 value 存入分佈式 redis;以 uuid 作爲 key, session 作爲 value 存入分佈式 redis。用戶每次登錄先通過 mobile 查詢 redis,如果能獲取到 uuid 且 uuid 不等於當前用戶的 uuid,要啓動強制下線,並下發下線通知,然後將當前用戶的 uuid 與 session 覆蓋上一次的值,保存到 redis。
強制下線通知
通過 gRPC 框架的 stream 機制來實現下線通知的推送。
5 項目的誕生
5.1 創建接口定義語言(interface definition language)
通過 protobuf 定義客戶端和服務端交換的數據格式,以及服務端與客戶端之間的 RPC 調用接口。如下:user.proto
syntax = "proto3";
package user;
service User {
rpc SignUp(UserInfo) returns (UserReply) {}
rpc SignIn(UserInfo) returns (UserReply) {}
}
message UserInfo {
string mobile = 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
5.2 客戶端僞代碼
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;
}
}
5.3 服務端僞代碼
ServerResult SignIn(ServerContext* context, const UserRequest* request,
UserReply* reply) override {
UserDB userDb;
ServerResult result;
// 根據手機號獲取 salt
String salt = userBb.getSalt(mobile);
// salt 與 第一次加密後的密碼 進行二次加密
std::string salt = Encrypt::randomSalt();
std::string pwdHash = Encrypt::md5(request->password() + salt);
User user userDb->getUser(request->mobile(),pwdHash);
if(user == null){
result->setErr(1);
result->sermessage("user is not exit, please sign up");
}
// 根據 mobie 生成 uuid
string uuid = EncryptUtil->getTokenForUser(mobile);
// 以 mobile 作爲 key,uuid 作爲 value 放入 redis,採用 string 數據類型
// 以 uuid 作爲 key, session 作爲 value 放入 redis,採用 hash 數據類型
}
ServerResult SignIn(ServerContext* context, const UserRequest* request,
UserReply* reply) override {
UserDB userDb;
ServerResult result;
// 根據手機號獲取 salt
String salt = userBb.getSalt(mobile);
// salt 與 第一次加密後的密碼 進行二次加密
std::string salt = Encrypt::randomSalt();
std::string pwdHash = Encrypt::md5(request->password() + salt);
User user userDb->getUser(request->mobile(),pwdHash);
if(user == null){
result->setErr(1);
result->sermessage("user is not exit, please sign up");
}
// 根據 mobie 生成 uuid
string uuid = EncryptUtil->getTokenForUser(mobile);
// 通過 mobile 查詢 redis
string old_uuid = redisString.get(mobile);
if(old_uuid != null && old_uuid != uuid){
// 啓動下線通知
}
// 以 mobile 作爲 key,uuid 作爲 value 放入 redis,採用 string 數據類型
// 以 uuid 作爲 key, session 作爲 value 放入 redis,採用 hash 數據類型
}
6 Bazel 構建項目
Bazel的編譯基於工作區(workspace)。工作區是一個存放了所有源代碼和 Bazel 編譯輸出文件的目錄,是整個項目的根目錄。指定一個目錄爲 Bazel 的工作區,就只要在該目錄下創建一個空的WORKSPACE文件即可。
Mac 生成項目樹形結構圖
# 安裝 tree
brew install tree
# 安裝後在文件夾內執行
tree
.
├── docker-compose.yml
├── grpc
│ ├── Dockerfile
│ ├── WORKSPACE
│ ├── client // 客戶端代碼
│ │ ├── src
│ │ │ ├── BUILD
│ │ │ ├── user_client.cpp
│ │ │ ├── user_client.hpp
│ │ │ └── utils
│ │ │ ├── EncryptUtil.cpp
│ │ │ ├── EncryptUtil.hpp
│ │ │ ├── MD5Util.cpp
│ │ │ └── MD5Util.hpp
│ │ ├── user.grpc.pb.cc
│ │ ├── user.grpc.pb.h
│ │ ├── user.pb.cc
│ │ └── user.pb.h
│ ├── protos // gRPC接口定義
│ │ └── user.proto
│ └── server // 服務端代碼
│ ├── Dockerfile
│ ├── src
│ │ ├── BUILD
│ │ ├── result
│ │ │ ├── ServerResult.cpp
│ │ │ └── ServerResult.hpp
│ │ ├── user_server.cpp
│ │ ├── user_server.hpp
│ │ └── utils
│ │ ├── EncryptUtil.cpp
│ │ ├── EncryptUtil.hpp
│ │ ├── MD5Util.cpp
│ │ └── MD5Util.hpp
│ ├── test
│ │ ├── BUILD
│ │ ├── serverTest.cpp
│ │ └── serverTest.hpp
│ ├── user.grpc.pb.cc
│ ├── user.grpc.pb.h
│ ├── user.pb.cc
│ └── user.pb.h
├── mysql
│ ├── Dockerfile
│ ├── UserDB.cpp
│ ├── UserDB.hpp
│ ├── init.sql
│ └── mysql.cnf
├── redis
├── Dockerfile
└── redis.conf
生成項目依賴圖
bazel query --nohost_deps --noimplicit_deps 'deps(//client/src:user-client)' \
--output graph
bazel query --nohost_deps --noimplicit_deps 'deps(//server/src:user-server)' \
--output graph
報錯了:找不到依賴包
6.1 WORKSPACE 文件
WORKSPACE 文件 用於指定當前文件夾就是一個 Bazel 的工作區,所以 WORKSPACE 文件總是存在於項目的根目錄下。
本項目的 WORKSPACE 文件
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"],
)
6.2 BUILD 文件
BUILD 文件用於告訴 Bazel 怎麼構建項目,如果工作區中的一個目錄包含BUILD文件,那麼它就是一個package。一個BUILD文件包含了幾種不同類型的指令。其中最重要的是編譯指令,它告訴 Bazel 如何編譯想要的輸出,比如可執行二進制文件或庫。BUILD文件中的每一條編譯指令被稱爲一個target,它指向一系列的源文件和依賴,一個 target 也可以指向別的 target。
舉個例子,下面這個hello-world的target利用了Bazel內置的cc_binary編譯指令,來從hello-world.cc源文件(沒有其他依賴項)構建一個可執行二進制文件。指令裏面有些屬性是強制的,比如name,有些屬性則是可選的,srcs表示的是源文件。
(1)客戶端的 BUILD
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"]
)
(2)服務端的 BUILD
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"]
)
6.3 編譯項目
首先進入到 user 目錄下,執行一下命令
編譯客戶端
bazel build //client:user-client
編譯服務端
bazel build //server:user-server
注意target中的//server:
是BUILD文件相對於WORKSPACE文件的位置,user-client 和 user-server
則是 BUILD 文件中命名好的 target 的名字。
7 Docker Compose 部署項目
Compose 是用於定義和運行多容器 Docker 應用程序的工具。通過 Compose,可以使用 YML 文件來配置應用程序需要的所有服務。然後,使用一個命令,就可以從 YML 文件配置中創建並啓動所有服務。
Compose 使用的三個步驟:
-
使用 Dockerfile 定義應用程序的環境。
-
使用 docker-compose.yml 定義構成應用程序的服務,這樣它們可以在隔離環境中一起運行。
-
最後,執行 docker-compose up 命令來啓動並運行整個應用程序。
7.1 服務端 user_server 鏡像
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 src:user_server; \
apt-get update; \
apt-get install build-essential;
7.2 mysql 鏡像
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
7.3 redis 鏡像
Dockerfile 文件
FROM redis
# 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
CMD [ "redis-server", "/usr/local/etc/redis/redis.conf" ]
7.4 docker-compose.yml
version: '3'
services:
### user server container #########################################
server:
build: ./grpc/server
ports:
- "50051:50051"
depends_on:
- mysql
- redis
links:
- mysql:mymysql
- redis:myredis
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
### Redis container #########################################
redis:
build: ./redis
ports:
- "6379:6379"
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf
restart: always
networks:
- default
8 項目的啓動
8.1 啓動服務端
通過 docker compose 啓動
cd user
docker-compose up --build
8.2 啓動客戶端
通過 bazel 編譯運行
bazel build src:user_client
bazel-bin/src/user_client
9 總結
由於沒學過 C++ ,它語法還是讓我有點難啃,所以部分代碼只能用僞代碼去編寫。雖然程序終究沒能運行起來,但是整個項目的結構是比較清晰的,每個技術間的關係與整合也弄明白了,還是一次很有意義的挑戰。
- 接觸新技術的時候,首先去官網看文檔,運行其最基本的例子。
- 去 github 找一些綜合的項目,有利於弄清楚每個技術怎樣在一個項目裏整合起來的。
- 結合相關的技術博文,繼續去看官網文檔,摳細節啦。