今天给大家分享一个Jenkins插件开发教程,获取更多运维相关知识可关注微信公众号运维日常手记。
本次分享的插件主要完成以下两个需求:
1、在Jenkins触发构建的时候向飞书机器人发送消息;
2、插件支持自由风格的流水线和pipeline流水线;
环境准备
JDK版本:21
Maven:3.9.9
编辑器:IDEA
操作系统:windows
初始化项目
1、配置setting文件,在%USERPROFILE%.m2\目录下创建settings.xml文件。
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0
http://maven.apache.org/xsd/settings-1.0.0.xsd">
<pluginGroups>
<pluginGroup>org.jenkins-ci.tools</pluginGroup>
</pluginGroups>
<profiles>
<profile>
<id>jenkins</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<repositories>
<repository>
<id>repo.jenkins-ci.org</id>
<url>https://repo.jenkins-ci.org/public/</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>repo.jenkins-ci.org</id>
<url>https://repo.jenkins-ci.org/public/</url>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
</settings>
2、cmd控制台下执行如下命令进入交互式初始化项目,按提示选择4初始化一个helloworld项目,artifactId输入feishu,其他的回车使用默认值即可。
mvn -U archetype:generate -Dfilter=io.jenkins.archetypes:
3、切换到上述步骤创建的项目,执行命令下载依赖。
cd feishu
mvn verify
4、运行helloworld项目,cmd控制台执行命令启动项目后,在浏览器访问http://localhost:8080即可访问到项目。
mvn hpi:run
5、配置IDEA,注意Maven home要配置成maven的安装目录,配置完成即可通过IDEA启动helloworld项目。
编辑
目录结构
把helloworld的代码通通删除,然后开始开发我们自己的代码,最后完整的目录结构如下。
编辑
插件开发要点
Jenkins为用户提供了各种扩展点,插件开发的本质其实就是去实现这些扩展点,加入我们自定义的逻辑,可以在这个文档找到各种扩展点Extension Points defined in Jenkins Core,每个扩展点都有相应的描述和插件代码示例,比如Notifier这个扩展点的作用就是在构建完成后执行相关操作,Implementations展示的是实现了该扩展点的社区插件。
编辑
自由风格流水线
下面正式进入插件开发,我们的插件需要在自由风格和pipeline流水线上都可以使用,先来适配自由风格流水线,我们的需求是在Jenkins触发构建和构建完成时都能收到飞书通知,因此需要实现两个扩展点,一个是Notifier,还有一个是RunListener,Notifier的作用上面已经说过,RunListener的作用是可以在流水线启动和结束时触发对应的逻辑。
先实现发送消息到飞书的逻辑
逻辑比较简单,定义了一个send方法,它的作用是发送一个POST请求到飞书,jsonPayload定义的是一个飞书卡片消息结构,color用户定义卡片标题的颜色,title是卡片的标题,content是内容,jenkinsUrl是一个链接,用于点击卡片按钮跳转到Jenkins。
package io.jenkins.plugins.feishu.service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.logging.Logger;
public class FeishuService {
private static final Logger LOG = Logger.getLogger(FeishuService.class.getName());
private final String webHookUrl;
public FeishuService(String webHookUrl) {
this.webHookUrl = webHookUrl;
}
public void send(String color, String title, String content, String jenkinsUrl) throws Exception {
// 构建卡片消息内容
String jsonPayload = String.format(
"{ "msg_type": "interactive", "card": { " + ""config": {"wide_screen_mode": true}, "
+ ""header": {"template":"%s", "title": {"tag": "plain_text", "content": "%s"}}, "
+ ""elements": [{"tag": "markdown", "content": "%s"}, "
+ "{"tag":"action","actions":[{"tag":"button","text":{"tag":"plain_text","content":"查看日志"},"
+ ""type":"primary","multi_url":{"url":"%s"}}]}]"
+ "}}",
color, title, content, jenkinsUrl);
// 创建 HttpClient
HttpClient client = HttpClient.newHttpClient();
// 创建 POST 请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webHookUrl))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload, StandardCharsets.UTF_8))
.build();
try {
// 发送请求并获取响应
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
// 输出响应状态码与响应内容
LOG.info("发送消息到飞书机器人成功, " + "响应状态码: " + response.statusCode() + "," + "响应内容: " + response.body());
} catch (Exception e) {
LOG.warning("发送消息到飞书机器人失败, " + "错误: " + e.getMessage());
throw e;
}
}
}
发送到飞书的卡片消息长这样
编辑
实现Notifier扩展点
我们定义了webHookUrl和message两个属性,这两个属性可以在Jenkins的UI上面配置,定义了一个send方法,send方法主要是定义发送消息的逻辑,onStarted和onCompleted方法则是调用send方法实现消息发送,注意到perform方法,perform是在流水线执行完成后自动触发的方法,因为我们希望流水线启动和完成都可以触发飞书通知,所以此处的perform方法不做任何处理直接返回true。
package io.jenkins.plugins.feishu;
import hudson.Extension;
import hudson.Launcher;
import hudson.model.*;
import hudson.model.AbstractProject;
import hudson.model.BuildListener;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.Notifier;
import hudson.tasks.Publisher;
import io.jenkins.plugins.feishu.service.FeishuService;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Map;
import java.util.logging.Logger;
import jenkins.model.Jenkins;
import jenkins.tasks.SimpleBuildStep;
import net.sf.json.JSONObject;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.StaplerRequest2;
public class FeishuNotifier extends Notifier implements SimpleBuildStep {
private static final Logger LOG = Logger.getLogger(FeishuNotifier.class.getName());
private final String webHookUrl;
private final String message;
@DataBoundConstructor
public FeishuNotifier(String webHookUrl, String message) {
super();
this.webHookUrl = webHookUrl;
this.message = message;
}
public String getWebHookUrl() {
return webHookUrl;
}
public String getMessage() {
return message;
}
@Override
public DescriptorImpl getDescriptor() {
return (DescriptorImpl) super.getDescriptor();
}
@SuppressWarnings("unchecked")
public static FeishuNotifier getNotifier(AbstractProject project) {
Map<Descriptor<Publisher>, Publisher> map = project.getPublishersList().toMap();
for (Publisher publisher : map.values()) {
if (publisher instanceof FeishuNotifier) {
return (FeishuNotifier) publisher;
}
}
return null;
}
@Override
public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener)
throws InterruptedException, IOException {
// return super.perform(build, launcher, listener);
return true;
}
private boolean isEmpty(String s) {
return s == null || s.trim().isEmpty();
}
private Result findPreviousBuildResult(AbstractBuild b) {
do {
b = b.getPreviousBuild();
if (b == null || b.isBuilding()) {
return null;
}
} while ((b.getResult() == Result.ABORTED) || (b.getResult() == Result.NOT_BUILT));
return b.getResult();
}
private boolean isPreviousBuildSuccess(AbstractBuild build) {
if (build.getResult() == Result.SUCCESS && findPreviousBuildResult(build) == Result.SUCCESS) {
return true;
}
return false;
}
public void onStarted(AbstractBuild build, TaskListener listener) {
if (getDescriptor().isDefaultSendNotificationOnStart()) {
LOG.info("Prepare Feishu notification for build started...");
send(build, listener, BuildPhase.STARTED);
}
}
public void onCompleted(AbstractBuild build, TaskListener listener) {
boolean notifyOnConsecutiveSuccesses = getDescriptor().isDefaultNotifyOnConsecutiveSuccesses();
boolean previousBuildSuccessful = isPreviousBuildSuccess(build);
if (notifyOnConsecutiveSuccesses || (!notifyOnConsecutiveSuccesses && !previousBuildSuccessful)) {
LOG.info("Prepare Feishu notification for build completed...");
send(build, listener, BuildPhase.COMPLETED);
}
}
private void send(AbstractBuild build, TaskListener listener, BuildPhase phase) {
String wb = isEmpty(webHookUrl) ? getDescriptor().getWebHookUrl() : webHookUrl;
String content = isEmpty(message) ? getDescriptor().getMessage() : message;
String jenkinsUrl = Jenkins.get().getRootUrl() + build.getConsoleUrl();
long startTimeInMillis = build.getStartTimeInMillis();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String startTime = sdf.format(new Date(startTimeInMillis));
String jobName = build.getProject().getName();
String title = "";
String color = "";
if (phase == BuildPhase.STARTED) {
title = "触发构建";
color = "yellow";
if (isEmpty(content)) {
content = "流水线名称: " + jobName + "\n触发时间: " + startTime;
}
}
if (phase == BuildPhase.COMPLETED) {
long duration = build.getDuration();
if (isEmpty(content)) {
content = "流水线名称: " + jobName + "\n发布时间: " + startTime + "\n耗时: " + duration + "ms";
}
Result result = build.getResult();
if (result == Result.SUCCESS) {
title = "发布成功";
color = "green";
} else if (result == Result.ABORTED) {
title = "发布取消";
color = "grey";
} else if (result == Result.FAILURE || result == Result.UNSTABLE) {
title = "发布失败";
color = "red";
}
}
try {
FeishuService client = new FeishuService(wb);
client.send(color, title, content, jenkinsUrl);
} catch (Exception e) {
listener.getLogger().println("发送飞书通知失败");
}
}
@Extension
public static final class DescriptorImpl extends BuildStepDescriptor<Publisher> {
private String webHookUrl;
private String message;
private boolean defaultSendNotificationOnStart;
private boolean defaultNotifyOnConsecutiveSuccesses = true;
public DescriptorImpl() {
super(FeishuNotifier.class);
load();
}
@Override
public String getDisplayName() {
return "Feishu Notifier";
}
@Override
public boolean isApplicable(Class<? extends AbstractProject> aClass) {
return true;
}
@Override
public boolean configure(StaplerRequest2 req, JSONObject formData) throws FormException {
req.bindJSON(this, formData);
save();
return true;
}
public String getWebHookUrl() {
return webHookUrl;
}
public void setWebHookUrl(String webHookUrl) {
this.webHookUrl = webHookUrl;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public boolean isDefaultSendNotificationOnStart() {
return defaultSendNotificationOnStart;
}
public boolean isDefaultNotifyOnConsecutiveSuccesses() {
return defaultNotifyOnConsecutiveSuccesses;
}
public void setDefaultSendNotificationOnStart(boolean defaultSendNotificationOnStart) {
this.defaultSendNotificationOnStart = defaultSendNotificationOnStart;
}
public void setDefaultNotifyOnConsecutiveSuccesses(boolean defaultSendNotificationsOnConsecutiveSuccesses) {
this.defaultNotifyOnConsecutiveSuccesses = defaultSendNotificationsOnConsecutiveSuccesses;
}
}
}
实现RunListener扩展点
RunListener扩展点有两个方法,onStarted方法在流水线启动时触发,我们在onStarted方法调用上一步FeishuNotifier里面定义的onStarted方法,实现消息发送,onCompleted方法在流水线执行完成后触发,也是一样调用FeishuNotifier里面定义的onCompleted方法实现消息发送。
package io.jenkins.plugins.feishu;
import hudson.Extension;
import hudson.model.AbstractBuild;
import hudson.model.TaskListener;
import hudson.model.listeners.RunListener;
@Extension
public class BuildListener extends RunListener<AbstractBuild> {
@Override
public void onStarted(AbstractBuild abstractBuild, TaskListener listener) {
FeishuNotifier trigger = FeishuNotifier.getNotifier(abstractBuild.getProject());
if (trigger == null) {
return;
}
trigger.onStarted(abstractBuild, listener);
}
@Override
public void onCompleted(AbstractBuild abstractBuild, TaskListener listener) {
FeishuNotifier trigger = FeishuNotifier.getNotifier(abstractBuild.getProject());
if (trigger == null) {
return;
}
trigger.onCompleted(abstractBuild, listener);
}
}
添加UI界面
在resource目录下添加global.jelly和config.jelly文件,这些文件是用来实现配置的可视化的。
global.jelly:
<?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:section title="Feishu Notification">
<f:entry
title="Webhook Url"
help="/plugin/feishu/help-awsAccessKey.html"
field="webHookUrl">
<f:textbox />
</f:entry>
<f:entry
title="Message"
help="/plugin/feishu/help-message.html"
field="message">
<f:textbox />
</f:entry>
<f:entry
title="Send notification also on start of build?"
help="/plugin/feishu/help-defaultSendNotificationOnStart.html"
field="defaultSendNotificationOnStart">
<f:booleanRadio />
</f:entry>
<f:entry
title="Send notifications on every build?"
help="/plugin/feishu/help-defaultNotifyOnConsecutiveSuccesses.html"
field="defaultNotifyOnConsecutiveSuccesses">
<f:booleanRadio />
</f:entry>
</f:section>
</j:jelly>
config.jelly:
<?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="Webhook Url (overrides global default)"
field="webHookUrl">
<f:textbox />
</f:entry>
<f:entry
title="Message (overrides global default)"
field="message">
<f:textbox />
</f:entry>
</j:jelly>
在webapp目录下新件html文件添加参数帮助信息
help-webHookUrl.html:
<div>
The default behavior is to send a notification on every build. If you would only like to receive notifications
only on failures and when the build has returned to a successful state, disable this option.
</div>
help-message.html:
<div>
<p>
The feishu message.
</p>
</div>
help-defaultSendNotificationOnStart.html:
<div>
<p>
If enabled, an feishu notification will not only be sent on build completion but also
in addition when the build job is started. You might want to use the variable
<tt>BUILD_PHASE (STARTED | COMPLETED)</tt> in your string templates.
</p>
</div>
help-defaultNotifyOnConsecutiveSuccesses.html:
<div>
The default behavior is to send a notification on every build. If you would only like to receive notifications
only on failures and when the build has returned to a successful state, disable this option.
</div>
完成后启动项目在Jenkins的界面上就能看到如下配置
全局配置:
编辑
创建一个自由风格的流水线测试
编辑
添加构建后操作选择飞书插件,输入webhook地址,message可以不填
编辑
保存后立即构建,将在飞书群里收到如下一条信息
编辑
适配pipeline流水线
要让插件在pipeline里面也能使用需要实现step扩展点,pipeline需要我们显示的调用,所以定义了多个属性,核心逻辑是start方法,getFunctionName的返回值是插件在pipeline里面的语法。
package io.jenkins.plugins.feishu;
import hudson.Extension;
import hudson.model.Run;
import hudson.model.TaskListener;
import io.jenkins.plugins.feishu.service.FeishuService;
import java.io.Serial;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;
import jenkins.model.Jenkins;
import org.jenkinsci.plugins.workflow.steps.Step;
import org.jenkinsci.plugins.workflow.steps.StepContext;
import org.jenkinsci.plugins.workflow.steps.StepDescriptor;
import org.jenkinsci.plugins.workflow.steps.StepExecution;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.springframework.lang.NonNull;
public class FeishuStep extends Step {
private static final Logger LOG = Logger.getLogger(FeishuNotifier.class.getName());
private String webHookUrl;
private String message;
private String color;
private String title;
@DataBoundConstructor
public FeishuStep(String webHookUrl, String color, String title, String message) {
this.webHookUrl = webHookUrl;
this.message = message;
this.color = color;
this.title = title;
}
public String getWebHookUrl() {
return webHookUrl;
}
@DataBoundSetter
public void setWebHookUrl(String webHookUrl) {
this.webHookUrl = webHookUrl;
}
public String getMessage() {
return message;
}
@DataBoundSetter
public void setMessage(String message) {
this.message = message;
}
public String getColor() {
return color;
}
@DataBoundSetter
public void setColor(String color) {
this.color = color;
}
public String getTitle() {
return title;
}
@DataBoundSetter
public void setTitle(String title) {
this.title = title;
}
@Override
public StepExecution start(StepContext context) throws Exception {
return new FeishuStepExecution(this, context);
}
private static class FeishuStepExecution extends StepExecution {
@Serial
private static final long serialVersionUID = 1L;
private final transient FeishuStep step;
private FeishuStepExecution(FeishuStep step, StepContext context) {
super(context);
this.step = step;
}
private boolean isEmpty(String s) {
return s == null || s.trim().isEmpty();
}
@Override
public boolean start() throws Exception {
StepContext context = this.getContext();
LOG.info("Send feishu message.");
try {
Run<?, ?> run = getContext().get(Run.class);
TaskListener listener = context.get(TaskListener.class);
String jenkinsUrl = Jenkins.get().getRootUrl() + run.getConsoleUrl();
String jobName = run.getParent().getDisplayName();
long startTimeInMillis = run.getStartTimeInMillis();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
String startTime = sdf.format(new Date(startTimeInMillis));
long duration = run.getDuration();
String content = "流水线名称: " + jobName +"\n发布时间: " + startTime + "\n耗时: " + duration + "ms";
String msg = step.getMessage();
if(!isEmpty(msg)){
content = msg;
}
String wb = step.getWebHookUrl();
String color = step.getColor();
String title = step.getTitle();
FeishuService client = new FeishuService(wb);
client.send(color, title, content, jenkinsUrl);
listener.getLogger().println("webhook发送通知成功.");
// 必须调用onSuccess或onFailure
context.onSuccess(null);
return true;
} catch (Exception e) {
context.onFailure(e);
return false;
}
}
}
@Extension
public static class DescriptorImpl extends StepDescriptor implements Serializable {
public DescriptorImpl() {}
@Override
public Set<? extends Class<?>> getRequiredContext() {
return new HashSet<>() {
{
add(Run.class);
add(TaskListener.class);
}
};
}
@Override
public String getFunctionName() {
return "FeishuNotify";
}
@NonNull
@Override
public String getDisplayName() {
return "Send Feishu message";
}
}
}
同样在resource的FeishuStep子目录创建config.jlly实现UI文档。
<?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="Webhook Url (overrides global default)"
field="webHookUrl">
<f:textbox />
</f:entry>
<f:entry
title="Message (overrides global default)"
field="message">
<f:textbox />
</f:entry>
<f:entry
title="Color (choice red, grey or green)"
field="color">
<f:textbox />
</f:entry>
<f:entry
title="Title (feishu card message title)"
field="title">
<f:textbox />
</f:entry>
</j:jelly>
创建一个pipeline流水线测试
编辑
我们在流水线语法中可以找到插件的使用方法。
编辑
编辑
在pipeline script输入如下测试的pipeline脚本
pipeline {
agent any
stages {
stage('Test Feishu') {
steps {
script {
try {
echo "Before FeishuNotify"
FeishuNotify(
webHookUrl: 'https://open.feishu.cn/open-apis/bot/v2/hook/08c94157-xxxxxxxxxxxxxx',
message: '',
title: '发布成功',
color: 'green'
)
echo "After FeishuNotify"
} catch (Exception e) {
echo "Error in FeishuNotify: ${e.toString()}"
}
}
}
}
}
}
运行pipeline后将收到飞书通知
编辑
至此完结,码字不易,喜欢文章请给个关注。
插件源码地址: feishu-plugin: Jenkins插件,用于向飞书机器人发送消息。