架构
(架构图源于参考书籍)
RabbitMQ包封装
下载
go get "github.com/streadway/amqp"
封装
package rabbitmq
import (
"encoding/json"
"log"
"github.com/streadway/amqp"
)
/*
对 RabbitMQ 函数库的再次封装,便于操作
*/
type RabbitMQ struct {
channel *amqp.Channel
Name string
exchange string
}
/*创建新结构体*/
func New(s string) *RabbitMQ {
conn, e := amqp.Dial(s)
if e != nil {
log.Fatalln(e)
}
ch, e := conn.Channel()
if e != nil {
log.Fatalln(e)
}
q, e := ch.QueueDeclare(
"", // 名字
false, // 耐用
true, // 不使用时删除
false, // 专用的
false, // 不等待
nil, // 参数
)
if e != nil {
log.Fatalln(e)
}
// 分配内存,构建一个新结构体
mq := new(RabbitMQ)
mq.channel = ch
mq.Name = q.Name
// 返回对象指针
return mq
}
/*将 RabbitMQMQ 结构体的消息队列与一个 exchange 绑定*/
func (q *RabbitMQ) Bind(exchange string) {
e := q.channel.QueueBind(q.Name, "", exchange, false, nil)
if e != nil {
log.Fatalln(e)
}
q.exchange = exchange
}
/*往某个消息队列发送消息*/
func (q *RabbitMQ) Send(queue string, body interface{}) {
// 序列化
str, e := json.Marshal(body)
if e != nil {
log.Fatalln(e)
}
e = q.channel.Publish("", queue, false, false, amqp.Publishing{
ReplyTo: q.Name,
Body: []byte(str),
})
if e != nil {
log.Fatalln(e)
}
}
/*往某个 exchange 发送消息*/
func (q *RabbitMQ) Publish(exchange string, body interface{}) {
// 序列化
str, e := json.Marshal(body)
if e != nil {
log.Fatalln(e)
}
e = q.channel.Publish(exchange, "", false, false, amqp.Publishing{
ReplyTo: q.Name,
Body: []byte(str),
})
if e != nil {
log.Fatalln(e)
}
}
/*生成一个接收信息的管道*/
func (q *RabbitMQ) Consume() <-chan amqp.Delivery {
c, e := q.channel.Consume(q.Name, "", true, false, false, false, nil)
if e != nil {
log.Fatalln(e)
}
return c
}
/*关闭消息队列*/
func (q *RabbitMQ) Close() {
q.channel.Close()
}
数据服务实现
心跳机制
定时向 apiServers exchange 发送自身服务节点的监听地址,以告知自身存在。
package heartbeat
import (
"demo/rabbitmq"
"demo/sys"
"os"
"time"
)
/*每 3s 向 apiServers exchange 发送一次心跳,心跳信息为该节点的监听地址*/
func StartHeartbeat() {
q := rabbitmq.New(os.Getenv(sys.RabbitmqServer))
defer q.Close()
for {
q.Publish(sys.ApiServersExchange,os.Getenv(sys.ListenAddress))
// 休眠 3s
time.Sleep(3 * time.Second)
}
}
存储对象定位
监听 dataServers exchange 的定位信息, 接收定位请求时,如果存储对象存在,则回送本节点监听地址,已告知存储对象在该节点。
package locate
import (
"demo/rabbitmq"
"demo/sys"
"log"
"os"
"strconv"
)
// 定位对象
func Locate(name string) bool {
// 访问磁盘上对应的文件名
_,e := os.Stat(name)
// 判读文件名是否存在
return !os.IsNotExist(e)
}
// 监听定位信息
func StartLocate(){
q := rabbitmq.New(os.Getenv(sys.RabbitmqServer))
defer q.Close()
// 绑定 data 网络层
q.Bind(sys.DataServersExchange)
// 获取信息管道
c := q.Consume()
// 从管道中遍历信息,msg 为需要定位的存储对象名字
for msg := range c{
// 去掉 json 序列化的双引号
obj,e := strconv.Unquote(string(msg.Body))
if e != nil {
log.Fatalln(e)
}
// 存储根目录拼接文件名,定位存储对象
if Locate(os.Getenv(sys.StorageRoot) + obj){
// 如果存储对象存在,则回送本节点监听地址,已告知存储对象在该节点
q.Send(msg.ReplyTo,os.Getenv(sys.ListenAddress))
}
}
}
PUT 和 GET 处理函数
PUT 请求时创建或者替换一个资源,GET 请求时获取资源,将这两个业务封装在 Handler 函数。
package objects
import (
"demo/sys"
"io"
"log"
"net/http"
"os"
"strings"
)
/*
PUT/GET 业务处理函数
*/
func Handler(w http.ResponseWriter, r *http.Request) {
m := r.Method
// PUT 方法时,创建或者替换资源
if m == http.MethodPut {
put(w, r)
return
}
// GET 方法时,获取资源
if m == http.MethodGet {
get(w, r)
return
}
// 其他方式时,返回状态码,方法不允许
w.WriteHeader(http.StatusMethodNotAllowed)
}
func put(w http.ResponseWriter, r *http.Request) {
// 处理文件路径,将文件保存在 D:/uploadFile 目录下,文件名为客户端 PUT 请求时的名字,文件名应为转义后的名字
// 文件路径可以使用 os.Getenv(var) 通过环境变量来指定会更加灵活
log.Println(r.URL.EscapedPath())
fileName := os.Getenv(sys.StorageRoot) + strings.Split(r.URL.EscapedPath(), "/")[2]
// 创建文件
f, e := os.Create(fileName)
if e != nil {
log.Println(e)
// 返回服务器错误状态码
w.WriteHeader(http.StatusInternalServerError)
return
}
// 将客户端 PUT 请求的文件数据保存到服务端创建的文件 f 中
io.Copy(f, r.Body)
// 关闭文件
defer f.Close()
}
func get(w http.ResponseWriter, r *http.Request) {
// 处理文件路径,从 D:/uploadFile 目录下获取文件,文件名为客户端 GET 请求时的名字,文件名应为转义后的名字
// 文件路径可以使用 os.Getenv(var) 通过环境变量来指定会更加灵活
log.Println(r.URL.EscapedPath())
fileName := os.Getenv(sys.StorageRoot) + strings.Split(r.URL.EscapedPath(), "/")[2]
// 创建文件
f, e := os.Open(fileName)
if e != nil {
log.Println(e)
// 返回文件未找到状态码
w.WriteHeader(http.StatusNotFound)
return
}
// 将服务端的文件 f 复制到响应体 w 中
io.Copy(w, f)
// 关闭文件
defer f.Close()
}
/*
注意:
f 本身类型是 *os.File,它是一个指向 os.File 结构体的指针,而 os.File 结构体同时实现了 io.Writer 和 io.Reader 两个接口,
因此它既是一个 io.Writer 又是 一个 io.Reader,可以在 io.Copy 中作为不同的参数使用。
*/
main 函数
package main
import (
"demo/dataServer/heartbeat"
"demo/dataServer/locate"
"demo/dataServer/objects"
"demo/sys"
"net/http"
"os"
)
func main() {
// 向 apiServers exchange 发送心跳
go heartbeat.StartHeartbeat()
// 监听定位信息
go locate.StartLocate()
// 注册URL与逻辑处理函数
http.HandleFunc("/handleObjs/", objects.Handler)
// 启动并监听服务
http.ListenAndServe(os.Getenv(sys.ListenAddress), nil)
}
至此,我们实现了数据服务层的基本业务:
1、数据服务节点向 apiServers exchange 发送心跳信息以告知自身存在。
2、数据服务节点监听 dataServers exchange 定位信息,若定位到存储对象信息则单向回送自身节点信息以告知 dataServers exchange 所需要的存储对象在该节点。
3、数据服务节点处理 PUT 和 GET 请求实现基础的存储对象创建和获取。
接口服务实现
监听心跳
监听数据服务节点的心跳信息,缓存全部的数据节点。
package heartbeat
import (
"demo/rabbitmq"
"demo/sys"
"log"
"math/rand"
"os"
"strconv"
"sync"
"time"
)
var dataServers = make(map[string]time.Time) // 缓存数据服务节点
var m sync.RWMutex // 使用多读写锁,比互斥锁高效
/*监听心跳*/
func ListenHeartbeat() {
q := rabbitmq.New(os.Getenv(sys.RabbitmqServer))
defer q.Close()
// 绑定 api 网络层
q.Bind(sys.ApiServersExchange)
c := q.Consume()
// 移除过期节点
go removeExpiredDataServer()
// 监听数据服务节点心跳,将心跳信息写入全局缓存
for msg := range c {
dataServer, e := strconv.Unquote(string(msg.Body))
if e != nil {
log.Fatalln(e)
}
// 写操作互斥,防止多 goroutine 对 dataServers 同时写
m.Lock()
dataServers[dataServer] = time.Now()
m.Unlock()
}
}
/*移除过期的数据服务节点*/
func removeExpiredDataServer() {
// 每 3s 扫描一遍缓存的数据服务节点
// 若当前时间减去心跳时间超过 6s 则判定为节点过期
for {
time.Sleep(3 * time.Second)
// 写操作互斥,防止多 goroutine 对 dataServers 同时写
m.Lock()
for s, t := range dataServers {
if t.Add(6 * time.Second).Before(time.Now()) {
delete(dataServers, s)
}
}
m.Unlock()
}
}
/*获取全部数据服务节点*/
func GetDataServers() []string{
// 读锁,可多 goroutine 对 dataServers 同时读
m.RLock()
defer m.RUnlock()
dataServer := make([]string,0)
for s,_ := range dataServers {
dataServer = append(dataServer,s)
}
return dataServer
}
/*随机选择一个数据服务节点返回*/
func ChooseRandomDataServer() string{
dataServer := GetDataServers()
length := len(dataServer)
if length == 0 {
return ""
}
return dataServer[rand.Intn(length)]
}
定位存储对象
package locate
import (
"demo/rabbitmq"
"demo/sys"
"encoding/json"
"net/http"
"os"
"strconv"
"strings"
"time"
)
/*定位存储对象*/
func Locate(name string) string{
// 创建临时消息队列
q := rabbitmq.New(os.Getenv(sys.RabbitmqServer))
// 向 data 网络层群发这个存储对象的名字
q.Publish(sys.DataServersExchange,name)
// 获取信息管道
c := q.Consume()
// 休眠一秒之后将临时消息队列关闭,防止超时阻塞
go func() {
time.Sleep(time.Second)
q.Close()
}()
// 从管道中读取定位信息
msg := <- c
s,_ := strconv.Unquote(string(msg.Body))
return s
}
/*判断存储对象是否存在*/
func Exist(name string) bool {
return Locate(name) != ""
}
/*处理定位请求*/
func Handler(w http.ResponseWriter,r *http.Request){
m := r.Method
// 非 GET 方法
if m!= http.MethodGet{
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
objInfo := Locate(strings.Split(r.URL.EscapedPath(),"/")[2])
// 未找到该存储对象
if len(objInfo) == 0 {
w.WriteHeader(http.StatusNotFound)
return
}
obj,_ := json.Marshal(objInfo)
w.Write(obj)
}
PUT 和 GET 处理函数
接口服务的 PUT 和 GET 请求是将 HTTP 请求转发到数据服务,实际上是调用数据服务的 PUT 和 GET 方法。
package objects
import (
"demo/apiServer/heartbeat"
"demo/apiServer/locate"
"demo/apiServer/objectStream"
"demo/sys"
"fmt"
"io"
"log"
"net/http"
"strings"
)
/*接口服务的 PUT 和 GET 请求是将 HTTP 请求转发到数据服务,实际上是调用数据服务的 PUT 和 GET 方法*/
func Handler(w http.ResponseWriter, r *http.Request) {
m := r.Method
// PUT 方法时,创建或者替换资源
if m == http.MethodPut {
put(w, r)
return
}
// GET 方法时,获取资源
if m == http.MethodGet {
get(w, r)
return
}
// 其他方式时,返回状态码,方法不允许
w.WriteHeader(http.StatusMethodNotAllowed)
}
/*处理接口服务 PUT 请求*/
func put(w http.ResponseWriter,r *http.Request){
// 获取存储对象名字
obj := strings.Split(r.URL.EscapedPath(),"/")[2]
// 存储请求数据体
httpStatus,e := storeObject(r.Body,obj)
if e != nil {
log.Fatalln(e)
}
// 返回存储结果
w.WriteHeader(httpStatus)
}
func storeObject(r io.Reader,obj string)(int,error){
// 获取接口服务节点存储对象的流
stream, e := putStream(obj)
if e != nil {
return http.StatusServiceUnavailable,e
}
// 将请求数据体拷贝到流 stream
io.Copy(stream,r)
// 关闭流
e = stream.Close()
if e != nil {
return http.StatusInternalServerError,e
}
// 返回成功状态码
return http.StatusOK,nil
}
func putStream(obj string)(*objectStream.PutStream,error){
// 随机选择一个数据服务节点
server := heartbeat.ChooseRandomDataServer()
// 若没有可用的数据服务节点则返回错误信息
if server == "" {
return nil,fmt.Errorf(sys.DataServerNotFound)
}
// 返回数据服务节点存储对象的流
return objectStream.NewPutStream(server,obj),nil
}
/*处理接口服务 GET 请求*/
func get(w http.ResponseWriter,r *http.Request){
// 获取存储对象名称
obj := strings.Split(r.URL.EscapedPath(),"/")[2]
// 获取存储对象数据流
stream, e := getStream(obj)
if e != nil {
w.WriteHeader(http.StatusNotFound)
return
}
// 将数据流拷贝到响应流 w
io.Copy(w,stream)
}
func getStream(obj string) (io.Reader,error){
// 根据存储对象名称进行定位
server := locate.Locate(obj)
// 未找到该存储对象时返回定位失败错误
if server == "" {
return nil,fmt.Errorf(sys.DataServerLocateFail,obj)
}
// 定位到存储对象时,返回该对象的数据流
return objectStream.NewGetStream(server,obj)
}
处理 PUT 和 GET 请求时的读写流
package objectStream
import (
"demo/sys"
"fmt"
"io"
"net/http"
)
type PutStream struct {
writer *io.PipeWriter // writer 用于实现 Write 方法
c chan error // c 用于将 goroutine 传输数据过程中抛出的错误传回主协程
}
/* 构建接口服务存储对象流,当数据通过写入到该 PutStream.writer 时,向数据服务节点请求的 reader 就会读到管道中的数据 */
func NewPutStream(server, obj string) *PutStream {
// 创建一对管道互连的 reader 和 writer,写入 writer 中的数据可以从 reader 中读出来
reader, writer := io.Pipe()
c := make(chan error)
// 由于管道读写是阻塞的,因此需要另起一个 goroutine 来向数据服务节点发起 PUT 请求,此时接口服务是一个 client 端
go func() {
// 构建请求
request, _ := http.NewRequest(http.MethodPut, "http://"+server+"/handleObjs/"+obj, reader)
client := http.Client{}
// 发起请求
r, e := client.Do(request)
// 其他异常
if e == nil && r.StatusCode != http.StatusOK {
e = fmt.Errorf(sys.DataServerError, r.StatusCode)
}
// 将异常写入管道
c <- e
}()
// 返回接口服务存储对象流
return &PutStream{
writer: writer,
c: c,
}
}
/*实现 io.Writer 接口*/
func (w *PutStream) Write(p []byte) (n int, err error) {
return w.writer.Write(p)
}
/*让管道另一端的 reader 读到 io.EOF ,否则 client.Do(request) 将一直处于请求阻塞状态*/
func (w *PutStream) Close() error {
w.writer.Close()
return <-w.c
}
type GetStream struct {
reader io.Reader
}
func newGetStream(url string) (*GetStream, error) {
// 向数据服务节点发起 GET 请求
r, e := http.Get(url)
if e != nil {
return nil, e
}
if r.StatusCode != http.StatusOK {
return nil, fmt.Errorf(sys.DataServerError, r.StatusCode)
}
// 返回请求响应数据
return &GetStream{
reader: r.Body,
}, nil
}
func NewGetStream(server, obj string) (*GetStream, error) {
// 当数据服务节点为空或者请求对象名字为空,则抛出异常
if server == "" || obj == "" {
return nil, fmt.Errorf(sys.InvalidServerOrObject, server, obj)
}
// 调用数据服务节点 GET 接口,返回请求响应数据
return newGetStream("http://" + server + "/handleObjs/" + obj)
}
/*实现 io.Reader 接口*/
func (r *GetStream)Read(p []byte) (n int, err error) {
return r.reader.Read(p)
}
main 函数
package main
import (
"demo/apiServer/heartbeat"
"demo/apiServer/locate"
"demo/apiServer/objects"
"demo/sys"
"net/http"
"os"
)
func main(){
// 监听数据服务节点心跳
go heartbeat.ListenHeartbeat()
// 处理对象请求,实际上是将对象请求转发给数据服务
http.HandleFunc("/handleObjs/",objects.Handler)
// 处理定位请求
http.HandleFunc("/locateObj/",locate.Handler)
// 启动并监听服务
http.ListenAndServe(os.Getenv(sys.ListenAddress),nil)
}
至此,我们实现了接口服务层的基本业务:
1、接口服务监听 apiServers exchange 以获取数据服务节点心跳信息,并缓存数据服务节点。
2、接口服务定位存储对象时会向 dataServers exchange 群发信息,等待数据服务节点响应,否则报 404,不会一直等待阻塞。
搭建 RabbitMQ 服务
# 添加仓库
curl -s https://packagecloud.io/install/repositories/rabbitmq/erlang/script.rpm.sh | sudo bash
# 安装 erlang
dnf install erlang
# 下载 rabbitmq3.9.7
wget https://github.com/rabbitmq/rabbitmq-server/releases/download/v3.9.7/rabbitmq-server-3.9.7-1.el8.noarch.rpm
# 添加 socat 依赖
yum install socat
# 安装 rabbitmq
rpm -ivh rabbitmq-server-3.9.7-1.el8.noarch.rpm
# 安装 web 管理插件
rabbitmq-plugins enable rabbitmq_management
# 启动 rabbitmq-server
systemctl start rabbitmq-server.service
# 创建远程用户,账号 yushanma ,密码 passwd
rabbitmqctl add_user yushanma passwd
# 设置该用户为 administrator 角色
rabbitmqctl set_user_tags yushanma administrator
# 设置权限
rabbitmqctl set_permissions -p '/' yushanma '.' '.' '.'
# 重启 rabbitmq 服务
systemctl restart rabbitmq-server.service
# 防火墙放行端口
iptables -I INPUT 4 -p tcp -m state --state NEW -m tcp --dport 15672 -j ACCEPT
# 保存防火墙规则
service iptables save
远程访问:http://ip:15672/ 账号密码为刚刚设定的 yushanma passwd
打开 http://ip:15672/cli ,下载 rabbitmqadmin ,也可以直接通过 uri 下载。
# 下载 rabbitmqadmin
wget http://node1:15672/cli/rabbitmqadmin
# 移到 PATH 目录,并赋予权限
mv rabbitmqadmin /usr/local/bin
chmod 777 /usr/local/bin/rabbitmqadmin
# 查看 rabbitmqadmin 命令
rabbitmqadmin -help
# 若无 python3 环境则先安装 python
dnf update -y
dnf install python3 -y
whereis python3
alternatives --set python /usr/bin/python3
python --version
测试
模拟分布式架构环境
# 查看本机网络接口
ip a
# 数据服务节点 eth0:1~6
# IP范围 172.16.17.1 ~ 172.16.17.6
# 接口服务节点 eth0:7~8
# IP范围 172.16.18.1 ~ 172.16.18.2
# 网络接口绑定多个 IP
ifconfig eth0:1 172.16.17.1/20
ifconfig eth0:2 172.16.17.2/20
ifconfig eth0:3 172.16.17.3/20
ifconfig eth0:4 172.16.17.4/20
ifconfig eth0:5 172.16.17.5/20
ifconfig eth0:6 172.16.17.6/20
ifconfig eth0:7 172.16.18.1/20
ifconfig eth0:8 172.16.18.2/20
eth0 为本机的内网网络接口,我们只需要用 ifconfig 接口别名 IP/掩码位数 就可以在同一接口上绑定多个 IP 。
# 定义一个Fanout路由,Fanout—发布与订阅模式,是一种广播机制,它是没有路由key的模式。 生产者将消息通过交换机存进队列 而后 消费者通过队列去消费。
# rabbitmqadmin declare exchange name=my.fanout type=fanout
# 创建 dataServers exchange
rabbitmqadmin declare exchange name=dataServers type=fanout
# 创建 apiServers exchange
rabbitmqadmin declare exchange name=apiServers type=fanout
在 /home/sam/ 路径下创建存储目录:
for i in `seq 1 6`; do mkdir -p files/$i/objects; done
导入 rabbitmq-server 环境变量:
export RABBITMQ_SERVER=amqp://yushanma:passwd@172.16.16.4:5672
# 15672 是管理页面端口,5672 是服务端口
启动服务:
# 启动数据服务节点
LISTEN_ADDRESS=172.16.17.1:12345 STORAGE_ROOT=/home/sam/files/1/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.2:12345 STORAGE_ROOT=/home/sam/files/2/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.3:12345 STORAGE_ROOT=/home/sam/files/3/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.4:12345 STORAGE_ROOT=/home/sam/files/4/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.5:12345 STORAGE_ROOT=/home/sam/files/5/objects/ go run dataServer/cmd/main.go &
LISTEN_ADDRESS=172.16.17.6:12345 STORAGE_ROOT=/home/sam/files/6/objects/ go run dataServer/cmd/main.go &
# 启动接口服务节点
LISTEN_ADDRESS=172.16.18.1:12346 go run apiServer/cmd/main.go &
LISTEN_ADDRESS=172.16.18.2:12346 go run apiServer/cmd/main.go &
访问接口服务节点,PUT 一个名为 hello 的文件:
curl -v 172.16.18.1:12346/handleObjs/hello -XPUT -d"hello yushanma"
用 locateObj 命令查看 hello 被保存到哪个数据服务节点上:
curl 172.16.18.1:12346/locateObj/hello
换另一个接口服务节点 GET 该对象:
curl 172.16.18.2:12346/handleObjs/hello
测试结果:
遇到的问题:
服务无法正常启动,是端口被占用的问题,换一个端口即可。
STORAGE_ROOT 指定存储根路径要加“/”,即 STORAGE_ROOT=/home/sam/files/1/objects/ ,否则存储的时候文件名会变成 objectshello ,而不是预期 objects 目录下的 hello 。