目录
系统说明
服务端的模块设计
存储数据说明
服务端设计-程序入口(main)
数据库的连接-mysql包的编写
数据的加密-hash文件的编写
数据传递格式-proto文件的编写
具体实现方法-controller包的编写
日志的打印-logs包的编写
扩展服务端
系统说明
首先说明该系统所使用的开发语言以及其他工具
- 该系统的开发使用的是GO语言,数据传输使用的是protobuf,具体涉及的部分是grpc的初步运用、简单的系统架构设计。
- 内置了基础的系统日志模块。
- 数据库的查询、插入操作
- hash的单向加密
技术栈:
类别 | 使用技术 |
---|---|
开发语言 | Go(Golang) |
通信协议 | gRPC(Protocol Buffers) |
数据库 | MySQL + GORM |
加密算法 | SHA256 |
并发模型 | goroutine + sync.WaitGroup |
日志记录 | 自定义 logs 模块 |
项目结构 | 模块化设计,分包实现 |
系统的用处:建立三个(或者多个)服务端,形成环形链路,以:本节点存储上一节点的某些数据,上一节点查询数据需要经过本节点数据的验证 为核心。
系统暂时只有插入以及查询操作,后续可能会扩展其他操作
可以设置多个服务端
我们主要讲解怎么写一个单独的服务端,以及如何扩展服务端的数量
服务端的模块设计
主要包括服务端的入口,控制中心,日志、数据库、proto、hash加密模块连接六个模块
每一个服务端都有四个功能且两两一组
- 请求的发送:1.发出查询请求给下一节点 2.发出保存请求给下一节点
- 请求的接收:1.接收上一节点发送的查询请求 2.接收上一节点发送的保存请求
这个照片里面缺少了hash的模块,因为截图的时候没截上。嘻嘻
存储数据说明
该系统理论上讲是可以根据自身需要进行数据存储的设计,也就是MySQL表如何设计。
在本系统内,我们存储的数据是
- id
- 公司名
- 市值
- 总资产
可以根据自身需要灵活更改
服务端设计-程序入口(main)
作为程序的开头,main函数的设计是非常重要的
当程序开始运行的时候我们首先调用一个
logs.Logger.Println("系统启动")
来把程序运行的事件保存到日志文件中。这个包是我们自己编写的,具体的编写在下面的:日志的打印-logs包的编写部分。
同时,程序具有以下几个功能:
- 请求的发送:1.发出查询请求给下一节点 2.发出保存请求给下一节点
- 请求的接收:1.接收上一节点发送的查询请求 2.接收上一节点发送的保存请求
我们把这四个功能封装成四个函数,GetSend(接收查询请求)GetSave(接收保存请求)Ping(发出查询请求)Save(发出保存请求)
因为接收请求的部分需要用到监听功能,且监听功能会阻塞函数的继续运行。所以我们这里需要用到的是groutine并发,同时为了防止协程没结束,主函数提前结束的情况我们需要使用到“等待组”
首先我们创建一个等待组,然后添加两个“等待”
var wg = sync.WaitGroup{}
wg.Add(2)
使用go关键字创建两个协程
go controller.GetSend(&wg)
go controller.GetSave(&wg)
这两个函数都是controller包下的函数,因为我们为了防止main函数过于臃肿。就把所有的操作函数封装到另一个包内,而且协程也是要结束的,结束的时候需要调用wg.Done()方法,所以我们把wg这个变量的地址作为实参传递,函数形参应该设置一个相同类型的指针接收(wg * sync.WaitGroup)
再接下来就是另外两个功能(发送请求)了,由于我们需要手动触发这两个功能,所以我们设置了一个for循环,循环变量为key(bool)初始设置为true,如果我们需要结束循环的时候就设置为false。
num := 0
key := true
var allmoney string//总资产
var nowmoney string//市值
var company string//公司名for key {......}
循环体我们需要设置一个switch,通过用户输入的选项决定是发出哪一个请求,又或者是否退出循环。
fmt.Println("请输入1.查询 2.保存")
fmt.Scanln(&num)
switch num {case 1:fmt.Println("请输入要查询的公司")fmt.Scanln(&company)controller.Ping(company)case 2:fmt.Println("输入公司名,市值,总资产:")_, err := fmt.Scanln(&company, &allmoney, &nowmoney) //输入的值用空格隔开if err != nil {return}controller.Save(company, allmoney, nowmoney)default:key = false}
这里的num、company、allmoney、nowmoney这四个变量都是我们提前声明好的。声明的位置最好是在for循环之前声明,主要是为了循环体的简便。
这样,就会根据我们输入的不同跳转到不同的模块了。并且从这里我们可以看出
查询的ping()方法需要传递一个公司名作为参数,保存的save()方法则是需要公司名、市值、总资产三个参数。
如果输入了不是1和2的其他数字,就会让key变为false,循环结束。
数据库的连接-mysql包的编写
数据库的连接我们单独写一个包的作用是为了方便给本项目所有的文件提供一个统一的数据库处理变量。
我们使用两个全局变量(首字母大写以便其他包访问),它们的类型是 *gorm.DB,也就是一个指针。
// CompanyTab 连接所属user的存储自身公司数据的库
var CompanyTab *gorm.DB// HashTab 连接到所属user存储的下一个user的hash数据的库
var HashTab *gorm.DB
然后由于我们需要一个存储本地数据的表,一个存储下一节点hash值的表。所以我们需要生成两个结构体,结构体使用结构体标签进行描述。
MyCompany是存储本地数据的,NextHash是存储下一公司的hash数据的。它们的ID字段我们在调用gorm的Creat方法的时候尽量不要写,因为它会自动补充。
type MyCompany struct { //存储本公司的数据ID uint `gorm:"primarykey"` //设置主键、自增CompanyName string `gorm:"type:varchar(255);unique"` //设置公司名,不能重复AllMoney string //设置资金NowMoney string
}
type NextHash struct { //存储下一公司的hash数据ID uint `gorm:"primarykey"` //设置唯一的idCompanyName string `gorm:"type:varchar(255);unique"`HashDataAllMoney string `gorm:"type:varchar(255)"` //设置对应的hash值,最大为255字符HashDataNowMoney string `gorm:"type:varchar(255)"`
}
然后我们创建一个函数,函数的作用是连接数据库
存储数据的格式也有了,下面就是连接数据库了
dns := "root:123456@tcp(127.0.0.1:3306)/表的名字"
我们先声明一个数据库的参数的变量,存储数据库的参数(用户、密码、主机地址、端口号、数据库的名称)注:数据库要提前建好,数据库的表可以不用提前建好。
然后我们使用gorm包里面的Open方法,它的参数是一个mysql.Open(dns)和&gorm.Config{}
gorm.Open(mysql.Open(dns), &gorm.Config{})
它会返回一个*DB类型的指针,以及一个错误信息。我们把这个指针存放在我们刚刚定义的两个变量上
// CompanyTable 提供CompanyTab变量的连接
func CompanyTable() {dns := "root:123456@tcp(127.0.0.1:3306)/company1" //连接到company1公司CompanyTab, _ = gorm.Open(mysql.Open(dns), &gorm.Config{})CompanyTab.AutoMigrate(&MyCompany{})fmt.Println("create table is true")
}// NextCompanyHash 提供HashTab变量的连接
func NextCompanyHash() {dns := "root:123456@tcp(127.0.0.1:3306)/company1" //连接到company1公司HashTab, _ = gorm.Open(mysql.Open(dns), &gorm.Config{})HashTab.AutoMigrate(&NextHash{})fmt.Println("create table is true")
}
但是这又导致一个小问题,就是我们导入的时候,虽然可以调用这两个变量,但是由于无法运行这两个函数,就会导致变量里面啥都没有。对此,我们需要用到init函数,这个函数的作用就是在这个包被导入的时候自动调用init函数,我们把上面两个函数的调用放入init函数即可
func init() { //重点,被导包的时候默认允许,执行两个*gorm.DB的初始化CompanyTable()NextCompanyHash()
}
数据的加密-hash文件的编写
我们对数据的加密使用了hash加密,这部分博主理解的比较浅,就不过多讲解了
他主要是接收一个字符串,然后把字符串通过hash加密之后得到一串字符,把字符返回给调用该函数的一方即可
package hashimport ("crypto/sha256""fmt"
)/*
这里我们使用一个hash算法进行所有数据的处理
a 处理的 hash值 传递给b ,需要时从b里面获取hash值,再从新计算一次a的hash值,一样说明数据无误
不一样说明出现问题作用函数1:被调用,参数是一个数据,以及一个hash值,如果相同则返回true否则返回false
作用函数2:接受一个值,返回一个hash值
*/func HashData(input string) string {// 创建一个新的 SHA256 哈希实例hash := sha256.New()// 将输入字符串转换为字节并写入哈希计算器hash.Write([]byte(input))// 计算哈希值并返回结果的十六进制表示hashValue := hash.Sum(nil)return fmt.Sprintf("%x", hashValue)
}
数据传递格式-proto文件的编写
我们再写proto文件的时候也要注意,根据我们的需求来写。
首先指明proto的版本,以及生成目录和生成的文件所属包
syntax = "proto3";option go_package = ".;pb";
我们来讲解我们的需求:
首先是我们一个服务端仅仅只有两个请求:一个是查询请求、一个是保存请求。为了方便,我们给请求都需要添加一个head,说明自己来自于哪一个服务端
查询请求:string head、string company(公司名)
查询请求的回应:string head、string allmoney(返回总资产的hash值)、string nowmoney(返回市值的hash值)、bool foot(是否正确查询t or f)
message send {string head = 1;//请求头,表明自己属于哪一个userstring company = 2;//a-z哪一个公司
}message get {string head = 1;//请求头,表明自己属于哪一个userstring allmoney = 2;string nowmoney = 3;bool foot = 4;//请求的回应,成功true错误false
}
保存请求: string head、dataAllMoney、dataNowMoney
保存请求的回应:string head、bool retu(t or f 表示是否保存成功)
message send1{//发出数据保存请求,包含请求的头,数据本身string head = 1;string dataAllMoney = 2;string dataNowMoney = 3;
}
message get1{//返回结果,true or false.并返回数据的hash值string head = 1;bool retu = 2;
}
最后我们根据这两个请求设计一个service
service Func{rpc Read(send)returns(get);//这是读数据的请求,发出读取哪个公司,返回读取到的数据以及读取结果rpc Save(send1)returns(get1);//这是保存请求,如果更新数据,就把数据发送给下一user,然后通过hash算法返回hash值
}
设计完成之后生成对应的go文件即可,生成文件的命令:protoc -I . --go_out=. --go-grpc_out=. 文件名.proto
具体实现方法-controller包的编写
这个包里面有我们之前使用go协程运行的两个函数
它们主要的作用就是监听一个ip和端口,这里的ip没有写而是写成了:8081这种形式,它默认的是本机ip。我们把这个ip地址注册到我们使用grpc.NewServer()注册的服务器中,并且使用服务器.Serve()方法监听我们创建的连接之后,我们就可以使用grpc.Dial()连接到这个ip,并调用这个服务器的方法,至于服务器内都有哪些方法取决于pb.RegisterFuncServer(s, &Server{}),这个Server接口的所有方法都是可以的。
func GetSend(wg *sync.WaitGroup) { //接收查询请求并返回结果,把等待组作为参数传递给本函数执行done()方法defer wg.Done()dns := ":8081"lis, err := net.Listen("tcp", dns)if err != nil {logs.Logger.Fatal(err)}s := grpc.NewServer()pb.RegisterFuncServer(s, &Server{})s.Serve(lis) //创建grpc服务器并监听对应的ipreturn
}func GetSave(wg *sync.WaitGroup) { //接收保存请求并返回结果defer wg.Done()dns := ":8082"lis, err := net.Listen("tcp", dns)if err != nil {logs.Logger.Fatal(err)}s := grpc.NewServer()pb.RegisterFuncServer(s, &Server{})s.Serve(lis) //创建grpc服务器并监听对应的ipreturn
}
我们在main函数中,调用的四个函数都是属于controller包。由于GO语言的特性,我们如果想让本包的资源可以被外面访问就需要使用首字母大写的方式
接下来我们来看具体如何实现这四个方法吧
首先是监听的方法,它的实现是基于我们之前写的proto文件里面的service,它在go文件中是一个接口类型,接口内定义了两个方法,我们需要实现这两个方法。
实现方法的办法是定义一个结构体,让结构体实现接口内的方法,接口内的方法是两个监听的方法Read和Save所以我们先创建结构体,让结构体实现这两个方法
type Server struct { //实现接口pb.UnimplementedFuncServer
}
然后是两个方法的实现,这里有一部分涉及到了我们自己写的数据库的包。
简单讲一下这两个函数的逻辑,当这个函数被调用的时候他有两个默认的参数,以及两个返回值
参数:第一个是固定的ctx context.Context 第二个是我们之前在proto文件里面的参数
Read(send)returns(get)
这个send就是我们的参数之一,返回值也是,并且还要返回一个错误信息。
我们通过之前自己写的mysql包里面提供的两个变量来进行数据库的操作,然后根据返回值的类型、个数进行return。
/*read方法的作用是接收其他服务端发送的请求,根据请求头判断是否是正确的请求,然后把需要查询的数据库的hash值返回
user1的read方法只针对user3起效,因为user1存储的是user3的hash值*/
func (s *Server) Read(ctx context.Context, req *pb.Send) (*pb.Get, error) { //1v1//别的用户发出的请求都会被这里接受到,我们返回的请求是处理好的结果logs.Logger.Println("接收到hash查询请求,请求来自于", req.Company)data := &mysql.NextHash{}mysql.HashTab.Where("company_name = ?", req.Company).First(data)return &pb.Get{ //return anythingHead: "user1", //返回给请求发起者,说明自己是谁Foot: true, //标识查询成功Allmoney: data.HashDataAllMoney,Nowmoney: data.HashDataNowMoney,}, nil
}// Save
/*
这个方法的作用是存储user3放来的数据的hash值,然后返回结果给user3
*/
func (s *Server) Save(ctx context.Context, req *pb.Send1) (*pb.Get1, error) {//别的用户发送的保存请求,我们回应给它一个保存的结果logs.Logger.Println("接收到保存请求,user1存储user2的hash数据,所属公司:", req.Head)/*处理逻辑:接收到需要存储的数据,然后转变为hash值,把hash值保存到对应的数据*/dataAllHash := hash.HashData(req.DataAllMoney) //公司名+市值+总资产dataNowMoney := hash.HashData(req.DataNowMoney)conmpay := req.Headerr := mysql.HashTab.Create(&mysql.NextHash{CompanyName: conmpay,HashDataAllMoney: dataAllHash,HashDataNowMoney: dataNowMoney,})if err != nil {return &pb.Get1{ //存储失败,返回falseHead: "user1",Retu: false,}, nil //返回结果给请求发起方}/*连接数据库存储数据*/return &pb.Get1{ //one存储好之后返回给three一个true证明处理好了Head: "user1",Retu: true,}, nil //返回结果给请求发起方
}
接下来就是连接的发起方,也就是main函数中我们手动触发的那两个函数
这两个函数的作用就是,连接到对应的ip端口,然后调用接口内的方法(Read或Save)
c := pb.NewFuncClient(conn)这个语句的作用是创建一个客户端,里面的参数是一个连接,连接到我们想查询的系统的ip端口,这里它需要连接的是下一个服务端,所以ip和端口应该是下一个服务端中监听函数里面监听查询请求的函数的端口。如果是save那么就是连接下一个服务端中监听函数里面保存请求对应的IP端口。
同时在通过客户端服务器调用对应的函数的时候需要注意以下参数,参数要与客户端接收的参数对应上,比如我们上面的服务端接收一个context.Context类型的参数以及对应的proto文件生成的结构体,在传参的时候我们就需要context.Background(),和对应的结构体当作参数
func Ping(company string) { //测试函数logs.Logger.Println("系统执行查询,查询目标是user3", company)conn, err := grpc.Dial("localhost:8083", grpc.WithInsecure())if err != nil {logs.Logger.Fatal(err)}defer conn.Close()c := pb.NewFuncClient(conn)res, err := c.Read(context.Background(), &pb.Send{Head: "user1", //作为user1向user2发送请求,请求的作用是查询,查询user2里面存储的user1的数据的hash值Company: company, //表示查询哪一个公司})if err != nil {logs.Logger.Fatal(err)}fmt.Println("head is:", res.Head, "foot is:", res.Foot, "allmoney hash is :", res.Allmoney, "nowmoney hash is:", res.Nowmoney) //获取到hash数据dataOfMysql := mysql.MyCompany{}mysql.CompanyTab.Where("company_name = ?", company).Find(&dataOfMysql)a := hash.HashData(dataOfMysql.AllMoney)b := hash.HashData(dataOfMysql.NowMoney)if a == res.Allmoney && b == res.Nowmoney {fmt.Println("data is true ^_^")}
}func Save(company, allmoney, nowmoney string) { //接收参数,第一个是新加入的公司名logs.Logger.Println("系统发出保存请求,保存公司为:", company, "发送给user3")conn, err := grpc.Dial("localhost:8084", grpc.WithInsecure())if err != nil {logs.Logger.Fatal(err)}defer conn.Close()c := pb.NewFuncClient(conn)c.Save(context.Background(), &pb.Send1{Head: company,DataAllMoney: allmoney,DataNowMoney: nowmoney,})mysql.CompanyTab.Create(&mysql.MyCompany{CompanyName: company,AllMoney: allmoney,NowMoney: nowmoney,})logs.Logger.Println("新公司添加成功,hash值存放在user3中")
}
这里面还有日志的打印,这部分就比较简单了。接下啦我们会介绍
日志的打印-logs包的编写
我们日志的打印就是创建一个文件,然后使用特定的格式进行对文件的写入
package logsimport ("log""os"
)var Logger *log.Loggerfunc init() {file, err := os.OpenFile("system_user1.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)if err != nil {log.Fatalln("打开日志文件失败:", err)}Logger = log.New(file, "", log.LstdFlags) // 例如输出格式:[时间] 内容
}
我们只需要导入logs包,然后调用包里面的Logger即可
扩展服务端
扩展服务端也是比较简单的,我们首先明确的是原有的服务端们是形成一个环路的,如果要扩展,就需要添加新的ip端口,让上一user的服务端的ip指向我们插入的user的监听方,让我们插入的请求方的user指向下一user的监听方。
还有就是需要清除一些数据,假如在auser和buser中间插入一个cuser,我们知道,a作为环路的头存放的是环路的尾的hash数据,所以从如果要插入到a和b中间,c就需要存放a的hash数据,并且b要清除a的hash数据存放c的hash数据
ai总结:(比我讲的清晰)
🧠 环状服务端结构的扩展逻辑(清晰版)
✅ 当前结构假设:
你有一个由多个服务端(称为 user 节点)组成的环状结构,每个节点:
监听一个特定 IP+端口;
保存一个“前驱节点”的 Hash 数据(可能用于验证完整性、链路溯源等);
指向下一个节点的地址(用作请求的下一个跳转目标);
✅ 扩展的目标:
将一个新的节点
cuser
插入到auser
和buser
之间。🧩 插入步骤:
1. 修改路由关系(即 IP/端口指向关系):
让
auser
的 下一个跳转地址 改为cuser
的监听地址;让
cuser
的 下一个跳转地址 指向原本的buser
;
buser
的下一个保持不变。2. 更新 Hash 数据:
原本
auser
存的是buser
的 hash 数据(因为它作为头,存的是尾);插入
cuser
后,cuser
应该保存auser
的 hash 数据;
buser
原本保存的是auser
的 hash 数据,现在应该清除这一条,改为存储cuser
的 hash 数据。✍️ 举个表格说明(前 → 后):
节点
原指向
原存 hash
修改后指向
修改后存 hash
auser
buser
buser
的 hash
cuser
buser
的 hash(可选,或删)
cuser
N/A
N/A
buser
auser
的 hash ✅
buser
-
auser
的 hash-
cuser
的 hash ✅🎯 最终效果:
环路结构维持稳定;
插入节点后,Hash 链也保持一致性;
每个节点仍然拥有自己应当保存的上一个节点的 Hash 信息;
并且数据传递或验证顺序没有断裂。