0%

升讯威在线客服与营销系统官方在线文档

安装

  1. 下载私有化部署包

  2. 解压下载的压缩包

  3. 在解压的目录新建一个Dockerfile

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    FROM centos:centos7.9.2009

    WORKDIR /wwwroot

    COPY init.sh /tmp/sh/
    COPY appsettings.json /tmp/config/
    COPY Management /wwwroot/Management
    COPY Resource /wwwroot/Resource
    COPY Server /wwwroot/Server

    RUN rpm -Uvh https://packages.microsoft.com/config/rhel/7/packages-microsoft-prod.rpm && yum -y install dnf && dnf install dotnet-sdk-3.1 -y && rpm -ivh https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm && yum install libgdiplus-devel nginx -y && ln -s /usr/lib64/libgdiplus.so /usr/lib/gdiplus.dll && ln -s /usr/lib64/libgdiplus.so /usr/lib64/gdiplus.dll && chmod +x /tmp/sh/init.sh && systemctl enable nginx

    COPY default.conf /etc/nginx/conf.d/

    EXPOSE 80

    ENTRYPOINT ["/tmp/sh/init.sh"]
  4. 创建初始化启动脚本,在解压的目录新建一个init.sh

    1
    2
    3
    4
    5
    6
    #!/bin/bash
    cp /tmp/config/appsettings.json /wwwroot/Server/appsettings.json
    nginx
    nginx -s reload
    cd /wwwroot/Server
    dotnet Sheng.Linkup.Server.dll
  5. nginx代理配置,在解压的目录新建一个default.conf

    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
    69
    70
    71
    72
    # https://kf.shengxunwei.com
    # Start

    map $http_upgrade $connection_upgrade {
    default upgrade;
    '' close;
    }

    upstream dotnet_server_proxy {
    server localhost:5000;
    keepalive 2000;
    }

    # kf-api (Server)
    server{
    listen 80;
    listen [::]:80;

    server_name kf-api.domain.io;

    location / {
    proxy_pass http://dotnet_server_proxy;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection keep-alive;
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
    proxy_set_header X-Forwarded-For $remote_addr;

    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;

    }
    }


    # kf-resource (Resource)
    server {
    listen 80;
    server_name kf-resource.domain.io;

    location / {
    root /wwwroot/Resource;
    index v.html;
    }

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
    root html;
    }

    }

    # kf-m (Management)
    server {
    listen 80;
    server_name kf-m.domain.io;

    location / {
    root /wwwroot/Management;
    index index.html;
    }

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
    root html;
    }

    }

    # End
    # https://kf.shengxunwei.com
  6. 在解压的目录新建一个appsettings.json配置,里面进行mysql数据库配置,初始化脚本在:数据库建表脚本/CreateDatabase_MySql.sql

    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
    {
    "Version": 3,
    "Logging": {
    "LogLevel": {
    "Default": "Information",
    "Microsoft": "Warning",
    "Microsoft.Hosting.Lifetime": "Information"
    }
    },
    "AllowedHosts": "*",
    "DatabaseEngine": "mysql",
    "ConnectionStrings": {
    "DefaultConnection": "Server=172.1.1.2;database=kf_shengxunwei;user=root;password=Tech@2023"
    },
    "AppSettings": {
    "FileStore": {
    "Store": "static",
    "AliOSS": {
    "AccessKeyId": null,
    "AccessKeySecret": null,
    "BucketName": null,
    "PublicAddress": null,
    "EndPoint": null
    }
    },
    "Email": {
    "Account": "",
    "Password": ""
    },
    "Translation": {
    "Engine": "",
    "Youdao": {
    "AppKey": null,
    "AppSecret": null
    },
    "Baidu": {
    "AppId": null,
    "AppSecret": null
    }
    },
    "Environment": {
    "DefaultLanguage": "zh-CHS",
    "CustomerContext": {
    "HeartbeatTimeoutSecond": 70,
    "CellphoneHeartbeatTimeoutSecond": 70,
    "OfflineOverSecond": 70
    },
    "TcpIpAddress": "172.1.1.44",
    "TcpPort": "9527",
    "ResourceAddress": "http://kf-resource.domain.com.cn",
    "HostAddress": "http://kf-api.domian.com.cn"
    }
    }
    }
  7. 执行docker build -t harbor.domain.dev/base/shengxunwei:1.3 .

  8. 运行docker run harbor.domain.dev/base/shengxunwei:1.3

  9. 访问https://kf-api.domain.io/Status

  10. 初始化访问https://kf-api.domain.io/Status/Setup

配置

Resource对应客户访问的界面

Management对应客服管理后台界面

ApiUrlhttp://kf-api.domain.io

ResourceUrlhttp://kf-resource.domain.io

管理后台地址http://kf-m.domain.io

需要修改下面几个文件里面的ApiUrl和ResourceUrl

1
2
3
/wwwroot/Resource/embedded.js
/wwwroot/Resource/WebChat/Config.js
/wwwroot/Management/config.js

使用

客户访问http://kf-resource.domain.io/WebChat/MobileWebChat.html?siteCode=freesite

客服下载客户端,两个就能进行聊天了

window系统免费安装软件制作系统,用于打包封装nginx、emqx、mysql、redis、springboot等合成一个windows的exe安装包。

安装脚本

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
[Setup]

#define MyAppName "myapp"
#define MyAppVersion "1.0"
#define MyAppPublisher "LP"
#define MyAppURL "http://www.lp.top:8090/"
#define MyAppExeName "myapp.exe"

[Setup]
AppId= {{ED9D5968-F178-48C5-AC61-2AED59BBCEF6}} //每次编译需要修改该uuid
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={pf}\{#MyAppName}
DefaultGroupName={#MyAppName}
DisableDirPage=yes
OutputDir=D:\innosetup\output
OutputBaseFilename=gateId
Compression=lzma
SolidCompression=yes
LicenseFile=D:\workspace\license.rtf #软件安装申明
;AlwaysRestart=yes
;PrivilegesRequired=admin

[Code]
//#############################自定义函数###################################
// 判断单个端口占用
function IsNotPortOccupation(strPortNum: String): Boolean;
// 变量定义
var ErrorCode: Integer;
var bRes: Boolean;
var strFileContent: AnsiString;
var strTmpPath: String; // 临时目录
var strTmpFile: String; // 临时文件,保存查找软件数据结果
var strCmdFind: String; // 查找端口命令, netstat -natp tcp |findstr "LISTENING" |findstr ":80 "| find /C ":80 " 找到的话返回个数,找不到为0
begin
strTmpPath := GetTempDir();
strTmpFile := Format('%sfindProtRes.txt', [strTmpPath]);

strCmdFind := Format('/c netstat -natp tcp |findstr "LISTENING" |findstr ":%s "|find /C ":%s " > "%s "', [strPortNum, strPortNum ,strTmpFile]);
log(strCmdFind);

bRes := ShellExec('open', ExpandConstant('{cmd}'), strCmdFind, '', SW_HIDE, ewWaitUntilTerminated, ErrorCode);
bRes := LoadStringFromFile(strTmpFile, strFileContent);
strFileContent := Trim(strFileContent);
if StrToInt(strFileContent) > 0 then begin
result:=true;
end else result:=false;
end;
// 判断多个端口占用
function IsNotPortsOccupation(const portList: array of String): array of String;
var
i: Integer;
begin
for i := 0 to GetArrayLength(portList)-1 do
begin
if IsNotPortOccupation(portList[i]) then
begin
SetLength(Result,Length(Result)+1);
Result[High(Result)] := portList[i];
end;
end;
end;
// 数组转换为字符串
function JoinArrayToString(arr: array of String; delimiter: String): String;
var
i: Integer;
begin
Result := '';
for i := 0 to GetArrayLength(arr) - 1 do
begin
if Result <> '' then
Result := Result + delimiter;
Result := Result + arr[i];
end;
end;
// 检查是否需要初始化数据
function IsInitMySqlData: Boolean;
begin
if MsgBox('存在历史数据,是否初始化历史数据?',mbInformation,MB_YESNO) = IDYES then
begin
Result := true;
end else
Result := false;
end;
//#############################自定义界面###########################################
//----------------安装类型界面-------------------------------------
var
Page: TWizardPage;

RadioButton1, RadioButton2, RadioButton3, RadioButton4: TRadioButton;
Lbl1, Lbl2: TNewStaticText;


procedure CreateTypeSelectPage;
begin
Page := CreateCustomPage(wpInfoBefore, '选择安装类型', '请根据您的需要选择安装的类型');

RadioButton1 := TRadioButton.Create(Page);
RadioButton1.Left := ScaleX(80);
RadioButton1.Top := ScaleY(40);
RadioButton1.Width := Page.SurfaceWidth;
RadioButton1.Height := ScaleY(17);
RadioButton1.Caption := '标准安装';
RadioButton1.Checked := True;
RadioButton1.Parent := Page.Surface;

Lbl1 := TNewStaticText.Create(Page);
Lbl1.Left := ScaleX(95);
Lbl1.Top := ScaleY(60);
Lbl1.Width := ScaleX(250);
Lbl1.Height := ScaleY(50);
Lbl1.Caption := '按照标准模式安装软件到您的电脑';
Lbl1.Parent := Page.Surface;

RadioButton2 := TRadioButton.Create(Page);
RadioButton2.Left := ScaleX(80);
RadioButton2.Top := RadioButton1.Top + ScaleY(60);
RadioButton2.Width := Page.SurfaceWidth;
RadioButton2.Height := ScaleY(17);
RadioButton2.Caption := '自定义安装';
RadioButton2.Checked := false;
RadioButton2.Parent := Page.Surface;

Lbl2 := TNewStaticText.Create(Page);
Lbl2.Left := ScaleX(95);
Lbl2.Top := Lbl1.Top + ScaleY(60);
Lbl2.Width := ScaleX(250);
Lbl2.Height := ScaleY(50);
Lbl2.Caption := '您可以选择单个安装项,建议经验丰富的用户使用';
Lbl2.Parent := Page.Surface;
end;
//------------------------安装状态界面--------------------------------------------
procedure CreateShowStatusPage;
begin
Page := CreateCustomPage(wpInfoAfter, '等待服务状态', '请耐心等待服务启动完成');
RadioButton3 := TRadioButton.Create(Page);
RadioButton3.Left := ScaleX(80);
RadioButton3.Top := ScaleY(40);
RadioButton3.Width := Page.SurfaceWidth;
RadioButton3.Height := ScaleY(17);
RadioButton3.Caption := 'redis';
RadioButton3.Checked := false;
RadioButton3.Parent := Page.Surface;

RadioButton4 := TRadioButton.Create(Page);
RadioButton4.Left := ScaleX(80);
RadioButton4.Top := RadioButton1.Top + ScaleY(60);
RadioButton4.Width := Page.SurfaceWidth;
RadioButton4.Height := ScaleY(17);
RadioButton4.Caption := 'emqx';
RadioButton4.Checked := false;
RadioButton4.Parent := Page.Surface;
end;
//-------------------------参数配置界面--------------------------------------------


//#############################安装开始时掉用###################################
procedure InitializeWizard();
var
UsePorts: array of String;
UsePortsMessage: String;
begin
UsePorts := IsNotPortsOccupation(['80','1883','8083','8084','8883','18083','3306','6379','8080','8081','8082','8085']);
if Length(UsePorts)>0 then //检测这些端口是否占用,包含nginx、emqx、mysql、redis、springboot
begin
UsePortsMessage := '占用的端口:'+ JoinArrayToString(UsePorts,',');
MsgBox(UsePortsMessage, mbInformation, MB_OK);
Abort;
end;
CreateTypeSelectPage;
CreateShowStatusPage;
end;

function ShouldSkipPage(PageID: Integer): Boolean; //是否跳过组件选择界面
begin
if (PageID = wpSelectComponents) and (RadioButton1.Checked) then
Result := True
else if (PageID = wpSelectProgramGroup) and (RadioButton1.Checked) then
Result := True
end;
//#############################安装结束时掉用#########################################
procedure CurStepChanged(CurStep: TSetupStep);
var
UsePorts: array of String;
UsePortsMessage: string;
begin
if CurStep = ssPostInstall then
begin
UsePorts := IsNotPortsOccupation(['80','1883','8083','8084','8883','18083','3306','6379','8080','8081','8082','8085']);
if Length(UsePorts)<12 then //检测服务是否正常运行,有些服务还没启动成功,这里就已经运行了,会导致一些服务检测不到,而且检测到没运行,也不支持安装回退
begin
UsePortsMessage := '启动成功的服务端口:'+ JoinArrayToString(UsePorts,',');
MsgBox(UsePortsMessage, mbInformation, MB_OK);
Abort;
end;
end;
end;

[Types]
Name: "Custom"; Description: "Custom Installation"; Flags: iscustom

[Components]
Name: MySql; Description: MySql ; Types: Custom //设置组件树(安装选择组件界面显示的内容)
Name: Redis; Description: Redis ; Types: Custom
Name: Emqx; Description: Emqx ; Types: Custom
Name: Nginx; Description: Nginx ; Types: Custom
Name: Java; Description: Java ; Types: Custom
//通过 exclusive 完成 DMR、XPT 的互斥选择
Name: Java\Gateway; Description: Gateway; Types: Custom;
Name: Java\Gateid; Description: Gateid; Types: Custom;
Name: Java\Base; Description: Base; Types: Custom;
Name: Java\Auth; Description: Auth; Types: Custom;

[Languages]
Name: "chinesesimp"; MessagesFile: "compiler:Default.isl"

[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 0,6.1

[Files]
Source: "D:\workspace\mysql\*"; DestDir: "{app}\mysql"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: MySql
Source: "D:\workspace\redis\*"; DestDir: "{app}\redis"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: Redis
Source: "D:\workspace\emqx\*"; DestDir: "{app}\emqx"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: Emqx
Source: "D:\workspace\nginx\*"; DestDir: "{app}\nginx"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: Nginx
Source: "D:\workspace\project\*"; DestDir: "{app}\project"; Flags: ignoreversion recursesubdirs createallsubdirs;Components: Java
; 注意: 不要在任何共享系统文件上使用“Flags: ignoreversion ?

[Icons]
; Name: "{commondesktop}\project";Filename: "{app}\project\start.exe"; WorkingDir: "{app}\HSDServer"

[INI]
;修改数据库配置文
Filename:"{app}\mysql\my.ini";Section:"mysqld";Key:"basedir"; String:"{app}\mysql";Components:MySql;
Filename:"{app}\mysql\my.ini";Section:"mysqld";Key:"datadir"; String:"{app}\mysqlData";Components:MySql;
Filename:"{app}\mysql\my.ini";Section:"mysqld";Key:"port"; String:"3306";Components:MySql;
Filename:"{app}\mysql\my.ini";Section:"client";Key:"port"; String:"3306";Components:MySql;

[Run] //安装脚本,包含数据初始化脚本
Filename: "{app}\mysql\bin\mysql_install.bat";Components:MySql;
Filename: "{app}\mysql\bin\mysql_init.bat";Components:MySql;Check:IsInitMySqlData;
Filename: "{app}\redis\init-redis.bat";Components:Redis;
Filename: "{app}\redis\start-redis.bat";Components:Redis;
Filename: "{app}\emqx\bin\mqttSatrt.bat";Components:Emqx;
Filename: "{app}\nginx\startNginx.bat";Components:Nginx;
Filename: "{app}\project\start.bat";Components:Java;

[UninstallRun] //卸载执行的脚本
Filename: "{app}\mysql\bin\mysql_stop.bat";Components:MySql;
Filename: "{app}\redis\stop-redis.bat";Components:Redis;
Filename: "{app}\emqx\bin\mqttStop.bat";Components:Emqx;
Filename: "{app}\nginx\stopNginx.bat";Components:Nginx;
Filename: "{app}\project\stop.bat";Components:Java;

[UninstallDelete] //删除目录
Type:filesandordirs;Name:"{app}\mysql";Components:MySql;
Type:filesandordirs;Name:"{app}\redis";Components:Redis;
Type:filesandordirs;Name:"{app}\emqx";Components:Emqx;
Type:filesandordirs;Name:"{app}\nginx";Components:Nginx;
Type:filesandordirs;Name:"{app}\project";Components:Java;

[Code]
//卸载时,弹窗提示删除数据
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
var
DeleteProfile: string;
DeleteConfirm: Boolean;
ErrorCode: Integer;
begin
case CurUninstallStep of
//卸载后的收尾工作
usPostUninstall:begin
// 确认是否删除整个目录
DeleteProfile := ExpandConstant('{app}');
DeleteConfirm :=MsgBox('是否保留数据库数据?',mbConfirmation,MB_YESNO) = idYes;
if DeleteConfirm=False then
DelTree(ExpandConstant('{app}'), True, True, True);
end;
end;
end;

参考:

Inno Setup添加自定义页面

使用innoSetup将mysql+nginx+redis+jar包打包成windows安装包

go入门

docker打包构建部署

  1. 创建一个项目目录和两个文件,结构如下

    1
    2
    3
    go-hello
    |-hello.go
    |-Dockerfile
  2. 文件内容分别如下

    hello.go文件内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    package main

    import (
    "fmt"
    "log"
    "net/http"
    )

    func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello World")
    }

    func main() {
    http.HandleFunc("/", hello)
    if err := http.ListenAndServe(":8080", nil); err != nil {
    log.Fatal(err)
    }
    }

    Dockerfile文件内容:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    FROM golang:1.21.3-alpine AS builder
    WORKDIR /build
    ADD . /build
    RUN go build -o hello ./hello.go

    FROM alpine
    WORKDIR /build
    COPY --from=builder /build/hello /build/hello
    EXPOSE 8080
    CMD ["./hello"]
  3. 在项目跟目录(go-hello),执行docker build -t hello:latest .进行镜像打包

  4. 运行镜像docker run -p 8080:8080 hello:latest然后访问http://127.0.0.1:8080/

手动测试

  1. 在项目跟目录执行go build -o hello ./hello.go生成hello可执行文件
  2. 执行./hello,然后访问http://127.0.0.1:8080/

导入github包

  1. 在项目跟目录执行go mod init go-hello,会生成一个go.modgo.sum的文件,文件内容如下:

    go.mod

    1
    2
    3
    4
    module qiniu_go
    go 1.21.3
    require github.com/qiniu/go-sdk/v7 v7.19.0
    require golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect

    go.sum

    1
    2
    3
    github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
    github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
    .....
  2. 在项目跟目录执行go mod tidy,删除错误或者不使用的modules

实战

写一个接口将接口的json数据保存到七牛云,支持修改

main.go,dockerfile根据hello的例子进行修改即可

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"

"github.com/qiniu/go-sdk/v7/auth"
"github.com/qiniu/go-sdk/v7/storage"
)

func upload(writer http.ResponseWriter, request *http.Request) {
// 检查请求方法是否为POST
if request.Method != http.MethodPost {
http.Error(writer, "Invalid request method", http.StatusMethodNotAllowed)
return
}

// 读取请求体
body, err := io.ReadAll(request.Body)
if err != nil {
http.Error(writer, "Error reading request body", http.StatusInternalServerError)
return
}

// 解析JSON数据
var jsonData map[string]interface{}
err = json.Unmarshal(body, &jsonData)
if err != nil {
http.Error(writer, "Error decoding JSON", http.StatusBadRequest)
return
}

// 打印接收到的JSON数据
fmt.Printf("Received JSON: %+v\n", jsonData)

// 可选:将JSON数据转为字节
data, err := json.Marshal(jsonData)
if err != nil {
http.Error(writer, "Error encoding JSON", http.StatusInternalServerError)
return
}

accessKey := ""
secretKey := ""
bucket := "test"
keyToOverwrite := "3D/3d.json"

putPolicy := storage.PutPolicy{
Scope: fmt.Sprintf("%s:%s", bucket, keyToOverwrite),
}
mac := auth.New(accessKey, secretKey)
upToken := putPolicy.UploadToken(mac)

cfg := storage.Config{}
// 空间对应的机房
cfg.Region = &storage.ZoneHuanan
// 是否使用https域名
cfg.UseHTTPS = true
// 上传是否使用CDN上传加速
cfg.UseCdnDomains = false

formUploader := storage.NewFormUploader(&cfg)
ret := storage.PutRet{}
putExtra := storage.PutExtra{
Params: map[string]string{
"x:name": "github logo",
},
}
//data := []byte("hello, this is qiniu cloud")
dataLen := int64(len(data))
qErr := formUploader.Put(context.Background(), &ret, upToken, keyToOverwrite, bytes.NewReader(data), dataLen, &putExtra)
if err != nil {
fmt.Println("err:", qErr)
return
}
fmt.Println(ret.Key, ret.Hash)
fmt.Fprintf(writer, "https://qiniu.xx.com/"+keyToOverwrite)
}

func main() {
fmt.Println("service start")
http.HandleFunc("/upload", upload)
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}

}

时间同步的两种方式chrony vs ntp

ntp:传统的时间同步配置,既可以当服务端,也可以当客户端

chrony:新式时间配置,采用微调修改同步时间

常见命令

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
#查看时间
[root@master ~]# date
#查看时间详情
[root@master ~]# timedatectl
Local time: 三 2024-01-10 00:27:35 CST #本地时间,初始值来自于RTC,通常等于RTC+时区值(如本地时间= RTC + 8小时)
Universal time: 二 2024-01-09 16:27:35 UTC #世界时间,中国时区=UTC+8
RTC time: 二 2024-01-09 17:50:38 #BIOS时间,硬件时间
Time zone: Asia/Shanghai (CST, +0800) #时区
NTP enabled: yes #表示开启NTP同步
NTP synchronized: no #NTP是否同步完成
RTC in local TZ: no #no=RTC时间用UTC,yes=RTC时间用LOCAL time
DST active: n/a
# 默认情况下,系统配置为使用UTC,也可使用本地时间
[root@master ~]# timedatectl set-ntp truefalse #开启关闭ntp
[root@master ~]# timedatectl set-local-rtc true # 将RTC设置为本地时间
[root@master ~]# timedatectl set-local-rtc false # 将RTC设置为UTC
[root@master ~]# chronyc tracking |grep System # 查看服务器时间和NTP server偏差
System time : 23735.990234375 seconds fast of NTP time
[root@master ~]# chronyc activity #显示有多少NTP源在线/离线
200 OK
4 sources online
0 sources offline
[root@master ~]# chronyc makestep #立即手动同步时间(需要chronyd服务运行)

[root@master ~]# hwclock -w #将当前时间和日期写入BIOS,避免重启后失效(改了之后恢复)
[root@master ~]# hwclock --systohc #将系统时间写入RTC(待观察)
[root@master ~]# hwclock --localtime #显示 BIOS 中实际的时间
2024年01月09日 星期二 18时10分55秒 -0.062371 秒
[root@master ~]# systemctl status chronyd.service #查看chronyd服务是否启动

[root@master ~]# ntpdate cn.pool.ntp.org #ntp同步时间
[root@master ~]# timedatectl set-time 2023-12-28 #手动设置时间,需要关闭自动同步时间
[root@master ~]# timedatectl set-ntp no
[root@master ~]# timedatectl set-time 2023-12-28
[root@master ~]# timedatectl set-time 16:06:17
[root@master ~]# timedatectl set-timezone Asia/Shanghai #设置时区
[root@master ~]# timedatectl set-timezone UTC #将Local time设置为UTC

问题

  1. 修改时间过后,时间过一段时间又不对了

    分析,怀疑是chronyc慢慢修正成了错误时间

    解决过程:

    1. 通过把chronyc停用,换成ntpd进行同步时间,过了一天,时间还是改变了(RTC+8),发现ntpd服务在3点的时候停止了

    2. 添加时间ntpd服务是否运行的检测的脚本check_ntp.sh

      1
      2
      3
      4
      5
      6
      7
      8
      #!/bin/bash

      # 检查 NTP 服务是否正在运行
      if systemctl is-active --quiet ntpd; then
      echo "NTP service is running."
      else
      echo "NTP service is not running."
      fi
    3. 添加时间是否同步time_sync_status.sh

      1
      2
      3
      4
      5
      6
      7
      8
      #!/bin/bash

      # 检查时间是否同步
      if timedatectl | grep "NTP synchronized: yes" > /dev/null; then
      echo "System time is synchronized."
      else
      echo "System time is not synchronized."
      fi
    4. 整合服务检测,与时间同步

      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
      #!/bin/bash

      LOG_FILE="/var/log/sync_time.log"

      # 检查系统时间是否正确
      if timedatectl | grep "NTP synchronized: yes" > /dev/null; then
      echo "$(date): System time is synchronized." >> "$LOG_FILE"
      echo "$(timedatectl)" >> "$LOG_FILE"
      else
      echo "$(date): System time is not synchronized." >> "$LOG_FILE"
      echo "$(timedatectl)" >> "$LOG_FILE"
      # 检查 NTP 服务状态
      if systemctl is-active --quiet ntpd; then
      echo "$(date): NTP service is running. Synchronizing time..." >> "$LOG_FILE"
      echo "$(timedatectl)" >> "$LOG_FILE"
      systemctl stop ntpd # 停止 NTP 服务,以便手动同步时间
      ntpd -gq # 强制同步时间
      hwclock --systohc # 同步时间到 RTC
      systemctl start ntpd # 重新启动 NTP 服务
      echo "$(date): Time synchronized successfully." >> "$LOG_FILE"
      echo "$(timedatectl)" >> "$LOG_FILE"
      else
      echo "$(date): NTP service is not running. " >> "$LOG_FILE"
      echo "$(timedatectl)" >> "$LOG_FILE"
      ntpd -gq # 强制同步时间
      hwclock --systohc # 同步时间到 RTC
      systemctl start ntpd # 重新启动 NTP 服务
      echo "$(date): Time synchronized successfully." >> "$LOG_FILE"
      echo "$(timedatectl)" >> "$LOG_FILE"
      fi
      fi
    5. 创建一个定时任务crontab -e

      1
      */1 * * * * /root/sync_time.sh
    6. 后面观察日志tail -f /var/log/sync_time.log即可,执行more /var/log/sync_time.log | grep " is not " -A 28 -B 10进行查看什么时候通不过时间

参考:

chrony时间同步服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#添加用户
sudo useradd -d /home/exxk/shared_bikes_html -m -s /bin/bash bikesfe
#创建修改密码
sudo passwd bikesfe
#添加bikesfe用户只能使用sftp
sudo vim /etc/ssh/sshd_config
Match User bikesfe
ChrootDirectory /home/exxk/shared_bikes_html
ForceCommand internal-sftp
AllowTcpForwarding no
X11Forwarding no
#修改/home/exxk/shared_bikes_html目录及上层目录权限
sudo chmod 755 /home/exxk
sudo chmod 755 /home/exxk/shared_bikes_html
sudo chown root:root /home/exxk/shared_bikes_html
sudo chown root:root /home/exxk
#测试
#sftp登录成功
sftp bikesfe@172.16.10.2
#ssh登录失败
ssh bikesfe@172.16.10.2

常见问题:

  1. 不修改目录及上级目录权限会提示如下错误:
1
2
3
4
5
6
Jan 08 14:24:26 ubuntu sshd[2677787]: Accepted password for bikesfe from 172.16.30.210 port 52532 ssh2
Jan 08 14:24:26 ubuntu sshd[2677787]: pam_unix(sshd:session): session opened for user bikesfe(uid=1001) by (uid=0)
Jan 08 14:24:26 ubuntu systemd[2677793]: Listening on GnuPG cryptographic agent (ssh-agent emulation).
Jan 08 14:24:26 ubuntu sshd[2677897]: fatal: bad ownership or modes for chroot directory component "/home/hcytech/"
Jan 08 14:24:26 ubuntu sshd[2677787]: pam_unix(sshd:session): session closed for user bikesfe
Jan 08 14:24:37 ubuntu systemd[2677793]: Closed GnuPG cryptographic agent (ssh-agent emulation).

Firebase是个啥

服务端

  1. 添加依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>com.google.firebase</groupId>
    <artifactId>firebase-admin</artifactId>
    <version>9.2.0</version>
    </dependency>
  2. 在Firebase控制台,点击项目设置->服务账号->Firebase Admin SDK->选择java->生成新的私钥等待下载成功,复制下载成功的文件到项目里面

  3. 创建FireBaseConfig.java,添加初始化代码

    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
    import com.google.auth.oauth2.GoogleCredentials;
    import com.google.firebase.FirebaseApp;
    import com.google.firebase.FirebaseOptions;
    import org.springframework.boot.CommandLineRunner;
    import org.springframework.stereotype.Component;
    import java.io.FileInputStream;
    import java.io.IOException;

    @Component
    public class FireBaseConfig implements CommandLineRunner {
    @Override
    public void run(String... args) throws Exception {
    initFireBase();
    }

    private void initFireBase() throws IOException {
    //协议开放 克服握手错误
    System.setProperty("https.protocols", "TLSv1,TLSv1.1,TLSv1.2");
    System.out.println("协议开放成功: "+System.getProperty("https.protocols"));
    //firebase初始化
    FileInputStream refreshToken = new FileInputStream("path/to/serviceAccountKey.json"); //上一步下载的文件(私钥json)
    FirebaseOptions options = FirebaseOptions.builder()
    .setCredentials(GoogleCredentials.fromStream(refreshToken))
    .setDatabaseUrl("https://<DATABASE_NAME>.firebaseio.com/")
    .build();
    FirebaseApp.initializeApp(options);
    System.out.println("firebase初始化成功!!!");
    }
    }
  4. 创建工具类FireBaseUtils

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import com.google.firebase.messaging.*;
    import java.util.Map;

    public class FireBaseUtils {
    public static String sendOne(String token, String title, String body, Map<String,String> data) throws FirebaseMessagingException {
    Message message = Message.builder()
    .setToken(token)
    // .setAndroidConfig(AndroidConfig.builder()
    // .setNotification(AndroidNotification.builder().setTitle(title).setBody(body).build()).build())
    .setNotification(Notification.builder().setTitle(title).setBody(body).build())
    .putAllData(data)
    // .setNotification(Notification.builder().build())
    .build();
    String result= FirebaseMessaging.getInstance().send(message);
    System.out.println("消息发送成功: "+result);
    return result;
    }
  5. 测试发送消息

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    @GetMapping("/send")
    public String send(){
    try {
    Map<String,String> data=new HashMap<>();
    data.put("allDataKey","allDataValue");
    //设备的token由客户端传过来
    return FireBaseUtils.sendOne("dtgeiUDK<设备的token>RVhF9:APAi1_xR4JQt5dELt",
    "我是标题","我是body",data);
    } catch (FirebaseMessagingException e) {
    throw new RuntimeException(e);
    }
    }

Firebase如何进行消息推送

  1. firebase控制台创建项目

  2. 产品类别->吸引->Messaging界面,点击添加应用

  3. 项目设置->常规->您的应用查看应用接入配置

    • web版本(未成功,应该是没有token的原因)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      import { initializeApp } from "firebase/app";
      import { getAnalytics } from "firebase/analytics";
      import { getMessaging, onMessage } from "firebase/messaging"; // 接入messaging模块

      const firebaseConfig = {
      apiKey: "AIxxx9HY",
      authDomain: "fir-502c5.firebaseapp.com",
      projectId: "fir-502c5",
      storageBucket: "fir-502c5.appspot.com",
      messagingSenderId: "47xxxx14",
      appId: "1:471967859514:web:b1d4axx6cac0d748603",
      measurementId: "G-FxxK"
      };

      // Initialize Firebase
      const app = initializeApp(firebaseConfig);
      const analytics = getAnalytics(app);

      const messaging=getMessaging(app); // 接入messaging模块
      onMessage(messaging, (payload) => {
      console.log('Message received. ', payload);
      });
    • Android

      可以使用 android studio再带的firebase进行集成,点击tools->firebase后续跟着提示操作即可。

      下面介绍手动版本:

      1. 引入依赖

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        //在项目(project)build.gradle添加com.google.gms.google-services依赖
        plugins {
        id 'com.android.application' version '8.2.0' apply false
        //添加依赖
        id 'com.google.gms.google-services' version '4.3.15' apply false
        }

        //在模块(module)build.gradle添加com.google.gms.google-services依赖
        plugins {
        id 'com.android.application'
        id 'com.google.gms.google-services'
        }
        ....
        dependencies {
        ....
        implementation(platform("com.google.firebase:firebase-bom:32.3.1"))
        implementation("com.google.firebase:firebase-analytics")
        implementation("com.google.firebase:firebase-inappmessaging")
        implementation("com.google.firebase:firebase-inappmessaging-display")
        }
      2. 添加获取token的方法,服务端发送消息的时候需要该token,一般在设备登录的时候把token发给服务端

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        FirebaseMessaging.getInstance().getToken()
        .addOnCompleteListener(new OnCompleteListener<String>() {
        @Override
        public void onComplete(@NonNull Task<String> task) {
        if (!task.isSuccessful()) {
        Log.w(TAG, "Fetching FCM registration token failed", task.getException());
        return;
        }
        // Get new FCM registration token
        String token = task.getResult();
        // Log and toast
        String msg = "toke is: "+token;
        Log.i(TAG, msg);
        Toast.makeText(MainActivity.this, msg, Toast.LENGTH_SHORT).show();
        }
        });
      3. 添加接受消息的代码

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        18
        19
        20
        21
        22
        23
        import android.util.Log;
        import androidx.annotation.NonNull;
        import com.google.firebase.messaging.FirebaseMessagingService;
        import com.google.firebase.messaging.RemoteMessage;

        public class MyFirebaseMessagingService extends FirebaseMessagingService {
        private static final String TAG="MyFirebaseMessagingService";
        @Override
        public void onNewToken(@NonNull String token) {
        Log.i(TAG,"Refreshed token: " + token);
        }
        @Override
        public void onMessageReceived(@NonNull RemoteMessage message) {
        Log.d(TAG, "From: " + message.getFrom());
        //From: 471967859514
        Log.d(TAG, "AllData: "+message.getData());
        //AllData: {allDataKey=allDataValue}
        Log.d(TAG, "Notification title: "+message.getNotification().getTitle());
        //Notification title: 我是标题
        Log.d(TAG, "Notification body: "+message.getNotification().getBody());
        //Notification body: 我是body
        }
        }
  4. 制作首个宣传活动(或者新建宣传活动)

腾讯IM vs 环信IM

文档对比,腾讯IM容易理解,环信IM文档复杂

腾讯IM使用示例

数据流入流出逻辑

1
2
3
4
客户端->IM服务端:创建用户获取userid
客户端->后端:传入userid获取userSig
客户端->IM服务端:通过usersSig进行登录
客户端->IM服务端:使用聊天等其他IM api功能
  1. 登录腾讯云控制台

  2. 即时通信IM创建新应用(已有忽略)

  3. 查看SDKAppID和密钥

  4. 在IM腾讯云控制台创建用户(或者通过客户端创建)

  5. 服务端计算UserSig(建议,不在服务端生成,容易泄露SDKAppID和密钥)

    • 添加依赖(不想添加依赖,可以直接复制里面的两个文件到自己的项目)

      1
      2
      3
      4
      5
      <dependency>
      <groupId>com.github.tencentyun</groupId>
      <artifactId>tls-sig-api-v2</artifactId>
      <version>2.0</version>
      </dependency>
    • 引用工具类,生成userSig

      1
      2
      3
      4
      public static void main(String[] args) {
      TLSSigAPIv2 tlsSigAPIv2=new TLSSigAPIv2(sdkappid:1600,key:"35680781295a9");
      System.out.println(tlsSigAPIv2.genUserSig(userid:"y",expire:86400));
      }
  6. 编写客户端聊天代码,这里体验web版(快速入门(Web & H5 Vue2/Vue3)

    • 填入返回的userSig

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      // 客户端生成userSig
      TUIKit.login({
      userID: userID,
      userSig: genTestUserSig({
      SDKAppID,
      secretKey,
      userID,
      }).userSig,
      });
      // 替换为服务端生成的userSig
      TUIKit.login({
      userID: userID,
      userSig: userSig,
      });

环信IM使用示例

安装

  1. 设置镜像名emqx/emqx:5.3.1
  2. 配置nodePort端口映射:mqtt客户端连接端口1883、emqx管理界面端口18083
  3. 设置挂载(可选):挂载目录/opt/emqx/data/opt/emqx/log
  4. 访问emqx管理界面<IP>:18083,默认用户名admin,密码public
  5. 安装MQTTX客户端,输入HOST:mqtt://、Port:18083,其他信息随便输入,点击连接即可

使用场景

监听mq客户端连接或离线的状态(规则引擎)

  1. 登录emqx管理界面

  2. 点击Rules->Create

  3. SQL Editor输入

    1
    2
    3
    4
    5
    SELECT
    *
    FROM
    "$events/client_disconnected", #设备断开事件
    "$events/client_connected" #设备连接事件(如果不需要连接事件,可删)
  4. 在左侧点击Actions->Add Action

  5. 在Action选择Republish(转发消息),输入topic的名字,例如exxk/status/client,其他默认即可,Payload设置为空,空代表消息原封不动转发。

  6. 最后在代码里面订阅exxk/status/client即可,代码如下

    1
    2
    3
    4
    5
    log.info("message {}", message);
    String topic= String.valueOf(message.getHeaders().get("mqtt_receivedTopic"));
    if(topic.equals("exxk/status/client")){
    //处理在线或离线逻辑即可
    }
  7. 设备断电离线会有2分钟左右的延迟

常见问题

  1. 问题:配置挂载目录后,启动提示该日志Failed to read: "data/loaded_modules", error: enoent,无法访问管理界面,客户端正常连接。

    解决:更换版本5.3.1解决。

基础概念

云消息队列 MQTT 版

  • *QoS*

    • QoS(Quality of Service)指消息传输的服务质量。分别可在消息发送端和消息消费端设置。

      • 发送端的QoS设置:影响发送端发送消息到云消息队列 MQTT 版的传输质量。
      • 消费端的QoS设置:影响云消息队列 MQTT 版服务端投递消息到消费端的传输质量。

      QoS包括以下级别:

      • QoS0:代表最多分发一次。
      • QoS1:代表至少达到一次。
      • QoS2:代表仅分发一次。
  • *cleanSession*

    • cleanSession标志是MQTT协议中对一个消费者客户端建立TCP连接后是否关心之前状态的定义,与消息发送端的设置无关。具体语义如下:
      • cleanSession=true:消费者客户端再次上线时,将不再关心之前所有的订阅关系以及离线消息。
      • cleanSession=false:消费者客户端再次上线时,还需要处理之前的离线消息,而之前的订阅关系也会持续生效。

QoS和cleanSession搭配使用时需注意以下几点:

  • MQTT要求每个客户端每次连接时的cleanSession标志必须固定,不允许动态变化,否则会导致离线消息的判断有误。
  • MQTT目前对外QoS2消息不支持非cleanSession,如果客户端以QoS2方式订阅消息,即使设置cleanSession=false也不会生效。
  • P2P消息的cleanSession判断以接收方客户端的配置为准。

消费端QoS和cleanSession的不同组合产生的结果如QoS和cleanSession的组合关系所示。

QoS级别 cleanSession=true cleanSession=false
QoS0 无离线消息,在线消息只尝试推一次。 无离线消息,在线消息只尝试推一次。
QoS1 无离线消息,在线消息保证可达。 有离线消息,所有消息保证可达。
QoS2 无离线消息,在线消息保证可达且只接收一次。 暂不支持。

spring接入mqtt

  1. 添加依赖

    1
    2
    3
    4
    <dependency>
    <groupId>org.springframework.integration</groupId>
    <artifactId>spring-integration-mqtt</artifactId>
    </dependency>
  2. 添加mq配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Configuration
    @ConfigurationProperties(prefix = "mqtt")
    @Data
    @FieldDefaults(level = AccessLevel.PRIVATE)
    public class MqttConfigProperties {
    String host;
    String user;
    String password;
    String clientId;
    String topic;
    String sendTopic1;
    String sendTopic2;
    Integer qos;
    Integer timeout;
    Integer keepalive;
    }

    对应的配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    mqtt:
    host: tcp://172.16.1.1:1883
    user: mqtt_user_test
    password: mqtt_user_test
    qos: 0
    clientId: sys_manager
    topic: sys/position
    timeout: 10
    keepalive: 60
  3. 注入配置

    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
    69
    70
    71
    72
    73
    @Configuration
    @Slf4j
    public class MqttConfiguration {
    @Resource
    private MqttConfigProperties mqttConfigProperties;
    @Bean
    public MessageChannel mqttInputChannel() {
    return new DirectChannel();
    }
    @Bean
    public MessageChannel mqttOutputChannel() {
    return new DirectChannel();
    }
    /***
    * mqtt连接配置
    */
    @Bean
    public MqttPahoClientFactory mqttPahoClientFactory() {
    DefaultMqttPahoClientFactory defaultMqttPahoClientFactory = new DefaultMqttPahoClientFactory();
    MqttConnectOptions mqttConnectOptions = new MqttConnectOptions();
    mqttConnectOptions.setUserName(mqttConfigProperties.getUser());
    mqttConnectOptions.setPassword(mqttConfigProperties.getPassword().toCharArray());
    mqttConnectOptions.setConnectionTimeout(mqttConfigProperties.getTimeout());
    mqttConnectOptions.setKeepAliveInterval(mqttConfigProperties.getKeepalive());
    mqttConnectOptions.setServerURIs(new String[]{mqttConfigProperties.getHost()});
    mqttConnectOptions.setCleanSession(false);
    defaultMqttPahoClientFactory.setConnectionOptions(mqttConnectOptions);
    return defaultMqttPahoClientFactory;
    }

    /**
    * 出站配置
    */
    @Bean
    @ServiceActivator(inputChannel = "mqttOutputChannel")
    public MessageHandler messageOutHandler() {
    MqttPahoMessageHandler messageHandler = new MqttPahoMessageHandler(mqttConfigProperties.getClientId() + "outChannel", mqttPahoClientFactory());
    messageHandler.setAsync(true);
    messageHandler.setDefaultQos(0);
    messageHandler.setDefaultTopic(mqttConfigProperties.getTopic());
    DefaultPahoMessageConverter defaultPahoMessageConverter = new DefaultPahoMessageConverter();
    defaultPahoMessageConverter.setPayloadAsBytes(true);
    messageHandler.setConverter(defaultPahoMessageConverter);
    return messageHandler;
    }

    /**
    * 入站配置
    */
    @Bean
    public MessageProducer inbound() {
    String uuid = UUID.randomUUID().toString();
    MqttPahoMessageDrivenChannelAdapter adapter = new MqttPahoMessageDrivenChannelAdapter(
    mqttConfigProperties.getClientId() + uuid, mqttPahoClientFactory(), mqttConfigProperties.getTopic());
    adapter.setCompletionTimeout(5000);
    adapter.setQos(0);
    adapter.setConverter(new DefaultPahoMessageConverter());
    adapter.setOutputChannel(mqttInputChannel());
    return adapter;
    }

    /**
    * 处理入站消息
    */
    @Bean
    @ServiceActivator(inputChannel = "mqttInputChannel")
    public MessageHandler handler() {
    return message -> {
    log.info("message payload {}", String.valueOf(message.getPayload()));
    };
    }

    }
  4. 发送消息

    1
    2
    3
    4
    @MessagingGateway(defaultRequestChannel ="mqttOutputChannel")
    public interface MqttGateway {
    void sentToMqtt(String data);
    }

spring 调用emqx的api

点击左侧系统设置菜单下的 API 密钥,可以来到 API 密钥页面。如果需要 API 密钥来创建一些脚本调用 HTTP API,可以在此页面进行创建获取操作。点击页面右上角创建按钮打开创建 API 密钥弹框,填写 API 密钥相关数据,如果到期时间未填写 API 密钥将永不过期,点击确定提交数据,提交成功后页面上将提供此次创建的 API 密钥的 API Key 和 Secret Key,其中 Secret Key 后续将不再显示,用户需立即将 API Key 和 Secret Key 保存至安全的地方;保存数据完毕可点击关闭按钮关闭弹框。

在 API 密钥页面上,您可以按照以下步骤生成用于访问 HTTP API 的 API 密钥和 Secret key。

  1. 单击页面右上角的**+ 创建**按钮,弹出创建 API 密钥的对话框。

  2. 在创建 API 密钥对话框上,配置 API 密钥的详细信息。

    如果到期时间文本框留空,API 密钥将永不过期。

  3. 单击确认按钮,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
import cn.com.hcytech.config.MqttConfigProperties;
import com.alibaba.fastjson2.JSONObject;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;
import java.util.Base64;

@Component
@Data
@Slf4j
public class EMqxApiClient {

@Resource
private MqttConfigProperties mqttConfigProperties;

public JSONObject makeApiRequestGet(String apiUrl) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
httpHeaders.setBasicAuth(generateAuthorizationHeader(mqttConfigProperties.getApiKey(), mqttConfigProperties.getApiSecret()));
HttpEntity<String> request = new HttpEntity<>(httpHeaders);
ResponseEntity<String> resp = restTemplate.exchange(mqttConfigProperties.getApiService() + apiUrl, HttpMethod.GET, request, String.class);
if (resp.getStatusCode() == HttpStatus.OK) {
String responseData = resp.getBody();
log.debug("EMqx Response: " + responseData);
return JSONObject.parseObject(responseData);
} else {
log.error("API request failed with status code: " + resp.getStatusCode());
}
return new JSONObject();
}

private String generateAuthorizationHeader(String apiKey, String secretKey) {
String credentials = apiKey + ":" + secretKey;
return Base64.getEncoder().encodeToString(credentials.getBytes());
}

public Boolean getClientStatus(Long tenantId, String deviceSN) {
boolean isConnect = false;
try {
String url = "/clients/" + tenantId + "_" + deviceSN;
JSONObject result = makeApiRequestGet(url);
isConnect = result.getBoolean("connected");
} catch (Exception e) {
log.error("查询设备MQTT连接状态:{}",e.getMessage());
}
return isConnect;
}
}

前提

随着K8S的火爆,K8S渐渐抛弃了docker容器,采用了Containerd容器,这就导致安装K8S的话一般就没有docker命令了,以及在流水线编译镜像时,也依赖宿主机的docker环境,因此才研究了下除了使用docker环境编译镜像,还能使用其他那几种方式编译镜像。

DinD(docker in docker)

未实践 使用 DinD 作为 Pod 的 Sidecar

未实践 使用 DaemonSet 在每个 containerd 节点上部署 Docker

改方式使用的是docker pull docker镜像的方式,常用使用方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
#需要挂载宿主机的docker,其实共用了宿主机环境不安全
$ docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
docker:latest sh
/ # docker version

#需要在宿主机创建网络,因此就没实验了,因为宿主机都没有docker命令
$ docker network create some some-network
$ docker run -it --rm --network some-network \
-e DOCKER_TLS_CERTDIR=/certs \
-v some-docker-certs-client:/certs/client:ro \
docker:latest sh
/ # docker version

buildkit

属于工具软件,主要安装在Linux, macOS, and Windows环境,安装命令brew install buildkit,如果在编译的容器(例如gitlab runner的容器、jenkins的容器)里面安装,应该也可以有很好的适应性,但是还未实验。

Kaniko

测试使用

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
#使用debug镜像,因为可以进入sh命令行,原镜像是gcr.io替换为gcr.lank8s.cn,因为gcr.io访问不了
➜ ~ docker run -it --entrypoint=/busybox/sh gcr.lank8s.cn/kaniko-project/executor:debug
#创建个Dockerfile,内容如下
/workspace # vi Dockerfile
/workspace # cat Dockerfile
FROM nginx:alpine
/workspace # vi /kaniko/.docker/config.json
/workspace # cat /kaniko/.docker/config.json
{
"auths":{
"http://harbor.xxxtech.dev/v2/":{
"username":"harbor",
"password":"harbor123"
}
}
}
/workspace # /kaniko/executor --insecure-registry harbor.xxxtech.dev -d harbor.xxxtech.dev/test/aa:latest
INFO[0000] Retrieving image manifest nginx:alpine
INFO[0000] Retrieving image nginx:alpine from registry index.docker.io
INFO[0004] Built cross stage deps: map[]
INFO[0004] Retrieving image manifest nginx:alpine
INFO[0004] Returning cached image manifest
INFO[0004] Executing 0 build triggers
INFO[0004] Building stage 'nginx:alpine' [idx: '0', base-idx: '-1']
INFO[0004] Skipping unpacking as no commands require it.
INFO[0004] Pushing image to harbor.exxktech.dev/test/aa:latest
INFO[0008] Pushed harbor.xxxtech.dev/test/aa@sha256:c20d8bd7e80b5ffa16019254427e3215b61b730db61a78c7b7b6be8d00acdded
#测试运行镜像
➜ ~ docker run -p 8080:80 -d harbor.xxxtech.dev/test/aa:latest
Unable to find image 'harbor.xxxtech.dev/test/aa:latest' locally
latest: Pulling from test/aa
7264a8db6415: Pull complete
518c62654cf0: Pull complete
d8c801465ddf: Pull complete
ac28ec6b1e86: Pull complete
eb8fb38efa48: Pull complete
e92e38a9a0eb: Pull complete
58663ac43ae7: Pull complete
2f545e207252: Pull complete
Digest: sha256:c20d8bd7e80b5ffa16019254427e3215b61b730db61a78c7b7b6be8d00acdded
Status: Downloaded newer image for harbor.xxxtech.dev/test/aa:latest
3c2e345f52b4a06f9d4d57dcbb95b95876552d6d122536828123e66099c8310d
#访问http://localhost:8080/即可看到nginx页面

gitlab runner使用(还未实践)

使用 kaniko 构建 Docker 镜像

Jib

在pom.xml文件添加jib插件,然后使用mvn package -f pom.xml进行打包,就会自动上传到harbor仓库了

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
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>com.google.cloud.tools</groupId>
<artifactId>jib-maven-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<from>
<image>harbor.exxktech.dev/base/java8:1.0.0</image>
<auth>
<username>zhangsan</username>
<password>Harbor123</password>
</auth>
</from>
<to>
<image>harbor.exxktech.dev/test/frts-business</image>
<tags>
<tag>1.4</tag>
</tags>
<auth>
<username>zhangsan</username>
<password>Harbor123</password>
</auth>
</to>
<allowInsecureRegistries>true</allowInsecureRegistries>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>build</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

因为harbor仓库不是https,因此执行mvn命令时,还需加上jvm参数-DsendCredentialsOverHttp=true,linux可以通过mvn -DsendCredentialsOverHttp=true package进行打包。

常见问题

  1. 出现如下错误

    {"errors":[{"code":"PRECONDITION","message":"Failed to process request due to 'xxx-business:latest' configured as immutable."}]}

    分析:因为harbor设置了immutable,在harbor上面删除带immutable的tag就会提示the tag latest configured as immutable, cannot be deleted该错误。

    解决:在harbor管理界面,点击项目->Policy->TAG IMMUTABILITY->Immutability rules,然后删除相关规则

参考:镜像构建