知識庫 / Spring RSS 訂閱

Reddit應用第二次改進

REST,Spring
HongKong
6
04:00 AM · Dec 06 ,2025

1. 概述

讓我們繼續對 Reddit 網頁應用進行案例研究,並進行一輪新的改進,旨在使該應用更易於使用,更友好。

2. 計劃發佈文章分頁

首先,我們以分頁的形式列出所有計劃發佈的文章,以便更輕鬆地查看和理解內容。

2.1. 批量操作

我們將使用 Spring Data 生成所需的批量操作,充分利用 Pageable 接口來檢索用户的已排期帖子:

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

以下是我們的控制器方法 getScheduledPosts():

private static final int PAGE_SIZE = 10;

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

2.2. 實現分頁顯示文章

現在,讓我們在前端實現一個簡單的分頁控件:

<table>
<thead><tr><th>Post title</th></thead>
</table>
<br/>
<button id="prev" onclick="loadPrev()">Previous</button> 
<button id="next" onclick="loadNext()">Next</button>

以下是如何使用純 jQuery 加載頁面:

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

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

function loadPrev(){ 
    loadPage(currentPage-1); 
}

function loadPage(page){
    currentPage = page;
    $('table').children().not(':first').remove();
    $.get("api/scheduledPosts?page="+page, function(data){
        $.each(data, function( index, post ) {
            $('.table').append('<tr><td>'+post.title+'</td><td></tr>');
        });
    });
}

隨着我們繼續前進,這張手冊表將很快被一個更成熟的表插件所取代,但目前它已經足夠使用。

3. 向未登錄用户顯示登錄頁面

當用户訪問根目錄時,如果他們已登錄,他們應該看到不同的頁面;如果他們未登錄,他們應該看到登錄頁面

如果用户已登錄,他們應該看到他們的主頁/儀表盤。如果他們未登錄,他們應該看到登錄頁面:

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

4. 帖子重發時的高級選項

在Reddit中刪除並重發帖子是一種有用的、高效的功能。然而,我們希望謹慎使用它並完全掌控何時應該以及何時不應該這樣做。

例如——我們可能不想刪除已經有評論的帖子。畢竟,評論代表着互動,我們希望尊重平台和評論該帖子的用户。

因此——這就是我們即將添加的第一個小但非常有用的功能——一個新的選項,它將允許我們僅在帖子中沒有評論時才刪除帖子。

另一個有趣的問題是——如果帖子被重發了多少次但仍然沒有獲得所需的關注度,我們應該在最後一次嘗試後保留它還是刪除它?就像所有有趣的問題一樣,答案是“取決於”。如果是普通帖子,我們可能就此打住,讓它保持存在。但是,如果這是一個非常重要的帖子,我們真的非常希望它能獲得一些關注,我們可能會在最後刪除它。

因此,這就是我們在這裏要構建的第二個小但非常實用的功能。

最後,我們該如何處理有爭議的帖子呢?一個帖子在Reddit上可能只有2個贊成票,因為有2個贊成票;或者它有100個贊成票和98個反對票。前者意味着它沒有獲得關注,而後者意味着它獲得了大量的關注,並且投票結果被分割了。

因此——這就是我們即將添加的第三個小功能——一個新的選項,用於在確定是否需要刪除帖子時考慮贊成票與反對票的比例。

4.1. 帖子實體

首先,我們需要修改我們的 帖子實體:

@Entity
public class Post {
    ...
    private int minUpvoteRatio;
    private boolean keepIfHasComments;
    private boolean deleteAfterLastAttempt;
}

以下是3個字段:

  • minUpvoteRatio: 用户希望其帖子達到最低點贊比例 – 點贊比例代表總投票中點讚的百分比 [最大值 = 100,最小值 = 0]
  • keepIfHasComments: 即使未達到所需分數,用户也希望保留其帖子。
  • deleteAfterLastAttempt: 確定用户在最終嘗試結束時,未達到所需分數後是否刪除帖子。

4.2. 調度器

現在,讓我們將這些有趣的選項集成到調度器中:

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

在更具吸引力的部分——checkAndDelete() 的實際邏輯方面:

private void checkAndDelete(Post post) {
    if (didIntervalPass(post.getSubmissionDate(), post.getTimeInterval())) {
        if (didPostGoalFail(post)) {
            deletePost(post.getRedditID());
            post.setSubmissionResponse("Consumed Attempts without reaching score");
            post.setRedditID(null);
            postReopsitory.save(post);
        } else {
            post.setNoOfAttempts(0);
            post.setRedditID(null);
            postReopsitory.save(post);
        }
    }
}

以下是 didPostGoalFail() 的實現 – 檢查帖子是否未能達到預定義的目標/分數:

private boolean didPostGoalFail(Post post) {
    PostScores postScores = getPostScores(post);
    int score = postScores.getScore();
    int upvoteRatio = postScores.getUpvoteRatio();
    int noOfComments = postScores.getNoOfComments();
    return (((score < post.getMinScoreRequired()) || 
             (upvoteRatio < post.getMinUpvoteRatio())) && 
           !((noOfComments > 0) && post.isKeepIfHasComments()));
}

我們還需要修改從Reddit獲取Post信息的那部分邏輯,以確保收集更多數據:

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

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

我們正在使用一個簡單的值對象來表示從 Reddit API 中提取的得分:

public class PostScores {
    private int score;
    private int upvoteRatio;
    private int noOfComments;
}

最後,我們需要修改checkAndReSubmit()函數,將成功重發的帖子的redditID設置為null

private void checkAndReSubmit(Post post) {
    if (didIntervalPass(post.getSubmissionDate(), post.getTimeInterval())) {
        if (didPostGoalFail(post)) {
            deletePost(post.getRedditID());
            resetPost(post);
        } else {
            post.setNoOfAttempts(0);
            post.setRedditID(null);
            postReopsitory.save(post);
        }
    }
}

請注意:

  • checkAndDeleteAll(): 每 3 分鐘運行一次,檢查帖子是否已耗盡嘗試並可刪除
  • getPostScores(): 返回帖子的 {得分、點贊比例、評論數量}

4.3. 修改日程頁面

我們需要將新的修改添加到我們的 schedulePostForm.html 中:

<input type="number" name="minUpvoteRatio"/>
<input type="checkbox" name="keepIfHasComments" value="true"/>
<input type="checkbox" name="deleteAfterLastAttempt" value="true"/>

5. 重要日誌郵件通知

接下來,我們將實現一個快速但非常實用的配置項——在logback配置中啓用重要日誌的郵件通知(ERROR級別)。這當然非常方便,可以輕鬆地在應用程序的生命週期早期跟蹤錯誤。

首先,我們需要在我們的 pom.xml 中添加幾個必需的依賴項:

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

然後,我們將向我們的 logback.xml 添加一個 SMTPAppender

<configuration>

    <appender name="STDOUT" ...

    <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.example.com</smtpHost>
        <to>[email protected]</to>
        <from>[email protected]</from>
        <username>[email protected]</username>
        <password>password</password>
        <subject>%logger{20} - %m</subject>
        <layout class="ch.qos.logback.classic.html.HTMLLayout"/>
    </appender>

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

</configuration>

現在,部署的應用程序會在問題發生時自動發送電子郵件。

6. 緩存子版塊

事實證明,自動補全子版塊代價很高。每次用户在安排帖子時開始在子版塊中輸入內容——我們需要調用 Reddit API 以獲取這些子版塊並向用户顯示一些建議。這不太理想。

與其調用 Reddit API——我們只需緩存流行的子版塊並使用它們進行自動補全。

6.1. 檢索子版塊

首先,讓我們檢索最受歡迎的子版塊並將它們保存到純文本文件中:

public void getAllSubreddits() {
    JsonNode node;
    String srAfter = "";
    FileWriter writer = null;
    try {
        writer = new FileWriter("src/main/resources/subreddits.csv");
        for (int i = 0; i < 20; i++) {
            node = restTemplate.getForObject(
              "http://www.reddit.com/" + "subreddits/popular.json?limit=100&after=" + srAfter, 
              JsonNode.class);
            srAfter = node.get("data").get("after").asText();
            node = node.get("data").get("children");
            for (JsonNode child : node) {
                writer.append(child.get("data").get("display_name").asText() + ",");
            }
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                logger.error("Error while getting subreddits", e);
            }
        }
        writer.close();
    } catch (Exception e) {
        logger.error("Error while getting subreddits", e);
    }
}

這是否是一個成熟的實現?不。我們需要什麼更多東西?不需要。我們需要繼續前進。

6.2. 子版塊自動補全

接下來,確保在應用程序啓動時將子版塊加載到內存中——通過讓服務實現 InitializingBean 接口:

public void afterPropertiesSet() {
    loadSubreddits();
}
private void loadSubreddits() {
    subreddits = new ArrayList<String>();
    try {
        Resource resource = new ClassPathResource("subreddits.csv");
        Scanner scanner = new Scanner(resource.getFile());
        scanner.useDelimiter(",");
        while (scanner.hasNext()) {
            subreddits.add(scanner.next());
        }
        scanner.close();
    } catch (IOException e) {
        logger.error("error while loading subreddits", e);
    }
}

現在subreddit數據已經全部加載到內存中,我們可以無需訪問Reddit API就對subreddit進行搜索

public List<String> searchSubreddit(String query) {
    return subreddits.stream().
      filter(sr -> sr.startsWith(query)).
      limit(9).
      collect(Collectors.toList());
}

API 仍然提供subreddit建議功能:

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

7. 衡量指標

我們將集成一些簡單的衡量指標到應用程序中。有關如何構建這些類型的衡量指標的更多信息,我曾在這裏詳細地撰寫過。

7.1. Servlet 過濾器

以下是一個簡單的 MetricFilter

@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 req = httpRequest.getMethod() + " " + httpRequest.getRequestURI();

        chain.doFilter(request, response);

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

我們還需要在 ServletInitializer 中添加它:

@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 getFullMetric();
    Map getStatusMetric();
    
    Object[][] getGraphData();
}

7.3. 指標控制器

以下是負責通過 HTTP 暴露這些指標的基本控制器:

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

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

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

    @RequestMapping(value = "/metric-graph-data", method = RequestMethod.GET)
    @ResponseBody
    public Object[][] getMetricGraphData() {
        Object[][] result = metricService.getGraphData();
        for (int i = 1; i < result[0].length; i++) {
            result[0][i] = result[0][i].toString();
        }
        return result;
    }
}

8. 結論

本案例研究進展順利。該應用最初只是一個使用 Reddit API 進行 OAuth 教程;現在,它正在演變成一個對 Reddit 高級用户(尤其是關於排程和重新提交選項)非常有用的工具。

此外,自從我開始使用它以來,我提交到 Reddit 的內容通常都獲得了更多的關注,這當然是好事。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.