自己实现一个多项目多环境的自动化测试系统,支持邮件提醒和报告导出

1. 前言

最近在正式系统每次演示之前总是会出现BUG,然后再领导的带领下写了这么个系统

主要实现的功能如下

  1. 创建项目

包括项目负责人,登录方式,负责人邮箱,手机号等,方便测试结果推送

  1. 创建项目环境

针对正式环境,测试环境等多环境单独配置,账号密码,获取token的地址等

  1. 添加接口列表

给项目添加接口列表,后续创建测试计划的时候可以针对不同的接口进行测试

  1. 创建测试计划

针对项目创建测试计划,是否发送邮件,执行几次,配置cron表达式等。

支持动态增减定时任务,测试完成后发送测试报告

支持websocket实时查看测试结果

  1. 测试报告查询

2. ER 图

  1. 项目信息表
  2. 项目环境表
  3. 环境变量表
  4. 项目接口表
  5. 测试计划表
  6. 计划接口详情表
  7. 测试计划执行记录表
  8. 执行记录接口结果表
  9. 定时任务表

太懒了

一个项目对应多个接口,

一个项目对应多个环境。

一个项目对应多个测试计划。

每个环境对应多个环境变量。

一个测试计划对应多个接口。

一个测试计划对应多个执行结果

每个执行结果对应多条接口执行记录

多条执行记录合并成一个测试报告

3. 关键代码记录

1. 动态增减定时任务

这里用的是Quartz,本来准备用xxlJob呢后来就给放弃了

1. 数据库

稍微简化一下,总的来说就是创建任务的时候会有一个plan_id(任务id),这个组别可以是项目id也可以是其他的,反正 plan_id + group_name 组合起来是唯一的就行

mysql 复制代码
create table tb_task(
	id int primary key auto_increment,
    plan_id int comment '计划id',
    group_name varchar(32) comment '组别',
    cron_expression varchar(30) comment '定时任务表达式'
)

2. 启动代码自动添加定时任务

java 复制代码
import net.lesscoding.testing.entity.PlanTask;
import net.lesscoding.testing.mapper.PlanTaskMapper;
import cn.hutool.core.collection.CollUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;

import java.util.List;

/**
 * @author eleven
 * @date 2023/11/10 9:43
 * @apiNote
 */
@Configuration
@Slf4j
public class ScheduledRunner implements ApplicationRunner {

    @Autowired
    private PlanTaskMapper planTaskMapper;

    @Autowired
    private SchedulerFactoryBean schedulerFactoryBean;
    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 查询表中没有被逻辑删除的计划
        List<PlanTask> planTaskList = planTaskMapper.selectList(new QueryWrapper<PlanTask>().eq("del_flag", false));
        // 获取quartz的执行器
        Scheduler scheduler = schedulerFactoryBean.getScheduler();
        // 批量创建定时任务
        if (CollUtil.isNotEmpty(planTaskList)) {
            for (PlanTask planTask : planTaskList) {
                JobDetail jobDetail = JobBuilder.newJob(TestingJob.class)
                        .withIdentity(planTask.getPlanId(), planTask.getGroupName())
                        .build();
                Trigger trigger = TriggerBuilder.newTrigger()
                        .withIdentity(planTask.getPlanId(), planTask.getGroupName())
                        .startNow()
                        .withSchedule(CronScheduleBuilder.cronSchedule(planTask.getCronExpression()))
                        .build();
                scheduler.scheduleJob(jobDetail, trigger);
                scheduler.start();
            }
        }
    }
}

3. 创建测试JOB

java 复制代码
import net.lesscoding.testing.entity.TestingPlan;
import net.lesscoding.testing.service.TestingPlanService;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.quartz.impl.triggers.CronTriggerImpl;
import org.springframework.beans.factory.annotation.Autowired;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * @author eleven
 * @date 2023/11/10 10:01
 * @apiNote
 */
@Slf4j
public class TestingJob implements Job {
    @Autowired
    private TestingPlanService planService;
    
    @Override
    public void execute(JobExecutionContext jobExecutionContext) throws JobExecutionException {
        JobDetail jobDetail = jobExecutionContext.getJobDetail();
        JobKey key = jobDetail.getKey();
        String planId = key.getName();
        log.info("{}trigger {}", planId, ((CronTriggerImpl) jobExecutionContext.getTrigger()).getCronExpression());
        log.info("{} jobKey {} time {}", planId, key, LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        log.info("定时任务开始执行测试计划");
        TestingPlan plan = new TestingPlan();
        plan.setId(planId);
        try {
            planService.runPlan(plan);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

4. 动态增减定时任务

这里就是变更计划的时候,要变更quartz里边的job

java 复制代码
import net.lesscoding.testing.service.PlanTaskService;
import net.lesscoding.testing.common.Result;
import net.lesscoding.testing.common.ResultFactory;
import net.lesscoding.testing.entity.PlanTask;
import net.lesscoding.testing.entity.TestingPlan;
import net.lesscoding.testing.mapper.PlanTaskMapper;
import net.lesscoding.testing.runner.TestingJob;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.quartz.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.stereotype.Service;

/**
 * @author eleven
 * @date 2023/11/10 10:48
 * @apiNote
 */
@Service
@Slf4j
public class PlanTaskServiceImpl extends ServiceImpl<PlanTaskMapper, PlanTask> implements PlanTaskService {

    @Autowired
    private PlanTaskMapper taskMapper;

    @Autowired
    private SchedulerFactoryBean schedulerFactoryBean;
	
    // 根据测试计划编辑定时任务
    @Override
    public int updateTaskByPlan(TestingPlan plan) throws SchedulerException {
        PlanTask planTask = taskMapper.selectOne(new QueryWrapper<PlanTask>()
                .eq("del_flag", false)
                .eq("plan_id", plan.getId()));
        if (planTask == null) {
            planTask = new PlanTask();
            planTask.setPlanId(plan.getId());
            planTask.setGroupName(plan.getPlanName());
            planTask.setCronExpression(plan.getCron());
        }
        // 编辑定时任务
        editPlanTask(planTask);
        return 1;
    }
    
	// 创建新的定时任务
    private void createNewScheduler(PlanTask task) throws SchedulerException {
        log.info("开始执行创建新任务");
        Scheduler scheduler = schedulerFactoryBean.getScheduler();
        JobKey jobKey = jobKey(task);
        JobDetail jobDetail = JobBuilder.newJob(TestingJob.class)
                .withIdentity(jobKey)
                .build();

        Trigger trigger = TriggerBuilder.newTrigger()
                .withIdentity(task.getPlanId(), task.getGroupName())
                .startNow()
                .withSchedule(CronScheduleBuilder.cronSchedule(task.getCronExpression()))
                .build();
        scheduler.scheduleJob(jobDetail, trigger);
        scheduler.start();
        log.info("任务创建完成");
    }
	
    /**
     * 更新job 直接删除再创建一个新
     */
    private void updateJob(PlanTask task) throws SchedulerException {
        deleteJob(task);
        createNewScheduler(task);
        log.info("更新成功");
    }

    /**
     * 删除job
     * @param task
     * @throws SchedulerException
     */
    public boolean deleteJob(PlanTask task) throws SchedulerException {
        Scheduler scheduler = schedulerFactoryBean.getScheduler();
        JobKey jobKey = jobKey(task);
        boolean deleteJob = scheduler.deleteJob(jobKey);
        log.info("当前 jobKey {} 删除结果{}", jobKey, deleteJob);
        return deleteJob;
    }

    private JobKey jobKey(PlanTask task) {
        JobKey jobKey = new JobKey(task.getPlanId(), task.getGroupName());
        log.info("当前任务 {}, jobKey{}", task, jobKey);
        return jobKey;
    }

}

2. 生成测试报告

有两种格式的,一种md的,一种html 的。怎么生成pdf没搞明白。这个用的是velocity模板引擎

1. 导入依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-velocity</artifactId>
    <version>1.4.7.RELEASE</version>
</dependency>

2. 模板

1. md模板

velocity 复制代码
# 1. $result.title

```mermaid
pie title 统计结果
    "成功" : ${result.successNum}
    "失败" : ${result.failedNum}
```

- 统计结果

|      | 接口总数 | 成功 | 失败 | 成功率 |
| ---- | -------- | ---- | ---- | ------ |
| 统计 | ${result.requestTotal}      | ${result.successNum}  | ${result.failedNum}    | ${result.successRate}   |

- 总览

| 描述           | 值                         |
| -------------- |---------------------------|
| 开始时间       | ${result.startTime}       |
| 结束时间       | ${result.endTime}         |
| 总耗时         | ${result.timeTotal}ms     |
| 总响应时间     | ${result.allTime}ms       |
| 平均响应时间   | ${result.perTime}ms       |
| 总响应数据大小 | ${result.responseSizeStr} |
| 未测接口       | ${result.waitNum}    |

# 2. 失败列表
#foreach($item in $result.failedResultList)
<h2> $foreach.index $item.interfaceName</h2>
$item.interfaceName

- 调用详情

| 请求方式           | 请求路径   | 是否成功 | 状态码        | 响应时长   | 响应大小                  |
|----------------|--------| -------- |------------|--------|-----------------------|
| `$item.method` | `$item.requestUri` |    #if($item.success):white_check_mark:#else :x:#end      | `$item.code` | $item.responseTime | $item.responseSizeStr |

- 请求参数

```json
$item.paramsJson
```
- 响应结果

```json
$item.responseJson
```
#end

# 3. 接口列表

#foreach($item in $result.allResultList)
<h2>$foreach.index $item.interfaceName</h2>

- 调用详情

| 请求方式           | 请求路径   | 是否成功 | 状态码        | 响应时长   | 响应大小                  |
|----------------|--------| -------- |------------|--------|-----------------------|
| `$item.method` | `$item.requestUri` |    #if($item.success):white_check_mark:#else :x:#end      | `$item.code` | $item.responseTime | $item.responseSizeStr |
#if($item.paramsJson != '')
- 请求参数

```json
$item.paramsJson
```
#end
#end

2. html模板

ve 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>$result.title</title>
    <style type="text/css">
        /* 清除默认样式的代码 */
        /* 去除常见标签默认的 margin 和 padding */
        body,
        h1,
        h2,
        h3,
        h4,
        h5,
        h6,
        p,
        ul,
        ol,
        li,
        dl,
        dt,
        dd,
        input {
            margin: 0;
            padding: 0;
        }

        /* 內减模式 */
        * {
            box-sizing: border-box;
        }

        /* 设置网页统一的字体大小、行高、字体系列相关属性 */
        body {
            font: 16px/1.5 "Helvetica Neue", Helvetica, Arial, "Microsoft Yahei",
            "Hiragino Sans GB", "Heiti SC", "WenQuanYi Micro Hei", sans-serif;
            color: #333;
        }

        /* 去除列表默认样式 */
        ul,
        ol {
            list-style: none;
        }

        /* 去除默认的倾斜效果 */
        em,
        i {
            font-style: normal;
        }

        /* 去除a标签默认下划线,并设置默认文字颜色 */
        a {
            text-decoration: none;
            color: #333;
        }

        /* 设置img的垂直对齐方式为居中对齐,去除img默认下间隙 */
        img {
            vertical-align: middle;
        }

        /* 去除input默认样式 */
        input {
            border: none;
            outline: none;
            color: #333;
        }

        /* 左浮动 */
        .fl {
            float: left;
        }

        /* 右浮动 */
        .fr {
            float: right;
        }

        /* 双伪元素清除法 */
        .clearfix::before,
        .clearfix::after {
            content: "";
            display: table;
        }

        .clearfix::after {
            clear: both;
        }

        .report-box {
            overflow: hidden;
            width: 100%;
            height: 100vh;
            display: flex;
            justify-content: center;
            height: 100%;
            background: rgb(233, 238, 243);
        }

        .report-content {

            overflow-y: auto;
            width: 75%;
            background: #fff;
            padding: 30px;
        }

        .report-content-title {
            height: 100px;
            font-size: 30px;
            font-weight: bold;
            display: flex;
            /* justify-content: center; */
            align-items: center;
        }

        .report-content-result {
            padding-top: 15px;
            font-size: 18px;
            border-top: 2px dashed #ccc;
            border-bottom: 2px dashed #ccc;
        }

        .report-result-chart {
            display: flex;
        }

        .api-title {
            padding: 13px 0 0 15px;
            font-size: 16px;
            color: #666666;
            display: flex;
            flex-direction: column;

        span {
            margin-bottom: 10px;
        }

        :nth-child(1) {
            font-size: 18px;
        }

        }
        .table-box {
            padding-top: 10px;
            text-align: center;
        }

        .report-result-list > ul {
            width: 100%;
            display: flex;
            flex-wrap: wrap;
            font-size: 14px;
            color: #666666;
            padding-top: 15px;
        }

        .api-info-item {
            width: 25%;
            height: 30px;
        }

        .detial-info-title {
            padding-top: 20px;
            display: flex;
            align-items: center;
        }

        .detial-info-title-state {
            width: 18px;
            height: 18px;
            background: #f2f2f2;
            display: flex;
            align-items: center;
            justify-content: center;
            margin-right: 10px;
        }

        .detial-info-title-state-img {
            width: 10px;
            height: 5px;
        }

        .report-resulr-detail {
            font-size: 16px;
        }

        .detial-info-list-api {
            width: 100%;
            margin-top: 10px;
            height: 30px;
            background: #f0eeee;
            display: flex;
            align-items: center;
        }

        .detial-info-list-api > span {
            margin-right: 20px;
        }

        .api-info-item-var {
            font-size: 14px;
            color: #666666;
        }
    </style>
</head>
<script src="https://cdnjs.cloudflare.com/ajax/libs/echarts/5.4.3/echarts.min.js"></script>
<body>
<div class="report-box">
    <div class="report-content">
        <div class="report-content-title">$result.title</div>
        <div class="report-content-result">
            <img src="$result.reportUrl" alt=""/>
            <span style="color: rgb(246, 115, 12)">测试结果</span>

            <div class="report-result-chart">
                <div id="chart" style="width: 100px; height: 100px"></div>
                <div class="api-title">
                    <span>接口</span>
                    <span>接口通过率:<span>$result.successRate</span></span>
                    <span>接口失败率:<span>$result.failedRate</span></span>
                </div>
            </div>
            <div class="table-box">
                <table
                        border="1"
                        bordercolor="#ccc"
                        width="100%"
                        height="100"
                        align="center"
                        cellspacing="0"
                        cellpadding="0"
                >
                    <tr>
                        <td width="25%"></td>
                        <td width="25%">请求总数</td>
                        <td width="25%">通过</td>
                        <td width="25%">失败</td>
                    </tr>
                    <tr>
                        <td>接口</td>
                        <td>${result.requestTotal}</td>
                        <td>${result.successNum}</td>
                        <td>${result.failedNum}</td>
                    </tr>
                </table>
            </div>

            <div class="report-result-list">
                <ul>
                    <li class="api-info-item">
                        开始时间:<span>${result.startTime}</span>
                    </li>
                    <li class="api-info-item">
                        结束时间:<span>${result.endTime}</span>
                    </li>
                    <li class="api-info-item">总耗时:<span>${result.timeTotal}ms</span></li>
                    <li class="api-info-item">总响应时间:<span>${result.allTime}ms</span></li>
                    <li class="api-info-item">
                        平均响应时间:<span>${result.perTime}ms</span>
                    </li>
                    <li class="api-info-item">
                        总响应数据大小: <span>${result.responseSizeStr}</span>
                    </li>
                    <li class="api-info-item">未测接口:<span>${result.waitNum}</span></li>
                </ul>
            </div>
        </div>
        <div class="report-resulr-detail">
            <img src="$result.reportUrl" alt=""/>
            <span style="color: rgb(246, 115, 12); font-size: 16px"
            >测试情况</span
            >
            <div>
                <div class="detial-info-title">
                    <div class="detial-info-title-state">
                        <img
                                src="$result.arrowUrl"
                                alt=""
                                class="detial-info-title-state-img"
                        />
                    </div>
                    <span>失败情况</span>
                </div>
                #foreach($item in $result.failedResultList)
                    <div>
                        <div class="detial-info-list-api">
                            <img
                                    src="$result.errorUrl"
                                    alt=""
                                    style="width: 18px; height: 18px; margin-right: 20px"
                            />
                            <span>$foreach.index </span>
                            <span style="color: #26cea4"> $item.method</span>
                            <span>$item.interfaceName</span>
                            <span style="color: blue"
                            >$item.requestUri</span
                            >
                        </div>
                        <div class="report-result-list">
                            <ul>
                                <li class="api-info-item">请求状况:<span>#if($item.success) 成功 #else 失败 #end</span>
                                </li>
                                <li class="api-info-item">状态码:<span>$item.code</span></li>
                                <li class="api-info-item">响应时间:<span>$item.responseTime</span></li>
                                <li class="api-info-item">相应数据大小:<span>$item.responseSizeStr</span></li>
                            </ul>
                            <div class="api-info-item-var">
                                <div>
                                    请求参数:<span
                                >$item.paramsJson</span
                                >
                                </div>
                                <div style="margin-top: 4px">
                                    响应数据:<span
                                >$item.responseJson</span
                                >
                                </div>
                            </div>
                        </div>
                    </div>
                #end
            </div>
            <div>
                <div class="detial-info-title">
                    <div class="detial-info-title-state">
                        <img
                                src="$result.arrowUrl"
                                alt=""
                                class="detial-info-title-state-img"
                        />
                    </div>
                    <span>测试详情</span>
                </div>
                #foreach($item in $result.allResultList)
                    <div>
                        <div class="detial-info-list-api">
                            <img
                                    src="#if($item.success) $result.successUrl #else $result.errorUrl #end"
                                    alt=""
                                    style="width: 18px; height: 18px; margin-right: 20px"
                            />
                            <span>$foreach.index </span>
                            <span style="color: #26cea4"> $item.method</span>
                            <span>$item.interfaceName</span>
                            <span style="color: blue"
                            >$item.requestUri</span
                            >
                        </div>
                        <div class="report-result-list">
                            <ul>
                                <li class="api-info-item">请求状况:<span>#if($item.success) 成功 #else 失败 #end</span>
                                </li>
                                <li class="api-info-item">状态码:<span>$item.code</span></li>
                                <li class="api-info-item">响应时间:<span>$item.responseTime</span></li>
                                <li class="api-info-item">相应数据大小:<span>$item.responseSizeStr</span></li>
                            </ul>
                            <div class="api-info-item-var">
                                <div>
                                    请求参数:<span
                                >$item.paramsJson</span
                                >
                                </div>
                                #if(!$item.success)
                                    <div style="margin-top: 4px">
                                        响应数据:<span
                                    >$item.responseJson</span
                                    >
                                    </div>
                                #end
                            </div>
                        </div>
                    </div>
                #end
            </div>
        </div>
    </div>
</div>

<script type="text/javascript">
    var Chart = echarts.init(document.getElementById("chart"));
    var option = {
        tooltip: {
            trigger: "item",
        },
        series: [
            {
                color: ["#26CEA4", "#FA5A5A"],
                name: "接口",
                type: "pie",
                radius: ["50%", "90%"],
                avoidLabelOverlap: false,
                label: {
                    show: false,
                    position: "center",
                },
                emphasis: {
                    label: {
                        show: true,
                        fontSize: 16,
                        fontWeight: "bold",
                    },
                },
                labelLine: {
                    show: false,
                },
                data: [
                    {value: 1048, name: "接口通过率"},
                    {value: 735, name: "接口失败率"},
                ],
            },
        ],
    };

    Chart.setOption(option);
</script>
</body>
</html>

3. 导出相应的测试报告

每次测试计划执行之后都会有一个执行记录,也就是这个testingPlanRunLog。

根据这个runLog查询到相应的结果记录。

java 复制代码
@Override
public void exportRunLogResult(TestingPlanRunLog runLog, HttpServletResponse response) throws IOException, DocumentException {
    Integer exportType = runLog.getExportType();
    if (exportType == null) {
        runLog.setExportType(1);
    }
    String ext = "";
    switch (runLog.getExportType()) {
        case 0 :
            ext = "md";
            break;
        case 2 :
            ext = "pdf";
            break;
        default:
            ext = "html";
    }
    String fileName = "导出结果." + ext;
    ServletOutputStream outputStream = response.getOutputStream();
    response.setHeader("Content-Disposition", "attachment;filename="+ URLEncoder.encode(fileName, "UTF-8"));
    response.setHeader("Connection", "close");
    response.setHeader("Content-Type", "application/octet-stream");
    //TestingResultVo testingResultVo = testResultTemplate(runLog);
    TestingResultVo testResultVO = testingResultMapper.getTestingResultById(runLog);
    exportUtil.writeToOutputStream(outputStream, testResultVO, exportType);
}
  • 导出工具
java 复制代码
import net.lesscoding.testing.model.vo.TestingResultVo;
import com.itextpdf.text.Document;
import com.itextpdf.text.DocumentException;
import com.itextpdf.text.html.simpleparser.HTMLWorker;
import com.itextpdf.text.pdf.PdfWriter;
import lombok.extern.slf4j.Slf4j;
import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.Velocity;
import org.springframework.stereotype.Component;

import java.io.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

/**
 * @author eleven
 * @date 2023/11/16 15:46
 * @apiNote
 */
@Component
@Slf4j
public class PlanResultExportUtil {

    public void writeToOutputStream(OutputStream outputStream, TestingResultVo resultVo, Integer type) throws IOException, DocumentException {
        String templatePath = type == 0 ?  "templates/planResult.md.vm" : "templates/planResult.html.vm";
        log.info("当前类型 {}, 模板地址{}", type, templatePath);
        Template template = buildTemplate(templatePath);
        PrintWriter writer = new PrintWriter(outputStream);
        template.merge(resultContext(resultVo), writer);
        log.info("当前data {}", writer.toString());
        // md 和 html 直接导出
        if (type != 2) {
            writer.flush();
            writer.close();
        } else {
            // todo 暂时无法直接从 writer生成pdf
        }
    }

    /**
     * 根据结果获取velocity的模板
     * @param resultVo          执行结果
     * @return
     */
    private VelocityContext resultContext(TestingResultVo resultVo) {
        Map<String, Object> contextMap = new HashMap<>(1);
        contextMap.put("result", resultVo);
        return new VelocityContext(contextMap);
    }

    public static Template buildTemplate(String path){
        Properties properties = new Properties();
        // 这里当然可以配置在yml里边
        properties.put("file.resource.loader.class", "org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader");
        properties.put("directive.foreach.counter.name", "velocityCount");
        properties.put("directive.foreach.counter.initial.value", "1");
        Velocity.init(properties);
        return Velocity.getTemplate(path,"utf-8");
    }
}

3. 生成邮件通知

这个用的是hutool的东西,更是low的一笔。页面丑的我自己都不想看

1. 申请秘钥

各大邮箱都可以开通pop3 stmp等等的

2. 创建配置

在resources下创建mail.settings文件

ini 复制代码
host=smtp.qq.com
port=465
from=XXX@qq.com
user=xxx
pass=刚才获取到的授权码
sslEnable=true

3. 创建一个非常丑的html

应该也能用velocity, 不过我懒

html 复制代码
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>

        .container {
            text-align: center;
        }

        .header {
            font-size: 30px;
            font-weight: bold;
            font-family: 黑体;
        }

        .tips {
            font-size: 16px;
            font-family: 宋体;
            color: darkgrey;
        }

        .bold {
            font-weight: bold;
            font-size: 18px;
            font-family: 黑体;
            text-decoration: underline;
        }
        .success {
            color: greenyellow;
        }
        .failed {
            color: red;
        }
    </style>
</head>
<body>
<div class="container">
    <div class="header">
        <p><span class="bold">{projectName}-{dateStr} 报告邮件</span></p>
        <hr>
    </div>
    <div class="mid">
        <div>
            <p>
                尊敬的用户【<span class="bold">{projectLeader}</span>】您好:
            </p>
            <p>
                您所负责的【<span class="bold">{projectName}</span> 】项目<br>,
                于 <span class="bold">{dateStr}</span>自动执行测试计划<span class="bold">{planName}</span>,环境<span class="bold">{profileName}</span><br>
                本次共执行了<span class="bold">{interfaceTotal}</span>个接口<br>
                成功<span class="bold success">{successNum}</span>个,失败<span class="bold failed">{failedNum}</span>个<br>
                详情请点击此链接查看<a href="{detailUrl}">{projectName}{dateStr}</a>
            </p>
        </div>
        <div class="tips">
            <p>( 如不是本人操作,请联系统管理员 请勿直接回复本邮件 )</p>
            <br>
            <hr>
        </div>
    </div>

</div>
<p>


</p>
</body>
</html>

4. 发送邮件

总的来说就是执行完成之后,将结果拼接好 替换html里边的内容就行了

java 复制代码
import net.lesscoding.testing.model.vo.EmailContentVo;
import net.lesscoding.testing.model.vo.TestingResultVo;
import cn.hutool.core.io.resource.ClassPathResource;
import cn.hutool.extra.mail.MailUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;

/**
 * @author eleven
 * @date 2023/11/16 14:17
 * @apiNote
 */
@Component
@Slf4j
public class EmailUtil {

    public Boolean sendResultEmail(EmailContentVo vo) throws IOException {
        String template = template();
        String content = replaceTemplate(template, getContextMap(vo));
        log.info("当前邮件内容 {}", content);
        String send = MailUtil.send(vo.getLeaderEmail(), vo.getSubject(), content, null, true, null);
        return false;
    }

    private Map<String, String> getContextMap(EmailContentVo vo) {
        Map<String,String> resultMap = new HashMap<>(7);
        resultMap.put("{projectName}", vo.getProjectName());
        resultMap.put("{projectLeader}", vo.getProjectLeader());
        resultMap.put("{planName}", vo.getPlanName());
        resultMap.put("{profileName}", vo.getProfileName());
        resultMap.put("{dateStr}", vo.getDateStr());
        resultMap.put("{interfaceTotal}", vo.getInterfaceTotal().toString());
        resultMap.put("{successNum}", vo.getSuccessNum().toString());
        resultMap.put("{failedNum}", vo.getFailedNum().toString());
        resultMap.put("{detailUrl}", vo.getDetailUrl().toString());
        return resultMap;
    }

    private String template() throws IOException {
        ClassPathResource resource = new ClassPathResource("templates/planCompleted.html");
        InputStream stream = resource.getStream();
        // 读取文件内容为字节数组
        byte[] fileContent = Files.readAllBytes(resource.getFile().toPath());
        // 将字节数组转换为字符串
        return new String(fileContent, StandardCharsets.UTF_8);
    }

    /**
     * 替换模板
     *
     * @param template   模板内容
     * @param contextMap 替换内容
     */
    private String replaceTemplate(String template, Map<String, String> contextMap) {
        for (String key : contextMap.keySet()) {
            template = template.replace(key, contextMap.get(key));
        }
        return template;
    }
}

4. 实时调用获取结果

需要创建一个Websocket服务,这个真是折磨了我很久啊。

1. 引入依赖

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

2. 创建Websocket配置

1. 消息处理

java 复制代码
package net.lesscoding.testing.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

/**
 * @author 24962
 */
@Component
@Slf4j
public class MyWebSocketHandler extends TextWebSocketHandler {


    public static List<WebSocketSession> list = new ArrayList<>();

    public List<WebSocketSession> getSessionList() {
        return list;
    }

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 当WebSocket连接建立时调用
        list.add(session);
        log.info("websocket链接已建立");
    }

    public void sendMessageToSession(WebSocketSession session, String message) throws Exception {
        // 向指定会话发送消息
        session.sendMessage(new TextMessage(message));
    }

    public void broadcastMessage(String message) throws Exception {
        // 广播消息到所有会话
        for(Iterator<WebSocketSession> sessionIterator = list.listIterator(); sessionIterator.hasNext();) {
            WebSocketSession session = sessionIterator.next();
            if (session != null && session.isOpen()) {
                session.sendMessage(new TextMessage(message));
            } else {
                sessionIterator.remove();
            }
        }

    }

    @Override
    public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception {
        // 当接收到WebSocket消息时调用
        String receivedMessage = (String) message.getPayload();
        // 处理接收到的消息
        log.info("当前服务端收到消息 {}", receivedMessage);
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 当WebSocket传输错误时调用
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
        log.info("当前链接关闭 {}, 状态 {}", session, closeStatus);
        // 当WebSocket连接关闭时调用
        list.remove(session);
    }

    @Override
    public boolean supportsPartialMessages() {
        return false;
    }
}

2. websocket配置

超时自动断开连接, 配置websocket端口等等。

现在的端口为 ws://{host}:{port}/{context-path}/websocket

java 复制代码
package net.lesscoding.testing.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean;

/**
 * @author 24962
 */
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer{

    @Autowired
    private WebsocketInterceptor websocketInterceptor;
	
    // 配置连接端口
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 指定WebSocket处理程序和路径
        registry.addHandler(new MyWebSocketHandler(), "/websocket")
            	// 特别注意下边这两个行, 否则js原生的 new WebSocket链接不上 淦!!!!
                .setAllowedOrigins("*")
                .addInterceptors(websocketInterceptor);
    }

    @Bean
    public ServletServerContainerFactoryBean createWebSocketContainer() {
        ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean();

        container.setMaxTextMessageBufferSize(512000);
        container.setMaxBinaryMessageBufferSize(512000);
        // 30s 活跃
        container.setMaxSessionIdleTimeout(30 * 1000L);
        return container;
    }

}

3. 拦截器

这个没怎么用上

java 复制代码
package net.lesscoding.testing.config;

import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;

import java.util.Map;

/**
 * @author eleven
 * @date 2023/11/15 12:16
 * @apiNote
 */
@Component
public class WebsocketInterceptor implements HandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception {
        System.out.println("前置拦截");
        return true;
    }

    @Override
    public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
        System.out.println("后置拦截");
    }
}

3. 实时调用结果

总的来说呢,就是在这个 runPlan的方法里边每次HttpClient调用了一个请求之中,都算出来结果

成功数量,失败数量,等待数量,成功率,每个接口的返回结果等

然后调用websocket的广播发送给前端就行了

单播其实也能实现,就是懒

java 复制代码
/**
     * 广播消息
     * @param prefixUrl
     * @param timer
     * @param socketResult
     * @param item
     * @param responseStr
     * @throws Exception
     */
private void broadcastSocketMessage(String prefixUrl, TimeInterval timer, SocketResultVo socketResult,
                                    ProjectInterface item, String responseStr) throws Exception {
    // 有websocket链接再进行广播
    ResultVo resultVo = new ResultVo();
    resultVo.setMethod(item.getMethod());
    resultVo.setInterfaceName(item.getInterfaceName());
    resultVo.setRequestUri(String.format("%s%s", prefixUrl, item.getInterfaceUri()));
    resultVo.setSuccess(responseStr.contains(Consts.SUCCESS_CODE.toString()));
    resultVo.setCode(PlanResultUtil.getResponseCode(responseStr));
    resultVo.setResponseTime(timer.intervalMs(item.getId()));
    resultVo.setResponseSize((long)responseStr.length());
    resultVo.setResponseJson(responseStr);
    socketResult.addResultVo(resultVo);
    log.info("当前执行方法 {}, 当前对象{}", item.getInterfaceUri(), socketResult);
    String socketMsg = gson.toJson(socketResult);
    socketHandler.broadcastMessage(socketMsg);
    log.info("websocket广播消息{}", socketMsg);
}
相关推荐
小码编匠25 分钟前
.NET 9 发布 性能提升、AI 支持与全方位改进
人工智能·后端·.net
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS美发门店管理系统(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·kafka·开源
Slow菜鸟1 小时前
SpringBoot教程(二十五) | SpringBoot配置多个数据源
java·spring boot·后端
baozhengw2 小时前
IntelliJ+SpringBoot项目实战(四)--快速上手数据库开发
spring boot·后端·数据库开发
颜淡慕潇2 小时前
【数据库系列】 Spring Boot 集成 Neo4j 的详细介绍
java·数据库·spring boot·后端·neo4j
许野平2 小时前
Rust:GUI 开源框架
开发语言·后端·rust·gui
刘翔在线犯法2 小时前
Scala的迭代器
开发语言·后端·scala
无敌小肥0072 小时前
Springboot 整合 itext 实现PDF文件合并,识别图片则转成PDF拼接
spring boot·后端·pdf
uhakadotcom3 小时前
Go 1.23 标准库新特性:深入解析 unique 功能
后端
IT大玩客3 小时前
SpringBoot如何集成WebSocket
spring boot·后端·websocket