1. 概述
本文继续推进我们之前那个小型案例项目——一个基于 Spring 的 Reddit 调度应用,对已有功能进行一系列小而实用的优化。
这些改进虽不炫酷,但能显著提升用户体验和系统健壮性。我们不追求大刀阔斧的重构,而是专注于“把事情做对、做好”,比如分页增强、邮件通知、逻辑简化和输入校验等。
2. 更智能的表格展示
我们用 jQuery 的 DataTables 插件替换掉原来简陋的 HTML 表格,让数据展示更专业、交互更流畅。
2.1 Post 仓库与服务层
首先,在 PostRepository
中添加一个统计用户定时发布文章数量的方法。Spring Data JPA 的命名查询语法让这事变得简单粗暴:
public interface PostRepository extends JpaRepository<Post, Long> {
...
Long countByUser(User user);
}
接着看服务层,根据分页参数获取用户的文章列表。这里有个关键点:时间要按用户的时区显示,否则跨国用户会一脸懵。
@Override
public List<SimplePostDto> getPostsList(int page, int size, String sortDir, String sort) {
PageRequest pageReq = new PageRequest(page, size, Sort.Direction.fromString(sortDir), sort);
Page<Post> posts = postRepository.findByUser(userService.getCurrentUser(), pageReq);
return constructDataAccordingToUserTimezone(posts.getContent());
}
转换时区的工具方法如下:
private List<SimplePostDto> constructDataAccordingToUserTimezone(List<Post> posts) {
String timeZone = userService.getCurrentUser().getPreference().getTimezone();
return posts.stream().map(post -> new SimplePostDto(
post, convertToUserTomeZone(post.getSubmissionDate(), timeZone)))
.collect(Collectors.toList());
}
private String convertToUserTomeZone(Date date, String timeZone) {
dateFormat.setTimeZone(TimeZone.getTimeZone(timeZone));
return dateFormat.format(date);
}
⚠️ 注意:dateFormat
应是线程安全的,建议使用 DateTimeFormatter
(Java 8+)替代 SimpleDateFormat
避免踩坑。
2.2 支持分页排序的接口
接下来,把这个带分页和排序的服务通过接口暴露出去:
@RequestMapping(method = RequestMethod.GET)
@ResponseBody
public List<SimplePost> getScheduledPosts(
@RequestParam(value = "page", required = false, defaultValue = "0") int page,
@RequestParam(value = "size", required = false, defaultValue = "10") int size,
@RequestParam(value = "sortDir", required = false, defaultValue = "asc") String sortDir,
@RequestParam(value = "sort", required = false, defaultValue = "title") String sort,
HttpServletResponse response) {
response.addHeader("PAGING_INFO",
scheduledPostService.generatePagingInfo(page, size).toString());
return scheduledPostService.getPostsList(page, size, sortDir, sort);
}
✅ 关键点:我们用一个自定义响应头 PAGING_INFO
把分页信息传给前端。虽然这不是最标准的做法(比如用 Link
头更符合 REST 规范),但胜在简单直接,适合快速迭代。
生成分页信息的逻辑也很清晰:
public PagingInfo generatePagingInfo(int page, int size) {
long total = postRepository.countByUser(userService.getCurrentUser());
return new PagingInfo(page, size, total);
}
PagingInfo
类封装了分页元数据:
public class PagingInfo {
private long totalNoRecords;
private int totalNoPages;
private String uriToNextPage;
private String uriToPrevPage;
public PagingInfo(int page, int size, long totalNoRecords) {
this.totalNoRecords = totalNoRecords;
this.totalNoPages = Math.round(totalNoRecords / size);
if (page > 0) {
this.uriToPrevPage = "page=" + (page - 1) + "&size=" + size;
}
if (page < this.totalNoPages) {
this.uriToNextPage = "page=" + (page + 1) + "&size=" + size;
}
}
}
2.3 前端集成 DataTables
前端使用 DataTables 的服务器端模式,通过 AJAX 调用上述接口:
<table>
<thead><tr>
<th>Post title</th><th>Submission Date</th><th>Status</th>
<th>Resubmit Attempts left</th><th>Actions</th>
</tr></thead>
</table>
<script>
$(document).ready(function() {
$('table').dataTable( {
"processing": true,
"searching":false,
"columnDefs": [
{ "name": "title", "targets": 0 },
{ "name": "submissionDate", "targets": 1 },
{ "name": "submissionResponse", "targets": 2 },
{ "name": "noOfAttempts", "targets": 3 } ],
"columns": [
{ "data": "title" },
{ "data": "submissionDate" },
{ "data": "submissionResponse" },
{ "data": "noOfAttempts" }],
"serverSide": true,
"ajax": function(data, callback, settings) {
$.get('api/scheduledPosts', {
size: data.length,
page: (data.start/data.length),
sortDir: data.order[0].dir,
sort: data.columns[data.order[0].column].name
}, function(res,textStatus, request) {
var pagingInfo = request.getResponseHeader('PAGING_INFO');
var total = pagingInfo.split(",")[0].split("=")[1];
callback({recordsTotal: total, recordsFiltered: total,data: res});
});
}
} );
} );
</script>
2.4 分页接口测试
接口上线前,必须有测试保驾护航。以下是两个核心分页场景的测试用例:
@Test
public void givenMoreThanOnePage_whenGettingUserScheduledPosts_thenNextPageExist()
throws ParseException, IOException {
createPost();
createPost();
createPost();
Response response = givenAuth().
params("page", 0, "size", 2).get(urlPrefix + "/api/scheduledPosts");
assertEquals(200, response.statusCode());
assertTrue(response.as(List.class).size() > 0);
String pagingInfo = response.getHeader("PAGING_INFO");
long totalNoRecords = Long.parseLong(pagingInfo.split(",")[0].split("=")[1]);
String uriToNextPage = pagingInfo.split(",")[2].replace("uriToNextPage=", "").trim();
assertTrue(totalNoRecords > 2);
assertEquals(uriToNextPage, "page=1&size=2");
}
@Test
public void givenMoreThanOnePage_whenGettingUserScheduledPostsForSecondPage_thenCorrect()
throws ParseException, IOException {
createPost();
createPost();
createPost();
Response response = givenAuth().
params("page", 1, "size", 2).get(urlPrefix + "/api/scheduledPosts");
assertEquals(200, response.statusCode());
assertTrue(response.as(List.class).size() > 0);
String pagingInfo = response.getHeader("PAGING_INFO");
long totalNoRecords = Long.parseLong(pagingInfo.split(",")[0].split("=")[1]);
String uriToPrevPage = pagingInfo.split(",")[3].replace("uriToPrevPage=", "").trim();
assertTrue(totalNoRecords > 2);
assertEquals(uriToPrevPage, "page=0&size=2");
}
✅ 测试覆盖了首页和第二页的翻页链接生成,确保分页逻辑正确。
3. 邮件通知功能
用户发布文章后,能及时收到邮件通知,这是提升产品体验的关键一环。我们基于 Spring 的事件机制实现。
3.1 邮件配置
先配置 JavaMailSender
Bean:
@Bean
public JavaMailSenderImpl javaMailSenderImpl() {
JavaMailSenderImpl mailSenderImpl = new JavaMailSenderImpl();
mailSenderImpl.setHost(env.getProperty("smtp.host"));
mailSenderImpl.setPort(env.getProperty("smtp.port", Integer.class));
mailSenderImpl.setProtocol(env.getProperty("smtp.protocol"));
mailSenderImpl.setUsername(env.getProperty("smtp.username"));
mailSenderImpl.setPassword(env.getProperty("smtp.password"));
Properties javaMailProps = new Properties();
javaMailProps.put("mail.smtp.auth", true);
javaMailProps.put("mail.smtp.starttls.enable", true);
mailSenderImpl.setJavaMailProperties(javaMailProps);
return mailSenderImpl;
}
对应的 application.properties
配置示例(以 AWS SES 为例):
smtp.host=email-smtp.us-east-1.amazonaws.com
smtp.port=465
smtp.protocol=smtps
smtp.username=AKIAIOSFODNN7EXAMPLE
smtp.password=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
support.email=support@reddit-scheduler.com
3.2 发布成功时触发事件
当文章成功提交到 Reddit 后,触发一个自定义事件:
private void updatePostFromResponse(JsonNode node, Post post) {
JsonNode errorNode = node.get("json").get("errors").get(0);
if (errorNode == null) {
// ... 更新文章状态
String email = post.getUser().getPreference().getEmail();
eventPublisher.publishEvent(new OnPostSubmittedEvent(post, email));
}
// ...
}
3.3 事件与监听器
事件类定义:
public class OnPostSubmittedEvent extends ApplicationEvent {
private Post post;
private String email;
public OnPostSubmittedEvent(Post post, String email) {
super(post);
this.post = post;
this.email = email;
}
}
监听器负责发送邮件:
@Component
public class SubmissionListner implements ApplicationListener<OnPostSubmittedEvent> {
@Autowired
private JavaMailSender mailSender;
@Autowired
private Environment env;
@Override
public void onApplicationEvent(OnPostSubmittedEvent event) {
SimpleMailMessage email = constructEmailMessage(event);
mailSender.send(email);
}
private SimpleMailMessage constructEmailMessage(OnPostSubmittedEvent event) {
String recipientAddress = event.getEmail();
String subject = "Your scheduled post submitted";
SimpleMailMessage email = new SimpleMailMessage();
email.setTo(recipientAddress);
email.setSubject(subject);
email.setText(constructMailContent(event.getPost()));
email.setFrom(env.getProperty("support.email"));
return email;
}
private String constructMailContent(Post post) {
return "Your post " + post.getTitle() + " is submitted.\n" +
"http://www.reddit.com/r/" + post.getSubreddit() +
"/comments/" + post.getRedditID();
}
}
✅ 使用事件机制实现了业务解耦,后续扩展(如短信通知)也很方便。
4. 使用总投票数替代投票比率
之前用“upvote ratio”(点赞率)判断文章是否失败,逻辑复杂且不易理解。现在改为使用“总投票数(total votes)”,更直观。
计算公式推导:
- 得分(Score) = 点赞数(upvotes) - 点踩数(downvotes)
- 总投票数(Total Votes) = 点赞数 + 点踩数
- 点赞率(Upvote Ratio) = 点赞数 / 总投票数
由以上可得:
总投票数 = Math.round( Score / (2 × Upvote Ratio - 1) )
在获取文章分数时,一并计算并存储总投票数:
public PostScores getPostScores(Post post) {
// ...
float ratio = node.get("upvote_ratio").floatValue();
postScore.setTotalVotes(Math.round(postScore.getScore() / ((2 * ratio) - 1)));
// ...
}
然后在判断文章是否失败时,直接使用总投票数:
private boolean didPostGoalFail(Post post) {
PostScores postScores = getPostScores(post);
int totalVotes = postScores.getTotalVotes();
// ...
return (((score < post.getMinScoreRequired()) ||
(totalVotes < post.getMinTotalVotes())) &&
!((noOfComments > 0) && post.isKeepIfHasComments()));
}
✅ 逻辑更清晰,用户配置门槛降低。
5. 重发布选项的输入校验
重发布功能涉及多个参数,必须确保用户输入合法,避免无效调度。
5.1 ScheduledPost 服务层校验
添加一个简单的方法检查重发布选项是否有效:
private boolean checkIfValidResubmitOptions(Post post) {
if (checkIfAllNonZero(
post.getNoOfAttempts(),
post.getTimeInterval(),
post.getMinScoreRequired())) {
return true;
} else {
return false;
}
}
private boolean checkIfAllNonZero(int... args) {
for (int tmp : args) {
if (tmp == 0) {
return false;
}
}
return true;
}
在调度新文章时调用校验:
public Post schedulePost(boolean isSuperUser, Post post, boolean resubmitOptionsActivated)
throws ParseException {
if (resubmitOptionsActivated && !checkIfValidResubmitOptions(post)) {
throw new InvalidResubmitOptionsException("Invalid Resubmit Options");
}
// ... 执行调度逻辑
}
✅ 当启用重发布时,以下字段必须为非零:
- 重试次数(noOfAttempts)
- 重试间隔(timeInterval)
- 最低得分要求(minScoreRequired)
5.2 全局异常处理
定义 @ControllerAdvice
统一处理校验异常:
@ExceptionHandler({ InvalidResubmitOptionsException.class })
public ResponseEntity<Object> handleInvalidResubmitOptions
(RuntimeException ex, WebRequest request) {
logger.error("400 Status Code", ex);
String bodyOfResponse = ex.getLocalizedMessage();
return new ResponseEntity<Object>(
bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST);
}
5.3 重发布选项测试
通过集成测试覆盖各种边界情况:
public class ResubmitOptionsLiveTest extends AbstractLiveTest {
private static final String date = "2016-01-01 00:00";
@Test
public void
givenResubmitOptionsDeactivated_whenSchedulingANewPost_thenCreated()
throws ParseException, IOException {
Post post = createPost();
Response response = withRequestBody(givenAuth(), post)
.queryParams("resubmitOptionsActivated", false)
.post(urlPrefix + "/api/scheduledPosts");
assertEquals(201, response.statusCode());
Post result = objectMapper.reader().forType(Post.class).readValue(response.asString());
assertEquals(result.getUrl(), post.getUrl());
}
@Test
public void
givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroAttempts_thenInvalid()
throws ParseException, IOException {
Post post = createPost();
post.setNoOfAttempts(0);
post.setMinScoreRequired(5);
post.setTimeInterval(60);
Response response = withRequestBody(givenAuth(), post)
.queryParams("resubmitOptionsActivated", true)
.post(urlPrefix + "/api/scheduledPosts");
assertEquals(400, response.statusCode());
assertTrue(response.asString().contains("Invalid Resubmit Options"));
}
@Test
public void
givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroMinScore_thenInvalid()
throws ParseException, IOException {
Post post = createPost();
post.setMinScoreRequired(0);
post.setNoOfAttempts(3);
post.setTimeInterval(60);
Response response = withRequestBody(givenAuth(), post)
.queryParams("resubmitOptionsActivated", true)
.post(urlPrefix + "/api/scheduledPosts");
assertEquals(400, response.statusCode());
assertTrue(response.asString().contains("Invalid Resubmit Options"));
}
@Test
public void
givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroTimeInterval_thenInvalid()
throws ParseException, IOException {
Post post = createPost();
post.setTimeInterval(0);
post.setMinScoreRequired(5);
post.setNoOfAttempts(3);
Response response = withRequestBody(givenAuth(), post)
.queryParams("resubmitOptionsActivated", true)
.post(urlPrefix + "/api/scheduledPosts");
assertEquals(400, response.statusCode());
assertTrue(response.asString().contains("Invalid Resubmit Options"));
}
@Test
public void
givenResubmitOptionsActivated_whenSchedulingNewPostWithValidResubmitOptions_thenCreated()
throws ParseException, IOException {
Post post = createPost();
post.setMinScoreRequired(5);
post.setNoOfAttempts(3);
post.setTimeInterval(60);
Response response = withRequestBody(givenAuth(), post)
.queryParams("resubmitOptionsActivated", true)
.post(urlPrefix + "/api/scheduledPosts");
assertEquals(201, response.statusCode());
Post result = objectMapper.reader().forType(Post.class).readValue(response.asString());
assertEquals(result.getUrl(), post.getUrl());
}
private Post createPost() throws ParseException {
Post post = new Post();
post.setTitle("Test Post " + randomAlphabetic(4));
post.setUrl("https://example.com");
post.setSubreddit("programming");
post.setSubmissionDate(dateFormat.parse(date));
return post;
}
}
✅ 测试覆盖了所有激活/非激活、有效/无效的组合,确保校验逻辑坚如磐石。
6. 总结
本轮优化围绕“提升用户体验和系统健壮性”展开,具体包括:
- ✅ 前端体验:通过 DataTables 实现专业级表格,支持分页、排序。
- ✅ 用户触达:引入邮件通知,让用户及时知晓发布结果。
- ✅ 逻辑简化:用“总投票数”替代复杂的“点赞率”,降低用户理解成本。
- ✅ 输入校验:对重发布选项进行严格校验,防止无效配置。
整个 Reddit 调度应用正朝着“让用户快速完成任务并离开”的目标稳步前进。每一步改进虽小,但累积起来就是质的飞跃。