1. 概述
讓我們繼續對 Reddit Web 應用程序進行案例研究,並進行新一輪改進,目標是使應用程序更易於使用和更友好。
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>帖子標題</th></thead>
</table>
<br/>
<button id="prev" onclick="loadPrev()">上一頁</button>
<button id="next" onclick="loadNext()">下一頁</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. Advanced Options for Post Resubmit
Removing and resubmitting posts in Reddit is a useful, highly effective functionality. However, we want to be careful with it and have full control over when we should and when we shouldn’t do it.
For example – we might not want to remove a post if it already has comments. At the end of the day, comments are engagement and we want to respect the platform and the people commenting on the post.
So – that’s the first small yet highly useful feature we’ll add – a new option that’s going to allow us to only remove a post if it doesn’t have comments on it.
Another very interesting question to answer is – if the post is resubmitted for however many times but still doesn’t get the traction it needs – do we leave it on after the last attempt or not? Well, like all interesting questions, the answer here is – “it depends”. If it’s a normal post, we might just call it a day and leave it up. However, if it’s a super-important post and we really really want to make sure it gets some traction, we might delete it at the end.
So this is the second small but very handy feature we’ll build here.
Finally – what about controversial posts? A post can have 2 votes on reddit because there it has to positive votes, or because it has 100 positive and 98 negative votes. The first option means it’s not getting traction, while the second means that it’s getting a lot of traction and that the voting is split.
So – that’s the third small feature we’re going to add – a new option to take this upvote to downvote ratio into account when determining if we need to remove the post or not.
4.1. The Post Entity
First, we need to modify our Post entity:
@Entity
public class Post {
...
private int minUpvoteRatio;
private boolean keepIfHasComments;
private boolean deleteAfterLastAttempt;
}
Here are the 3 fields:
- minUpvoteRatio: The minimum upvote ratio the user wants his post to reach – the upvote ratio represents how % of total votes ara upvotes [max = 100, min =0]
- keepIfHasComments: Determine whether the user want to keep his post if it has comments despite not reaching required score.
- deleteAfterLastAttempt: Determine whether the user want to delete the post after the final attempt ends without reaching required score.
4.2. The Scheduler
Let’s now integrate these interesting new options into the scheduler:
@Scheduled(fixedRate = 3 * 60 * 1000)
public void checkAndDeleteAll() {
List<Post> submitted =
postReopsitory.findByRedditIDNotNullAndNoOfAttemptsAndDeleteAfterLastAttemptTrue(0);
for (Post post : submitted) {
checkAndDelete(post);
}
}
On the the more interesting part – the actual logic of 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);
}
}
}
And here’s the didPostGoalFail() implementation – checking if the post failed to reach the predefined goal/score:
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()));
}
We also need to modify the logic that retrieves the Post information from Reddit – to make sure we gather more data:
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;
}
We’re using a simple value object to represent the scores as we’re extracting them from the Reddit API:
public class PostScores {
private int score;
private int upvoteRatio;
private int noOfComments;
}
Finally, we need to modify checkAndReSubmit() to set the successfully resubmitted post’s redditID to 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);
}
}
}
Note that:
- checkAndDeleteAll(): runs every 3 minutes through to see if any posts have consumed their attempts and can be deleted
- getPostScores(): return post’s {score, upvote ratio, number of comments}
4.3. Modify the Schedule Page
We need to add the new modifications to our 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. Cache Subreddits
Turns out, 自動補全子版塊代價高昂。 每次用户在安排帖子時開始在子版塊中輸入時,我們需要命中 Reddit API 以獲取這些子版塊並向用户顯示一些建議。 這不太理想。
而不是調用 Reddit API,我們將簡單地緩存流行的子版塊並使用它們進行自動補全。
6.1. Retrieve Subreddits
首先,讓我們檢索最流行的子版塊並將其保存到純文件:
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);
}
}
Is this a mature implementation? No. Do we need anything more? No we don’t. We need to move on.
6.2. Subreddit Autocomplete
Next, let’s make sure 子版塊在應用程序啓動時加載到內存中 – by having the service implement 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);
}
}
Now that the subreddit data is all loaded up into memory, 我們可以在不命中 Reddit API 的情況下搜索子版塊:
public List<String> searchSubreddit(String query) {
return subreddits.stream().
filter(sr -> sr.startsWith(query)).
limit(9).
collect(Collectors.toList());
}
The API exposing the subreddit suggestions of course remains the same:
@RequestMapping(value = "/subredditAutoComplete")
@ResponseBody
public List<String> subredditAutoComplete(@RequestParam("term") String term) {
return service.searchSubreddit(term);
}
最後,我們將集成一些簡單的指標到應用程序中。關於如何構建這些類型的指標的更多信息,我詳細地寫在下面。
這裏是簡單的 :
@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);
}
}
我們也需要將其添加到我們的 :
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
super.onStartup(servletContext);
servletContext.addListener(new SessionListener());
registerProxyFilter(servletContext, "oauth2ClientContextFilter");
registerProxyFilter(servletContext, "springSecurityFilterChain");
registerProxyFilter(servletContext, "metricFilter");
}
這裏是我們的 :
public interface IMetricService {
void increaseCount(String request, int status);
Map getFullMetric();
Map getStatusMetric();
Object[][] getGraphData();
}
這裏是負責通過 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 提交似乎也獲得了更多的關注,這總是令人高興。