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 的內容通常都獲得了更多的關注,這當然是好事。