0%

接口简介

详见手册

后端实现 Go

  1. 云端启用websocket server接受本地PC的链接请求

  2. 监听AI相关的http请求,然后将请求通过ws转发给本地PC,从本地PC获取结果后再回复给http请求

以上步骤类似于通过一个公网ip来进行内网穿透,将内网服务暴露至公网。比较简单的办法就是就是直接使用开源的内网穿透工具,将本地PC上的AI服务暴露出去。

这里选择修改Go服务器,自行开发内网穿透相关逻辑,为了继续熟悉Go语言和便于后续定制

websocket 服务器框架

wsserver实现

1. ClientManager

通过go程与chan,实现对所有连入的wsclient的管理

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
48
49
50
// ClientManager is a websocket manager
type ClientManager struct {
Clients map[*Client]bool
Broadcast chan []byte
Register chan *Client
Unregister chan *Client
}

// Client is a websocket client
type Client struct {
ID string
Socket *websocket.Conn
Send chan []byte
Count int // 心跳计数
Type int // 服务类型 1ai 2web
UserId string
}

// Start is to start a ws server
func (manager *ClientManager) Start() {
for {
select {
case conn := <-manager.Register:
manager.Clients[conn] = true
log.Println("ws连接:" + conn.Socket.RemoteAddr().String())
// jsonMessage, _ := json.Marshal(&Message{Content: "/A new socket has connected."})
// manager.Send(jsonMessage, conn)
case conn := <-manager.Unregister:
if _, ok := manager.Clients[conn]; ok {
log.Println("ws断开:" + conn.Socket.RemoteAddr().String())
if conn == GAIClient.Client {
GAIClient = nil
}
close(conn.Send)
delete(manager.Clients, conn)
// jsonMessage, _ := json.Marshal(&Message{Content: "/A socket has disconnected."})
// manager.Send(jsonMessage, conn)
}
case message := <-manager.Broadcast:
for conn := range manager.Clients {
select {
case conn.Send <- message:
default:
close(conn.Send)
delete(manager.Clients, conn)
}
}
}
}
}

2. Client

通过go程与chan,异步处理每个Client的读写逻辑

每个client的写逻辑是通用的,只需要将从web收到的数据,写给client即可

读逻辑则需要特殊处理,收到来自web的消息后,临时开辟出一个chan,然后阻塞接收chan的消息。当从ai收到回复后,将来自ai的回复写入chan,再通过http返回给web

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
48
49
50

// 写逻辑,直接通过chan将数据写进wswrite即可
// 对于aiclient,server收到http请求后,会直接将请求转发给aiclient
func (c *Client) Write() {
defer func() {
c.Socket.Close()
}()

for {
select {
case message, ok := <-c.Send:
if !ok {
c.Socket.WriteMessage(websocket.CloseMessage, []byte{})
return
}
c.Socket.WriteMessage(websocket.TextMessage, message)
}
}
}

// 针对本地aiclient的读逻辑
func (c *AIClient) Read() {
defer func() {
Manager.Unregister <- c.Client
c.Socket.Close()
}()

for {
_, message, err := c.Socket.ReadMessage()
if err != nil {
Manager.Unregister <- c.Client
c.Socket.Close()
break
}

// 重置心跳计时
c.Client.Count = 0
var msg2ai Msg2AI

err = json.Unmarshal(message, &msg2ai)
if err != nil {
log.Println("ai解析失败:" + c.Socket.RemoteAddr().String())
Manager.Unregister <- c.Client
c.Socket.Close()
} else {
c.runAILogic(msg2ai)
}
}
}

通过路由ws请求,以接受wsclient的连入

引入github.com/gorilla/websocket库,以实现websocket功能

ws请求基于gin框架,收到http请求后将其升级为ws长链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (u *WS) Bind() {
u.Ge.GET("/ai", u.execAI) // 接收pc发来的ws请求
// ...
}

func (u *WS) execAI(c *gin.Context) {
log.Println("ai上线")

// change the reqest to websocket model
conn, error := (&websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}).Upgrade(c.Writer, c.Request, nil)
if error != nil {
// 实现内部重定向
c.Redirect(http.StatusTemporaryRedirect, config.ServerCfg.Url+"/404.html")
return
}
// websocket connect
}

接收来自web的http请求

匹配全部/api/.*请求,然后直接转发给ai的ws通道。同时开辟一个chan给ai,阻塞等待ai将回复写入chan

借助chan的阻塞特性,以达到同步获取ai回复的效果

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
func (u *WS) Bind() {
// 改为路由参数配置
u.Ge.POST("/api/:name", u.execAPI)
}

func (u *WS) execAPI(c *gin.Context) {
api := c.Param("name")
log.Println("收到请求:" + api)

if GAIClient == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}

data, err := c.GetRawData()
if err != nil {
c.AbortWithError(http.StatusNotFound, err)
return
}

ctx := c.Request.Context()
strChan := make(chan string)
defer close(strChan)

GAIClient.runAIReq(c, api, string(data), strChan)

select {
case <-ctx.Done():
return
case str := <-strChan:
c.String(http.StatusOK, str)
}
}

心跳包超时检测

直接go启动一个定时器,定时去轮询每个ws链接。每个ws链接一定时间内没有消息发来,则主动断开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 心跳检测
func (manager *ClientManager) Timer() {
ticker := time.NewTicker(1 * time.Second)
for range ticker.C {
for conn := range manager.Clients {
conn.Count++
// if conn.Count >= 5 && conn != GAIClient.Client {
if conn.Count >= 600 {
log.Println("心跳超时:" + conn.Socket.RemoteAddr().String())
close(conn.Send)
delete(manager.Clients, conn)
}
}
}
}

接口简介

详见手册

后端实现 Python

  1. ChatGLM-6B模型本身是基于python的,且考虑到对硬件有较高要求,只能在本地PC上运行

  2. 云端服务器由GO实现

基于以上两点:决定继续用python开发本地服务,然后采用websocket与云端服务器进行通信。云端的GO服务器绑定公网IP,由本地的Python Client发起连接,然后由云端服务器发起请求,调用本地的AI对话功能

websocket

通过 asyncio 和 websockets 这两个py库进行ws通信,基础框架如下:

1
2
3
4
5
6
async with websockets.connect(url) as ws:
await ws.send("hello")
while True:
recvData = await ws.recv()
# do some logic
await ws.send(response)

在while循环中接收消息,处理后再通过ws返回给server

心跳包的实现

由于云服务器用nginx代理了所有请求,而nginx有超时机制。需要client发送心跳包来保持长链接,对框架做出一点改动

1
2
3
4
5
6
7
while True:
try:
recvData = await asyncio.wait_for(ws.recv(), timeout=60.0)
# do some logic
await ws.send(response)
except asyncio.TimeoutError:
await ws.send("heartbeat") # 发送心跳包

通过asyncio.wait_for的超时机制,每隔60s便向server发送一个心跳包,以保持链接

数据存储

由于是单卡AI提供的轻量级服务,不考虑复杂的数据存储。直接以json的形式存储在本地文件夹下即可

接口简介

详见手册

本地部署

AI对话能力通过ChatGLM-6B模型提供

硬件要求

由于本地PC是3A套装,需要安装AMD提供的ROCm框架来运行pytorch库

目前ROCm框架仅提供Linux版本,需要在PC上安装Linux系统,这里选择Ubuntu 22.04.2 LTS版本

制作U盘启动盘,进入bios一键安装即可。主板太渣M.2接口有限,所以这里选择了一块空置的机械硬盘进行安装,有条件还是建议上固态

  • 注意:
  1. ChatGLM-6B完全体的运行需要12~14G显存,本地的RX6800XT显卡有16G显存可以运行完全体。
  2. 模型加载时会把数据全部读入内存中,大约需要16~18G左右的内存,本机有32G内存可以正常加载。如果只有16G内存,建议手动开辟出至少8G的共享内存,否则模型启动中可能会爆内存闪退

环境配置

以ChatGLM-6B文档提供的库为基础,安装对应的python环境即可,这里建议在docker下安装

相关链接

ChatGLM-6B github 地址

pytorch 下载地址

关于AMD显卡与AI

AMD NO!

INVDIA YES!

微信公众号接口

微信公众号接口由微信服务器调用,当用户发起请求时,微信将消息封装后调用公众号的服务器接口

微信调用的接口需要由公网IP/域名提供服务

由于公众号服务器部署在Remote Liunx上,每次修改/开发接口后要上传至服务器重启服务太麻烦

现在需要一个内网穿透工具,将部署在开发机上的Web服务通过Remote Liunx映射到公网上

FastTunnel

开源社区逛了一圈决定用FastTunnel

优点: 免费、开源、跨平台、功能单一(主要就是内网穿透,没有其它乱七八糟的功能)、提供已编译的Server与Client软件

Server 配置

Linux开放端口AAAAA给Server使用,并指定站点的URL

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
{
"Logging": {
"LogLevel": {
// Trace Debug Information Warning Error
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"AllowedHosts": "*",
// Http&客户端通讯端口
"urls": "http://*:AAAAA",
// 是否启用文件日志输出
"EnableFileLog": false,
"FastTunnel": {
// 可选,绑定的根域名,
// 客户端需配置SubDomain,实现 ${SubDomain}.${WebDomain}访问内网的站点,注意:需要通过域名访问网站时必选。
"WebDomain": "loolob.cn",

// 可选,访问白名单,为空时:所有人有权限访问,不为空时:不在白名单的ip拒绝。
"WebAllowAccessIps": [ ],

// 可选,是否开启端口转发代理,禁用后不处理Forward类型端口转发.默认false。
"EnableForward": false,

// 可选,当不为空时,客户端也必须携带Tokens中的任意一个token,否则拒绝登录。
"Tokens": [ ],
}
}

Client 配置

指定Server的ip和端口 xxx.xxx.xxx.xxx:AAAAA

指定本地提供的web服务 127.0.0.1:BBBBB

然后指定子域名,此时外网通过 http://proxy.loolob.cn:AAAAA/ 就能访问到本地的web服务

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
48
49
{
"Logging": {
"LogLevel": {
// Trace Debug Information Warning Error
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
// 是否启用文件日志输出
"EnableFileLog": true,
"ClientSettings": {
"Server": {
// [必选] 服务端ip/域名(来自服务端配置文件的urls参数)
"ServerAddr": "xxx.xxx.xxx.xxx",
// [必选] 服务端监听的通信端口(来自服务端配置文件的urls参数)
"ServerPort": "AAAAA"
},

// [可选],服务端Token,必须与服务端配置一致,否则拒绝登录。
"Token": "",
/**
* [可选] 内网web节点配置
*/
"Webs": [
{
// [必选] 内网站点所在内网的ip
"LocalIp": "127.0.0.1",
// [必选] 内网站点监听的端口号
"LocalPort": "BBBBB",

// [必选] 子域名, 访问本站点时的url为 http://${SubDomain}.${WebDomain}:${ServerPort}
"SubDomain": "proxy"

// [可选] 附加域名,需要解析CNAME或A记录至当前子域名
// "WWW": [ "www.abc.com", "test111.test.cc" ]
}
],

/**
* [可选] 端口转发 通过专用端口代理,不需要有自己的域名
* 可穿透所有TCP上层协议
* 远程linux示例:#ssh -oPort=12701 {root}@{ServerAddr} ServerAddr 填入服务端ip,root对应内网用户名
* 通过服务端返回的访问方式进行访问即可
*/
"Forwards": [
]
}
}

Nginx 配置

用nginx转发一下proxy.loolob的请求,帮加上端口号AAAAA,免费每次请求都带个端口号

实际上还可以转发外部发来的https请求,用nginx转成http请求给FastTunnel Server (测试在Remote Linux上让FastTunnel开https服务失败了,所以FastTunnel Server实际上是监听的是80端口)

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
resolver 8.8.8.8;

server {
listen 80;

server_name proxy.loolob.cn;

location / {
proxy_pass http://$host:33080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

}

server {
listen 443 ssl http2;

server_name proxy.loolob.cn;

# 阿里云证书配置 Start
ssl_certificate /root/pem/fullchain.crt;
ssl_certificate_key /root/pem/private.pem;
ssl_session_timeout 5m;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_prefer_server_ciphers on;
# 阿里云证书配置 End

location / {
proxy_pass http://$host:33080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

}

证书申请

来此加密申请免费证书,3个月一续

证书验证

DNS验证

去域名商那里加个dns的Txt解析,将指定的子域名指向一段特定的文本。

尝试nameslio设置失败,已经设置了解析,但无法返回正确结果,放弃(不影响证书验证)

  • 后续更正

不是nameslio设置失败,是nameslio更新DNS设置后需要等待较长时间才会生效

Http验证

在服务器下放一个特定文件,通过指定域名能取到这个文件即可通过验证

证书下载使用

下载后丢到服务器,修改nginx设置以启用证书

nginx设置参考文档,来此加密版
nginx设置参考文档,阿里云版

Nginx配置

主要配置两个server:

  1. https server 含有证书信息
  2. http server 配置了跳转,将http请求都转发到https(含api字段的请求除外)

其余配置基本相同

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
server {
listen 443 ssl http2;

server_name loolob;

# 阿里云证书配置 Start
ssl_certificate /root/pem/fullchain.crt;
ssl_certificate_key /root/pem/private.pem;
ssl_session_timeout 5m;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_prefer_server_ciphers on;
# 阿里云证书配置 End


# 设置跨域配置 Start
set $cors_origin "";
if ($http_origin ~* "^http://localhost$"){
set $cors_origin $http_origin;
}

# add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Origin http://loolob.cn;
add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE,OPTIONS always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Headers DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,x-auth-token always;
add_header Access-Control-Max-Age 1728000 always;

# 预检请求处理
if ($request_method = OPTIONS) {
return 204;
}
# #设置跨域配置 End

include /root/loolob_blog/live2d_api/nginx.conf;
include /root/loolob_blog/liry-web-go/nginx.conf;
}

server {
listen 80;
server_name loolob

# 设置跨域配置 Start
set $cors_origin "";
if ($http_origin ~* "^http://localhost$"){
set $cors_origin $http_origin;
}

# add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Origin http://loolob.cn;
add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE,OPTIONS always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Headers DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,x-auth-token always;
add_header Access-Control-Max-Age 1728000 always;

# 预检请求处理
if ($request_method = OPTIONS) {
return 204;
}
# #设置跨域配置 End

rewrite ^(?!.*api).*$ https://$host$1;

# include /root/loolob_blog/hexo_blog/nginx.conf;
include /root/loolob_blog/live2d_api/nginx.conf;
include /root/loolob_blog/liry-web-go/nginx.conf;
}

index.html 修改

由于之前的代码写的接口都是发起的http请求,在https页面中请求http资源会有如下报错:

1
Mixed Content: The page at 'https://loolob.cn/' was loaded over HTTPS, but requested an insecure XM...

解决方法:

  1. 修改代码中的接口,改为都发起https请求
  2. 修改index.html加入如下字段,自动将http的不安全请求升级为https
    1
    <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

hexo 工程需要在 head.swig 这个文件中添加,如下:

1
2
3
4
5
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=2">
<meta name="theme-color" content="{{ theme.android_chrome_color }}">
<meta name="generator" content="Hexo {{ hexo_version }}">
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">

react 工程需要在 index.html 这个文件中添加,如下:

1
2
3
4
5
6
7
8
9
10
11
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta http-equiv="Access-Control-Allow-Origin" content="*" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests">
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />

Go 路由静态文件

由于Go本身自带路由静态文件的功能,便将React工程和Hexo工程都交由Go进行静态路由

原Hexo工程自带的的NodeJs Server弃用

  • 注意:系统内部的各个server还是交由Nginx进行反向代理,所以go server依旧监听本地端口
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
// 路由指定目录下的全部文件和文件夹

var PathStatic []StaticRoot = []StaticRoot{
{Router: "/", Path: "../hexo_blog/public/"},
{Router: "/file/", Path: "../liry-web-tool/build/"},
}

func (u *StaticDir) Bind() {

for _, item := range config.PathStatic {
dirs, err := ioutil.ReadDir(item.Path)
if err != nil {
log.Println("读取静态文件夹失败:", item.Path)
continue
}

for _, fi := range dirs {
fi.Name()
if fi.IsDir() {
u.Ge.Static(item.Router+fi.Name(), item.Path+fi.Name())
} else {
u.Ge.StaticFile(item.Router+fi.Name(), item.Path+fi.Name())
}
}
u.Ge.StaticFile(item.Router, item.Path+"index.html")
}
}

启动 Go Server

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
func main() {
r := gin.Default()
r.MaxMultipartMemory = 512 << 20 // 512 MiB

(&routers.StaticDir{
Routers: &routers.Routers{Ge: r},
}).Bind()

(&routers.Upload{
Routers: &routers.Routers{Ge: r},
}).Bind()

(&routers.FileList{
Routers: &routers.Routers{Ge: r},
}).Bind()

(&routers.Download{
Routers: &routers.Routers{Ge: r},
}).Bind()

(&routers.Delete{
Routers: &routers.Routers{Ge: r},
}).Bind()

r.Run("127.0.0.1:47777") // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}

Go Nginx配置

Go 监听本地47777端口,对外交由Nginx

1
2
3
4
5
6
location / {
root html;
index index.html index.htm;

proxy_pass http://127.0.0.1:47777;
}

pm2 托管 go server

直接用pm2启动go的可执行程序即可,不用额外编写脚本

1
pm2 start ./xxxxx

Go 环境部署

Linux下环境配置

官网获取Linux下的发布包

编辑 ~/.bashrc 文件新增如下配置

1
2
3
export GOROOT=/root/go
export GOPATH=/root/go
export GOPATH=$GOPATH:/root/loolob_blog/liry_web_go/

刷新配置

1
source ~/.bashrc
  • 注意:GOROOT为go发布包所在路径,GOPATH除了有发布包所在路径外,还要有自己的go工程的所在目录

Web Server 开发

Go Web Server 开发,采用轻量级的Web框架Gin

文件路由

文件服务器主要功能是上传下载和获取文件列表,核心接口为”/upload”,”/list”,”/download”

拉取文件列表

基本逻辑就是,server启动时读取本地有哪些文件,然后生成一个数据结构保存在内存中

收到list的Post请求后,将文件信息封装成一个json返回给前端

1
2
3
4
5
6
7
8
9
10
11
func (u *FileList) Bind() {
u.Ge.GET("/api/list", u.exec)
}

func (u *FileList) exec(c *gin.Context) {
log.Println("请求文件列表")

items := api.FileListCtr.Items

c.JSON(http.StatusOK, items)
}
处理文件上传

收到upload的Post请求后,将接受到的文件存到本地

唯一注意点就是,为防止文件重名和相同文件重复上传,存文件到本地时,将文件名改为其MD5值存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (u *Upload) Bind() {
u.Ge.POST("/api/upload", u.exec)
}

func (u *Upload) exec(c *gin.Context) {
log.Println("请求上传文件")

// Multipart form
form, _ := c.MultipartForm()
files := form.File["upload[]"]

if len(files) > 0 {
for _, file := range files {
log.Println("收到多个文件:", file.Filename)
u.saveUploadedFile(file)
}
} else {
file, _ := c.FormFile("file")
log.Println("收到单个文件:", file.Filename)
u.saveUploadedFile(file)
}

c.String(http.StatusOK, fmt.Sprintf("%d files uploaded!", len(files)))
}
处理文件下载

收到download的Get请求后,将本地文件丢给浏览器

唯一注意的点就是,需要用FileAttachment回传文件给浏览器,这个方法可以指定文件的名字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (u *Download) Bind() {
u.Ge.GET("/api/download", u.execGet)
}

func (u *Download) execGet(c *gin.Context) {
log.Println("请求下载文件")
md5 := c.Query("md5")
if len(md5) <= 0 {
log.Println("下载请求解析失败")
c.String(http.StatusNotFound, "获取文件失败")
return
}

items := api.FileListCtr.Items

it, _ := utils.ListFind(items, func(i interface{}) bool { return i.(config.FileItem).Md5 == md5 })
if it == nil {
log.Println("下载请求解析失败")
c.String(http.StatusNotFound, "获取文件失败")
return
}
log.Println("下载请求解析完成:", it.(config.FileItem).Name)
c.FileAttachment(config.PahtSave+it.(config.FileItem).Md5, it.(config.FileItem).Name)
}

工程发布

1
go build

安装React框架以及相关库

安装相关库

1
npm install react

安装react工具

1
npm install g react-create-app

工程初始化

采用TypeScript开发

使用react工具创建TS模板工程

1
npx create-react-app xxx --template typescript

引入UI库 Ant

引入现成的UI库 Ant 减少工作量

1
npm install antd --save

Ant组件使用

文件上传输入框

采用Ant现成的组件Dragger

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
render(): React.ReactNode {
return <div style={{ 'marginLeft': "5%", "marginRight": "5%" }}>
<Dragger
name='file'
multiple={true}
action={API.upload}
headers={{ "Access-Control-Allow-Origin": "*" }}
onChange={(info) => {
const { status } = info.file;
console.log("onChange", info)
if (status !== 'uploading') {
console.log(info.file, info.fileList);
}
if (status === 'done') {
message.success(`${info.file.name} file uploaded successfully.`);
Actions.UpdateFileList(this.props.updateFileList)
} else if (status === 'error') {
message.error(`${info.file.name} file upload failed.`);
}
}}
onDrop={(e) => {
console.log('Dropped files', e.dataTransfer.files);
}}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或将文件拖动到此处进行上传</p>
<p className="ant-upload-hint">
支持单次或批量上传
</p>
</Dragger>
</div >
}
文件列表

采用Ant现成的组件List

1
2
3
4
5
6
7
8
9
10
11
12
13
14
render(): React.ReactNode {
return <div style={{ 'marginLeft': "5%", "marginRight": "5%" }}>
<List
className='MyList'
itemLayout='vertical'
size='default'
split={true}
dataSource={this.props.data}
pagination={this.getPaginationConfig()}
renderItem={this.renderItem.bind(this)}
>
</List>
</div>
}

引入Redux框架

文件页面需要从server拉取文件列表以刷新界面,考虑到会动态刷界面,引入Redux框架管理数据

1
2
3
npm install redux
npm install react-redux
npm install @reduxjs/toolkit
  • 注意:新版Redux框架的Reducer使用方式可以参考toolkit

Redux使用示例

Action 生成

Action 类似发起的一个事件,由Reducer接受,并接受Aciton携带的数据。将收到的数据更新进store根数据中,此时使用对应数据的界面也会随之刷新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 以从服务器拉取文件列表为例
* 1. 发起requst,从服务器拉取到文件列表的json信息
* 2. 收到json信息后,用dispatch方法将这个json发送出去
*/
static UpdateFileList(dispatch: Function) {
API.json(API.fileList, (result: any, error: any) => {
if (error != null) {
console.log("UpdateFileList Error:", error)
return
}
if (!(result instanceof Array)) {
console.log("UpdateFileList Error:", result)
return
}
dispatch(result)
})
}

Action 发起与使用,和dispatch方法的使用,都需要先在组件中绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 绑定store中的数据至界面中
// 随后组件中便可以以 this.props.data 的方式使用数据
// 且当store中的data数据刷新时,相关页面也会随之刷新
const mapStateToProps = (state: any) => {
return {
data: state.fileList.items,
};
};

// 绑定dispatch方法,用来发起事件
// 当调用 this.props.updateFileList(data) 时,便会刷新store中的数据
const mapDispatchToProps = (dispatch: any) => {
return {
updateFileList: (data: any) => dispatch(update(data))
};
};

export default connect(mapStateToProps, mapDispatchToProps)(FileList)

List组件加载完成后,发起Action事件,从服务器拉文件列表

1
2
3
componentDidMount() {
Actions.UpdateFileList(this.props.updateFileList)
}
Reducer 接受数据

借助toolkit中的方法快速生成一个reducer

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
// 生成一个名为fileList的子数据结构
// 该数据结构下仅含一个名为items的列表,用来存放从服务器拉取的文件信息
// reducers.update方法,声明了items这个数据的更新方式:即收到数据后,按时间排序,然后直接赋值给items
export const FileListSlice = createSlice({
name: "fileList",
initialState: {
items: [],
},
reducers: {
update: (state, action) => {
action.payload.sort((a: FileItem, b: FileItem) => {
let ta = new Date(a.update)
let tb = new Date(b.update)
return tb.getTime() - ta.getTime()
})
state.items = action.payload
},
}
})

// 导出reducers.update方法,这个方法专门用来更新items数据
export const {
update,
} = FileListSlice.actions

export default FileListSlice.reducer
store 主数据

每个react工程,仅有一个store数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const store = configureStore({
reducer: {
fileList: FileListSlice,
}
})

function App() {
return (
<Provider store={store}>
<div className="App">
<FilePage></FilePage>
</div>
</Provider>
);
}

文件的上传与下载

文件上传

文件上传工程由Ant自带的Dragger组件实现,设置正确的上传地址即可

1
2
3
4
5
6
7
8
9
10
11
render(): React.ReactNode {
return <Dragger
// ......
action={API.upload}
// ......
>
{
// ......
}
</Dragger>
}
文件下载

文件下载借助html的 标签即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 发起下载请求时,构造一个</a>标签,然后设置下载url
// 注意:将href设置为url,会将下载流程转交给浏览器
// 此时浏览器会接管整个下载流程,无需再实现下载进度条和文件分块等逻辑
static DownloadFile(data: any, callback: Function) {
const fileName = data.name;
const linkNode = document.createElement('a');
linkNode.download = fileName; //a标签的download属性规定下载文件的名称
linkNode.style.display = 'none';
linkNode.href = `${API.download}?md5=${data.md5}`
document.body.appendChild(linkNode);
linkNode.click(); //模拟在按钮上的一次鼠标单击
URL.revokeObjectURL(linkNode.href); // 释放URL 对象
document.body.removeChild(linkNode);
}

发布工程

1
npm run build

将发布Blog的云服务器交由nginx代理

  • 对外端口由nginx进行监听

  • hexo server运行于本地,由nginx进行反向代理

  • php-fpm运行于本地,由nginx进行反向代理

nginx代理php

给live2d提供模型数据接口以及资源的php已由 nginx代理。php服务器监听本地9000端口。

测试nginx可配置分离,根目录下指定监听端口,通用配置等

根目录配置如下

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
# 指定跨域,反向代理的配置用include指定
http {

server {
listen 80;
server_name loolob

# 设置跨域配置 Start
set $cors_origin "";
if ($http_origin ~* "^http://localhost$"){
set $cors_origin $http_origin;
}

add_header Access-Control-Allow-Origin http://loolob.cn;
add_header Access-Control-Allow-Methods GET,POST,PUT,DELETE,OPTIONS always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Headers DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,x-auth-token always;
add_header Access-Control-Max-Age 1728000 always;

# 预检请求处理
if ($request_method = OPTIONS) {
return 204;
}
# #设置跨域配置 End

include /root/loolob_blog/live2d_api/nginx.conf;
}
}

php代理配置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 匹配/live2d_api路径,并在/root/loolob_blog目录下寻找文件
# 实际的文件在/root/loolob_blog/live2d_api/目录下
location /live2d_api/ {
root /root/loolob_blog/;
index index.php index.html index.htm;
}

# 匹配所有以.php结尾的文件
location ~ \.php$ {
root /root/loolob_blog/;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}

nginx代理hexo

改造hexo server启动命令

将hexo server进程交给pm2进行管理,为此需要手动添加一个中间脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const { fork } = require('child_process')

// hexo server监听地址localhost:47777,不直接对外
const hexo_server = fork("./node_modules/hexo/bin/hexo", ["s", "-i", "localhost", "-p", "47777", "-l"]);

hexo_server.on('message', function (m) {
console.log('message from hexo server:' + m);
});

// hexo server异常关闭时,关闭此中间脚本
// 由于此脚本托管给了pm2,pm2会自动重启非主动关闭的脚本
hexo_server.on('close', function () {
process.exit()
})

process.on('message', function (m) {
console.log('message from pm2: ' + m);
});

// 监听来自pm2中断消息,当pm2关闭时,同时关闭hexo server进程
// 防止pm2停止此中间脚本了,但hexo server还在运行的情况
process.on('SIGINT', function () {
hexo_server.kill('SIGINT')
});

添加nginx对hexo server的代理

1
2
3
4
5
6
# 直接转发
location / {
root html;
index index.html index.htm;
proxy_pass http://localhost.com:47777;
}

Android通过LinearLayout + RelativeLayout实现自适应布局

自适应布局要求切换不同大小的屏幕时,布局中控件所占区域的位置和比例不变

RelativeLayout可以控制控件相对位置,但无法指定控件所占空间比例,单RelativeLayout布局无法应对屏幕大小变化的情况

LinearLayout可以对其中的子控件指定layout_weightlayout_height属性,LinearLayout和RelativeLayout相互嵌套的方式可以完成自适应界面

例:上下结构(左右同理)

将屏幕分为上下或左右结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 指定垂直布局 -->
<!-- 需要指定子控件高度为0dp,否则layout_weight不生效 -->

<!-- 上半 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
</RelativeLayout>

<!-- 下半 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
</RelativeLayout>
</LinearLayout>

例:嵌套界面实现底部Banner

在界面下方居中处,放置banner图片及退出按钮

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<!-- 上占位空白 -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.85"
android:visibility="invisible" />

<!-- Banner区 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.13"
android:orientation="horizontal" >

<!-- 左占位空白 -->
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="0.05"
android:visibility="invisible" />

<!-- Banner区 -->
<RelativeLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="0.9"
android:background="#FFBDBDBD">

<!-- 模拟Banner退出按钮 -->
<FrameLayout
android:layout_width="15dp"
android:layout_height="15dp"
android:background="#FF0000"
android:layout_alignParentEnd="true"
android:layout_marginRight="10dp"
android:layout_marginTop="10dp" />

<!-- Banner图片 -->
<ImageView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="fitXY" />
</RelativeLayout>

<!-- 右边占位空白 -->
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="0.05"
android:visibility="invisible" />
</LinearLayout>

<!-- 下占位空白 -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="0.02"
android:visibility="invisible" />

</LinearLayout>