闲来无事搞了个Jenkins主题

发布在 技术

前言

这几周不加班,早早到家,闲来无事在 NAS 服务器上搭建了个 Jenkins,用了几天后觉得自带的主题有点单调。

Them Plguin

需要安装 Simple Theme PluginLogin Theme Plugin

怎么安装和使用就不说明了,参考官方最新的文档更为靠谱。

主要他们提供了加载 cssjs 的配置。

本地 Web 服务

本地开发时为了方便查看效果,我用 NodeJSKoa 在本地启动了一个 Web 服务,让 Theme Plugin 读取我本地的 cssjs 文件。

这样修改完 cssjs 文件后,刷新页面即可看到效果,而不需要每次把 cssjs 的文件内容配置到 Theme Plugin 中。

1
2
3
4
5
6
7
8
const Koa = require("koa");
const serve = require("koa-static-server");

const port = 4600;
const app = websockify(new Koa());

app.use(serve({ rootDir: "dist", rootPath: "/" }));
app.listen(port);

引入 Less

写了几句 css 后,发现没嵌套功能写的真难受,于是引入 Less,方便编写 css。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const less = require("less");

const renderLess = async (lessFilePath) => {
let lessFileContent = fs.readFileSync(lessFilePath, { encoding: "utf-8" });
let lessRenderRes = await less.render(lessFileContent);

let targetPath = path.join(
__dirname,
"../dist",
lessFilePath.substring("src/".length, lessFilePath.length)
);
let targetDir = path.dirname(targetPath);
let targetName = path.basename(targetPath, ".less") + ".css";
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
targetPath = path.join(targetDir, targetName);
fs.writeFileSync(targetPath, lessRenderRes.css, { encoding: "utf-8" });
return { path: targetPath.replace(path.join(__dirname, "../dist"), "") };
};

监听文件变化

既然都引入了 Less,那么肯定要引入 自动编译,同时为了后续热更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const glob = require("glob");
const chokidar = require("chokidar");

const watchAndRenderLess = () => {
glob("src/**/*.less", {}, function (er, files) {
files.forEach(renderLess);
});
const watcher = chokidar.watch("src/**/*.less");
watcher.on("all", async (e, filePath, stats) => {
if (e == "add" || e == "change" || e == "addDir") {
let result = await renderLess(filePath);
console.log(`${url}${result.path}`);
}
});
};

watchAndRenderLess();

现在就可以愉快的使用 Less 了。

热更新

每次编写样式后,都需要手动刷新页面才能看到效果,能不能自动热更新页面,不需要手动刷新页面呢?

使用三方库虽然方便,但自己尝试写一下也是不错的选择。

主要就是监听样式文件变化,然后通过 webScoket 通知页面,页面再加载新的样式。

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
const websockify = require("koa-websocket");

const port = 4600;
const url = `http://localhost:${port}`;
const clients = new Set();

app.ws.use(function (ctx, next) {
clients.add(ctx.websocket);
ctx.websocket.onclose = () => {
clients.delete(ctx.websocket);
};
return next(ctx);
});

const watchAndRenderLess = () => {
// ...
const watcher = chokidar.watch("src/**/*.less");
watcher.on("all", async (e, filePath, stats) => {
if (e == "add" || e == "change" || e == "addDir") {
// ...
refresh(result.path);
}
});
};

const renderClientJs = () => {
let content = fs.readFileSync(path.join(__dirname, "client.js"), {
encoding: "utf-8",
});
content = content.replace(/\$\[port\]/g, port);
fs.writeFileSync(path.join(__dirname, "../dist", "client.js"), content, {
encoding: "utf-8",
});
};

const refresh = (filePath) => {
clients.forEach((client) => {
let data = { type: "refresh", path: filePath };
client.send(JSON.stringify(data));
});
};

renderClientJs();
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
// client.js
(function () {
// 此文件用于修改主题样式文件时,热更新样式用的。

let host = "localhost:$[port]";
let files = new Map();

let findTargetCss = () => {
let links = [...document.querySelectorAll('link[rel="stylesheet"]')];
for (const link of links) {
let url = new URL(link.href);
if (url.host === host) {
files.set(url.pathname, link);
}
}
};

findTargetCss();

let ws = new WebSocket(`ws://${host}/`);
ws.onmessage = (e) => {
console.log("message", e);
let data = JSON.parse(e.data);
if (data.type == "refresh") {
if (files.has(data.path)) {
let node = files.get(data.path);
let url = new URL(node.href);
let qs = new URLSearchParams(url.search);
qs.set("t", new Date().getTime());
url.search = qs.toString();
node.href = url.toString();
}
}
};
ws.onopen = (e) => {
console.log("open", e);
ws.send("hello world");
};
})();

通过 Theme Plugin 再把这个 client.js 引入,就可以实现热更新了。

上面的代码仅是 demo,为了快速解决问题随手写的。

主题效果

背景使用了 Bing 的今日壁纸,每天都会看到不一样的背景,整体是半透明的风格,整体颜色风格会受到背景颜色的影响,这样每天都可以感受到不一样的效果了。

下面图片是经过压缩的,背景效果有点模糊,没真实的看着丝滑。




前言

公司的管理后台系统的前端部分都是部署到阿里云的 OSS(对象存储服务)里。最开始,团队成员都是手动上传部署,但这样存在以下问题:

  • 权限管理不当:需要给多人分配 OSS 访问权限,增加安全风险。
  • 操作繁琐:每次发布都要手动执行上传操作,容易出错。
  • 缺乏版本管理:如果出现问题,难以快速回滚到之前的版本。

为了解决这些问题,我决定开发一个 Jenkins 插件,让前端部署更自动化、可控化,同时减少不必要的 OSS 访问权限。

插件功能概述

这个 Jenkins 插件的主要功能包括:

  • 全局配置:管理员可以在 Jenkins 后台配置多个 OSS 服务器和授权。
  • 任务配置:在具体的 Jenkins 任务中,选择 OSS 服务器和上传目录。
  • 自动备份:每次部署前,会自动对原来的版本进行备份,以便回滚。

全局配置(管理员设置)

为了确保安全性,OSS 相关配置只允许 Jenkins 管理员 进行设置。

界面如下:

  • 可以配置多个 OSS 服务器和授权。
  • AccessKeySecret 使用了 Jenkins 的密码凭证 进行存储,避免明文暴露。
  • 配置完成后,可以点击 测试连接 按钮,确保 OSS 可用。
  • 每次部署时,插件会自动备份原有版本,以便紧急情况下回滚。

界面代码示例:

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
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form" xmlns:c="/lib/credentials">
<f:section title="阿里云OSS配置">
<f:entry title="配置列表">
<f:repeatable var="server" name="servers" items="${descriptor.servers}">
<table width="100%">
<f:entry title="唯一名称" description="如:中台测试、订单中心测试、商品中心正式">
<f:textbox name="name" value="${server.name}" />
</f:entry>
<f:entry title="Endpoint" description="如:oss-cn-beijing">
<f:textbox name="endpoint" value="${server.endpoint}" />
</f:entry>
<f:entry title="内网" description="是否为阿里云内网">
<f:checkbox name="internal" checked="${server.internal}" />
</f:entry>
<f:entry title="BucketName">
<f:textbox name="bucketName" value="${server.bucketName}" />
</f:entry>
<f:entry title="AccessKeyId">
<f:textbox name="accessKeyId" value="${server.accessKeyId}" />
</f:entry>
<f:entry title="AccessKeySecret">
<f:password name="accessKeySecret" value="${server.accessKeySecret}"/>
</f:entry>
<f:entry title="备份存储目录" description="远程原文件备份存储目录,通常保持默认即可。">
<f:textbox name="backupFolder" value="${server.backupFolder}" default="__backup__/" />
</f:entry>
<f:entry title="上传临时目录" description="上传时的远程临时目录,通常保持默认即可。">
<f:textbox name="temporaryFolder" value="${server.temporaryFolder}" default="__temporary__/" />
</f:entry>
<f:entry title="最大备份数">
<f:textbox name="backupMaxNumber" value="${server.backupMaxNumber}" default="3"/>
</f:entry>
<f:validateButton title="测试连接" progress="连接中..." method="testConnection" with="endpoint,internal,bucketName,accessKeyId,accessKeySecret" />
<f:entry title="">
<div align="right">
<f:repeatableDeleteButton />
</div>
</f:entry>
</table>
</f:repeatable>
</f:entry>
</f:section>
</j:jelly>

任务配置

任务的配置非常简单,用户只需要:

  1. 选择已配置的 OSS 服务器
  2. 待上传的内容
  3. 指定要上传的目录

界面如下:

界面代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form">
<f:entry title="OSS服务器" field="serverName">
<f:select />
</f:entry>
<f:entry title="本地目录" field="localFolder" description="待上传的目录,可使用jenkins变量。">
<f:textbox default="${descriptor.getLocalFolderDefaultValue()}" />
</f:entry>
<f:entry title="远程目录" field="remoteFolder" description="远程目录,用 / 结尾,如:user-center/ 。为空时则表示根目录。">
<f:textbox />
</f:entry>
</j:jelly>

任务构建(文件上传)

上传文件的部分逻辑较为常规,主要使用 aliyun-sdk-oss 提供的 API 进行操作。

建议参考官方文档:阿里云 OSS 官方 SDK 文档

部分 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
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
public class AliyunOssPublisher extends Builder {

// ...

@Override
public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener) throws InterruptedException, IOException {
logger = listener.getLogger();

AliyunOssServer server = findServerByName(serverName);
if (server == null) {
logger.println("未能找到名为 " + serverName + " 的服务配置");
return false;
}

// ...
if (publish()) {
logger.println("发布成功");
logger.println("准备收尾工作");
try {
cleanBackup();
shutdown();
} catch (Exception e) {
logger.println(desensitization(e.getMessage()));
}
logger.println("收尾完成");
return true;
}
return false;
}

private boolean publish() {
try {
// ...
return true;
} catch (Exception e) {
// ...
return false;
}
}

private void cleanBackup() {
logger.println("开始检查并清理多余备份内容");
// ...
}

public void shutdown() {
if (client != null && client.sdk != null) {
client.sdk.shutdown();
}
}

@Extension
public static final class AliyunOssPublisherDescriptor extends BuildStepDescriptor<Builder> {

private List<AliyunOssServer> servers = new ArrayList<>();

public AliyunOssPublisherDescriptor() {
super(AliyunOssPublisher.class);
load();
}

public AliyunOssServer[] getServers() {
return servers.toArray(new AliyunOssServer[0]);
}

@Override
public boolean isApplicable(Class<? extends AbstractProject> aClass) {
return true;
}

@Override
public String getDisplayName() {
return "发布到阿里云OSS";
}

@Override
public boolean configure(StaplerRequest req, JSONObject formData)
throws FormException {
// ...
}

public String getLocalFolderDefaultValue() {
return "${WORKSPACE}/build";
}

public ListBoxModel doFillServerNameItems() {
ListBoxModel m = new ListBoxModel();
AliyunOssServer[] servers = getServers();
for (AliyunOssServer server : servers) {
m.add(server.getName());
}
return m;
}

public FormValidation doTestConnection(@QueryParameter("endpoint") String endpoint,
@QueryParameter("internal") Boolean internal,
@QueryParameter("bucketName") String bucketName,
@QueryParameter("accessKeyId") String accessKeyId,
@QueryParameter("accessKeySecret") String accessKeySecret) throws Exception {
AliyunOssClient client = new AliyunOssClient(endpoint, internal, bucketName, accessKeyId, SecretHelper.decrypt(accessKeySecret));
try {
client.test();
return FormValidation.ok("链接成功");
} catch (Exception e) {
return FormValidation.error("链接失败:" + e.getMessage());
}
}
}
}

入门教程

如果你是第一次开发 Jenkins 插件,推荐先阅读官方的入门教程:Jenkins Plugin 开发入门

这个教程简单直接,可以帮助你快速了解 Jenkins 插件的开发流程,避免走弯路。

如何进阶

Jenkins 官方的入门教程比较基础,要真正开发一个插件,你可能会遇到一些文档里没有覆盖的问题。我的建议是:

找类似插件的源码:比如一些已有的 Jenkins OSS 上传插件,看看它们是如何实现的。

如何调试

参考:https://blog.csdn.net/xueerfei008/article/details/81871241

文章中是以 MacOS 为例子。

如何运行

在命令行中执行:mvn hpi:run

如何打包

在命令行中执行:mvn clean install

如何发版

修改 pom.xml 中的版本号,再执行 打包命令,打包得到 hpi 文件。

结语

这次是我第一次正式用 Java 开发,过程充满挑战,但也收获颇丰。希望这篇文章能帮助到想开发 Jenkins 插件的同学。🎉

如果你有更好的建议或者遇到问题,欢迎交流!

  • 第 1 页 共 1 页
作者的图片

YaoWorld

热爱编程,热爱生活。

前端工程师

中国-成都