闲来无事搞了个Jenkins主题

发布在 技术

前言

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

Them Plguin

需要安装 Simple Theme Plugin 和 Login Theme Plugin。

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

主要他们提供了加载 css 和 js 的配置。

本地 Web 服务

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

这样修改完 css 或 js 文件后,刷新页面即可看到效果,而不需要每次把 css 和 js 的文件内容配置到 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 的今日壁纸,每天都会看到不一样的背景,整体是半透明的风格,整体颜色风格会受到背景颜色的影响,这样每天都可以感受到不一样的效果了。

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




使用 Next.js 包装一个电影网站

发布在 技术

喜欢下载电影到 NAS 服务器上,然后可以很方便的在手机、电脑、投影仪上观看。平时去的那个电影网站,广告多,列表页没有电影的封面,页面太丑,作为前端开发实在看不下去了。

既然是自己搞来玩的,那么技术方案就可以随便选了,当练手。这次我打算用 Next.js。

程序、界面都没啥难度,而且不存储数据,每次请求时通过 got 获取获取对方页面,再用 cheerio 解析得到最终数据,再用 Next.js 渲染出来。

Next.js 的路由配置方式还是比较少见的,几年前自己在做 React 项目开发的时候,也采用过这样的路由方案,这种约定优于配置的方式非常喜欢。

同时它使用 jsx 来渲染页面,之前我也没体验过,空了我也要尝试下搞个 demo。

截图



前言

公司的管理后台系统的前端部分都是部署到阿里云的 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

热爱编程,热爱生活。

前端工程师

中国-成都