1. 概述

继我们之前关于 使用 Spring 构建 Reddit Web 应用的案例研究 之后,本文将进行第二轮功能迭代。本轮目标是提升用户体验,让应用更易用、更智能。

这些改进涵盖了分页、登录控制、高级发布策略、日志告警、缓存优化以及基础监控,都是实际项目中常见的“踩坑”点和优化方向。


2. 定时发布内容的分页支持

当前定时发布的帖子列表没有分页,当数量较多时用户体验极差。我们需要引入分页机制,让列表更清晰易读。

2.1. 分页数据查询实现

借助 Spring Data JPA,我们可以非常简洁地实现分页查询。只需在 Repository 接口中声明方法,框架会自动生成实现。

public interface PostRepository extends JpaRepository<Post, Long> {
    Page<Post> findByUser(User user, Pageable pageable);
}

控制器方法接收页码参数,默认每页 10 条:

private static final int PAGE_SIZE = 10;

@RequestMapping("/scheduledPosts")
@ResponseBody
public List<Post> getScheduledPosts(
  @RequestParam(value = "page", required = false, defaultValue = "0") int page) {
    User user = getCurrentUser();
    Page<Post> posts = 
      postRepository.findByUser(user, PageRequest.of(page, PAGE_SIZE));
    
    return posts.getContent();
}

⚠️ 注意:required = falsedefaultValue = "0" 确保了首次访问时 page 参数为 0,避免空指针。

2.2. 前端分页控件实现

前端使用简单的 jQuery 实现分页按钮和数据加载:

<table>
  <thead><tr><th>帖子标题</th></tr></thead>
  <tbody id="postList"></tbody>
</table>
<br/>
<button id="prev" onclick="loadPrev()">上一页</button> 
<button id="next" onclick="loadNext()">下一页</button>

JavaScript 脚本:

$(function(){ 
    loadPage(0); 
}); 

var currentPage = 0;
function loadNext(){ 
    if (currentPage < totalPages) loadPage(currentPage + 1);
} 

function loadPrev(){ 
    if (currentPage > 0) loadPage(currentPage - 1); 
}

function loadPage(page){
    currentPage = page;
    $('#postList').empty();
    $.get("/scheduledPosts?page=" + page, function(data){
        $.each(data, function(index, post) {
            $('#postList').append('<tr><td>' + post.title + '</td></tr>');
        });
    }).fail(function() {
        alert('加载失败');
    });
}

✅ 提示:虽然这里用了原生 jQuery,但生产环境建议替换为 DataTables 等成熟表格插件。


3. 根路径登录状态分流

用户访问首页 / 时,应根据登录状态返回不同页面:

  • 已登录 → 跳转至个人主页(dashboard)
  • 未登录 → 显示登录页

这个逻辑非常基础,但容易被忽略。

@RequestMapping("/")
public String homePage() {
    if (SecurityContextHolder.getContext().getAuthentication() != null 
        && SecurityContextHolder.getContext().getAuthentication().isAuthenticated()) {
        return "home";
    }
    return "index";
}

⚠️ 注意:判断登录状态时不仅要检查 Authentication 是否为空,还要确认其 isAuthenticated() 返回 true,避免匿名用户被误判。


4. 发布重试的高级控制选项

Reddit 的“删除并重新发布”功能很强大,但需要精细化控制。我们引入三个新选项,避免误操作或无效重试。

4.1. Post 实体扩展

Post 实体中新增三个字段:

@Entity
public class Post {
    ...
    private int minUpvoteRatio;           // 最低点赞率(百分比)
    private boolean keepIfHasComments;    // 有评论时保留帖子
    private boolean deleteAfterLastAttempt; // 最后一次尝试失败后删除
}

字段说明:

  • minUpvoteRatio:用户期望的最低点赞率(0-100),例如 70 表示至少 70% 的投票是赞
  • keepIfHasComments:若帖子已有评论,即使未达标也保留
  • deleteAfterLastAttempt:所有重试机会用尽且未达标时,是否自动删除

4.2. 调度器逻辑增强

调度器定期检查是否需要删除或重试帖子。关键逻辑在 checkAndDelete() 方法:

@Scheduled(fixedRate = 3 * 60 * 1000)
public void checkAndDeleteAll() {
    List<Post> submitted = postRepository
        .findByRedditIDNotNullAndNoOfAttemptsAndDeleteAfterLastAttemptTrue(0);
    
    for (Post post : submitted) {
        checkAndDelete(post);
    }
}

private void checkAndDelete(Post post) {
    if (didIntervalPass(post.getSubmissionDate(), post.getTimeInterval())) {
        if (didPostGoalFail(post)) {
            deletePost(post.getRedditID());
            post.setSubmissionResponse("Attempts consumed without reaching score");
            post.setRedditID(null);
            postRepository.save(post);
        } else {
            // 达标,重置尝试次数
            post.setNoOfAttempts(0);
            post.setRedditID(null);
            postRepository.save(post);
        }
    }
}

核心判断逻辑 didPostGoalFail()

private boolean didPostGoalFail(Post post) {
    PostScores scores = getPostScores(post);
    int score = scores.getScore();
    int upvoteRatio = scores.getUpvoteRatio();
    int commentCount = scores.getNoOfComments();

    boolean failedByScoreOrRatio = 
        (score < post.getMinScoreRequired()) || (upvoteRatio < post.getMinUpvoteRatio());

    boolean shouldKeepDueToComments = 
        (commentCount > 0) && post.isKeepIfHasComments();

    return failedByScoreOrRatio && !shouldKeepDueToComments;
}

获取帖子评分信息(来自 Reddit API):

public PostScores getPostScores(Post post) {
    JsonNode node = restTemplate.getForObject(
        "http://www.reddit.com/r/" + post.getSubreddit() + 
        "/comments/" + post.getRedditID() + ".json", JsonNode.class);
    PostScores scores = new PostScores();

    node = node.get(0).get("data").get("children").get(0).get("data");
    scores.setScore(node.get("score").asInt());
    
    double ratio = node.get("upvote_ratio").asDouble();
    scores.setUpvoteRatio((int) (ratio * 100));
    
    scores.setNoOfComments(node.get("num_comments").asInt());
    
    return scores;
}

PostScores 值对象:

public class PostScores {
    private int score;
    private int upvoteRatio;
    private int noOfComments;
    // getter/setter
}

⚠️ 注意:checkAndReSubmit() 方法也需同步修改,确保成功重试后重置 redditID

4.3. 前端表单更新

在发布表单 schedulePostForm.html 中添加新选项:

<label>
  最低点赞率 (%):
  <input type="number" name="minUpvoteRatio" min="0" max="100" value="60"/>
</label>

<label>
  <input type="checkbox" name="keepIfHasComments" value="true"/>
  有评论时保留帖子
</label>

<label>
  <input type="checkbox" name="deleteAfterLastAttempt" value="true"/>
  最后一次失败后删除
</label>

5. 重要日志邮件告警

生产环境必须对错误日志敏感。我们通过 Logback 配置,实现 ERROR 级别日志自动邮件通知。

5.1. 添加邮件依赖

<dependency>
    <groupId>jakarta.mail</groupId>
    <artifactId>jakarta.mail-api</artifactId>
    <version>2.1.2</version>
</dependency>

5.2. 配置 SMTPAppender

logback.xml 中添加邮件 Appender:

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <appender name="EMAIL" class="ch.qos.logback.classic.net.SMTPAppender">
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <smtpHost>smtp.gmail.com</smtpHost>
        <smtpPort>587</smtpPort>
        <username>admin@reddittool.com</username>
        <password>securePassword123</password>
        <to>dev-team@reddittool.com</to>
        <from>admin@reddittool.com</from>
        <subject>【ERROR】%logger{20} - %m</subject>
        <layout class="ch.qos.logback.classic.html.HTMLLayout"/>
        <asynchronousSending>true</asynchronousSending>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="EMAIL"/>
    </root>
</configuration>

✅ 效果:一旦应用抛出未捕获异常,开发团队将立即收到邮件,快速响应。


6. Subreddit 缓存优化

原始实现中,每次用户输入 subreddit 名称时都调用 Reddit API 实时查询,体验差且浪费资源。

我们改为缓存热门 subreddit 列表,实现本地自动补全。

6.1. 初始化缓存数据

编写一次性脚本,抓取前 2000 个热门 subreddit 并保存到本地 CSV:

public void getAllSubreddits() {
    JsonNode node;
    String after = "";
    FileWriter writer = null;
    try {
        writer = new FileWriter("src/main/resources/subreddits.csv");
        for (int i = 0; i < 20; i++) {
            node = restTemplate.getForObject(
                "https://www.reddit.com/subreddits/popular.json?limit=100&after=" + after, 
                JsonNode.class);
            after = node.get("data").get("after").asText();
            node = node.get("data").get("children");
            for (JsonNode child : node) {
                String name = child.get("data").get("display_name").asText();
                writer.append(name).append(",");
            }
            Thread.sleep(3000); // 避免请求过快被限流
        }
        writer.close();
    } catch (Exception e) {
        logger.error("Error fetching subreddits", e);
    }
}

⚠️ 生产建议:可考虑使用定时任务定期更新缓存,或使用 Redis 存储。

6.2. 启动加载与自动补全

服务启动时加载 CSV 到内存:

@Service
public class SubredditService implements InitializingBean {

    private List<String> subreddits = new ArrayList<>();

    @Override
    public void afterPropertiesSet() {
        loadSubreddits();
    }

    private void loadSubreddits() {
        try {
            Resource resource = new ClassPathResource("subreddits.csv");
            Scanner scanner = new Scanner(resource.getFile());
            scanner.useDelimiter(",");
            while (scanner.hasNext()) {
                String sr = scanner.next().trim();
                if (!sr.isEmpty()) subreddits.add(sr);
            }
            scanner.close();
            logger.info("Loaded {} subreddits into cache", subreddits.size());
        } catch (IOException e) {
            logger.error("Failed to load subreddits", e);
        }
    }

    public List<String> searchSubreddit(String query) {
        if (query == null || query.isEmpty()) return Collections.emptyList();
        return subreddits.stream()
            .filter(sr -> sr.toLowerCase().startsWith(query.toLowerCase()))
            .limit(9)
            .collect(Collectors.toList());
    }
}

暴露自动补全接口:

@RequestMapping(value = "/subredditAutoComplete")
@ResponseBody
public List<String> subredditAutoComplete(@RequestParam("term") String term) {
    return subredditService.searchSubreddit(term);
}

✅ 效果:前端输入时通过 AJAX 调用此接口,实现毫秒级响应的自动补全。


7. 接口调用监控

为掌握应用运行状况,我们添加基础的 HTTP 接口调用统计。

7.1. 监控过滤器

通过 Servlet Filter 拦截所有请求:

@Component
public class MetricFilter implements Filter {

    @Autowired
    private IMetricService metricService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                         FilterChain chain) throws IOException, ServletException {
        
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestKey = httpRequest.getMethod() + " " + httpRequest.getRequestURI();

        chain.doFilter(request, response);

        int status = ((HttpServletResponse) response).getStatus();
        metricService.increaseCount(requestKey, status);
    }
}

注册到容器:

@Override
public void onStartup(ServletContext servletContext) throws ServletException {
    super.onStartup(servletContext);
    servletContext.addListener(new SessionListener());
    registerProxyFilter(servletContext, "oauth2ClientContextFilter");
    registerProxyFilter(servletContext, "springSecurityFilterChain");
    registerProxyFilter(servletContext, "metricFilter"); // 注册监控过滤器
}

7.2. 监控服务接口

public interface IMetricService {
    void increaseCount(String request, int status);
    Map<String, Integer> getFullMetric();        // 全部请求统计
    Map<Integer, Integer> getStatusMetric();     // 各状态码统计
    Object[][] getGraphData();                   // 图表数据
}

7.3. 监控数据接口

提供 HTTP 接口供前端展示:

@Controller
public class MetricController {
    
    @Autowired
    private IMetricService metricService;

    @RequestMapping(value = "/metric", method = RequestMethod.GET)
    @ResponseBody
    public Map<String, Integer> getMetric() {
        return metricService.getFullMetric();
    }

    @RequestMapping(value = "/status-metric", method = RequestMethod.GET)
    @ResponseBody
    public Map<Integer, Integer> getStatusMetric() {
        return metricService.getStatusMetric();
    }

    @RequestMapping(value = "/metric-graph-data", method = RequestMethod.GET)
    @ResponseBody
    public Object[][] getMetricGraphData() {
        Object[][] data = metricService.getGraphData();
        // 确保第一行为字符串(如时间戳转为字符串)
        for (int i = 1; i < data[0].length; i++) {
            data[0][i] = data[0][i].toString();
        }
        return data;
    }
}

✅ 建议:可结合 ECharts 或 Grafana 实现可视化监控面板。


8. 总结

这个 Reddit 工具从最初的 OAuth 教程,逐步演变为一个功能丰富的自动化发布平台。本轮优化聚焦于:

  • 用户体验:分页、自动补全
  • 系统健壮性:登录分流、错误告警
  • 智能策略:基于评论、点赞率的发布控制
  • 可观测性:基础监控与日志告警

实际使用中,这些改进显著提升了帖子的曝光效率。项目虽小,但涵盖了现代 Web 应用开发的多个关键环节,值得参考。


原始标题:Second Round of Improvements to the Reddit App

« 上一篇: Baeldung每周评论24
» 下一篇: Baeldung周报第25期