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 调度应用正朝着“让用户快速完成任务并离开”的目标稳步前进。每一步改进虽小,但累积起来就是质的飞跃。


原始标题:3rd Round of Improvements to the Reddit Application

« 上一篇: Baeldung每周评论31
» 下一篇: Baeldung每周评论32