1. Overview
1.概述
Let’s continue our ongoing Reddit web app case study with a new round of improvements, with the goal of making the application more user friendly and easier to use.
让我们继续我们的正在进行的Reddit网络应用程序案例研究,进行新一轮的改进,目的是使该应用程序更加友好,更易于使用。
2. Scheduled Posts Pagination
2.预定的帖子分页
First – let’s list the scheduled posts with pagination, to make the whole thing easier to look at and understand.
首先–让我们列出预定的帖子与分页,以使整个事情更容易看清楚和理解。
2.1. The Paginated Operations
2.1.分页的操作
We’ll use Spring Data to generate the operation we need, making good use of the Pageable interface to retrieve user’s scheduled posts:
我们将使用Spring Data生成我们需要的操作,很好地利用Pageable接口来检索用户的预定帖子。
public interface PostRepository extends JpaRepository<Post, Long> {
Page<Post> findByUser(User user, Pageable pageable);
}
And here is our controller method getScheduledPosts():
这里是我们的控制器方法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. Display Paginated Posts
2.2.显示分页的帖子
Now – let’s implement a simple pagination control in front end:
现在–让我们在前端实现一个简单的分页控件。
<table>
<thead><tr><th>Post title</th></thead>
</table>
<br/>
<button id="prev" onclick="loadPrev()">Previous</button>
<button id="next" onclick="loadNext()">Next</button>
And here is how we load the pages with plain jQuery:
下面是我们如何用普通的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>');
});
});
}
As we move forward, this manual table will get quickly replaced with a more mature table plugin, but for now, this works just fine.
随着我们的发展,这个手动表格将很快被一个更成熟的表格插件所取代,但现在,这个工作就很好。
3. Show the Login Page to Non Logged in Users
3.向未登录的用户显示登录页面
When a user accesses the root, they should get different pages if they’re logged in or not.
当用户访问根目录时,他们应该得到不同的页面,如果他们登录或不登录。
If the user is logged in, they should see their homepage/dashboard. If they’re not logged in – they should see the login page:
如果用户已经登录,他们应该看到他们的主页/仪表板。如果他们没有登录 – 他们应该看到登录页面。
@RequestMapping("/")
public String homePage() {
if (SecurityContextHolder.getContext().getAuthentication() != null) {
return "home";
}
return "index";
}
4. Advanced Options for Post Resubmit
4.帖子重新提交的高级选项
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.
在Reddit删除和重新提交帖子是一个有用的、非常有效的功能。然而,我们要小心对待它,并完全控制何时应该做,何时不应该做。
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.
最后–有争议的帖子怎么处理?一个帖子在reddit上可以有2票,因为它只有正面票,或者因为它有100张正面票和98张负面票。第一个选项意味着它没有得到牵引力,而第二个选项意味着它得到了很多牵引力,而且投票是分裂的。
So – this is 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
4.1.Post实体
First, we need to modify our Post entity:
首先,我们需要修改我们的Post实体。
@Entity
public class Post {
...
private int minUpvoteRatio;
private boolean keepIfHasComments;
private boolean deleteAfterLastAttempt;
}
Here are the 3 fields:
以下是3个领域。
- 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
4.2.调度器
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():
在更有趣的部分–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:
这里是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()));
}
We also need to modify the logic that retrieves the Post information from Reddit – to make sure we gather more data:
我们还需要修改从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;
}
We’re using a simple value object to represent the scores as we’re extracting them from the Reddit API:
我们正在使用一个简单的值对象来表示分数,因为我们正在从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:
最后,我们需要修改 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);
}
}
}
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
4.3.修改时间表页面
We need to add the new modifications to our schedulePostForm.html:
我们需要将新的修改添加到我们的schedulePostForm.html。
<input type="number" name="minUpvoteRatio"/>
<input type="checkbox" name="keepIfHasComments" value="true"/>
<input type="checkbox" name="deleteAfterLastAttempt" value="true"/>
5. Email Important Logs
5.用电子邮件发送重要日志
Next, we’ll implement a quick but highly useful setting in our logback configuration – emailing of important logs (ERROR level). This is of course quite handy to easily track errors early on in the lifecycle of an application.
接下来,我们将在logback配置中实现一个快速但非常有用的设置–重要日志的邮件发送(ERROR级别)。这当然很方便,可以在应用程序生命周期的早期轻松跟踪错误。
First, we’ll add a few required dependencies to our pom.xml:
首先,我们将在我们的pom.xml中添加一些必要的依赖项。
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>javax.mail</groupId>
<artifactId>mail</artifactId>
<version>1.4.1</version>
</dependency>
Then, we will add a SMTPAppender to our logback.xml:
然后,我们将添加一个SMTPAppender到我们的logback.xml。
<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>example@example.com</to>
<from>example@example.com</from>
<username>example@example.com</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>
And that’s about it – now, the deployed application will email any problem as it happens.
就是这样–现在,部署的应用程序将在任何问题发生时通过电子邮件发送。
6. Cache Subreddits
6.缓存subreddits
Turns out, auto-completing subreddits expensive. Every time a user starts typing in a subreddit when scheduling a post – we need to hit the Reddit API to get these subreddits and show the user some suggestions. Not ideal.
事实证明,自动完成subreddits昂贵。每次用户在安排帖子时开始输入子红点 – 我们需要点击Reddit的API来获得这些子红点,并向用户展示一些建议。这并不理想。
Instead of calling the Reddit API – we’ll simply cache the popular subreddits and use them to autocomplete.
而不是调用Reddit的API–我们将简单地缓存流行的subreddits并使用它们来自动完成。
6.1. Retrieve Subreddits
6.1.检索子红包
First, let’s retrieve the most popular subreddits and save them to a plain file:
首先,让我们检索最受欢迎的子红包,并将其保存到一个普通文件中。
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. Subbreddit Autocomplete
6.2.Subbreddit自动完成
Next, let’s make sure the subreddits are loaded into memory on application startup – by having the service implement InitializingBean:
接下来,让我们确保在应用程序启动时将subreddits加载到内存中 – 通过让服务实现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, we can search over the subreddits without hitting the Reddit API:
现在,subreddit数据已经全部加载到内存中,我们可以在subreddits上进行搜索,而不需要点击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:
当然,暴露子红点建议的API仍然保持不变。
@RequestMapping(value = "/subredditAutoComplete")
@ResponseBody
public List<String> subredditAutoComplete(@RequestParam("term") String term) {
return service.searchSubreddit(term);
}
7. Metrics
7.度量衡
Finally – we’ll integrate some simple metrics into the application. For a lot more on building out these kinds of metrics, I wrote about them in some detail here.
最后,我们将在应用程序中集成一些简单的指标。关于构建这类指标的更多信息,我在这里写了一些细节。
7.1. Servlet Filter
7.1.servlet过滤器
Here the simple MetricFilter:
这里是简单的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);
}
}
We also need to add it in our ServletInitializer:
我们还需要在我们的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. Metric Service
7.2.度量衡服务
And here is our MetricService:
这里是我们的MetricService。
public interface IMetricService {
void increaseCount(String request, int status);
Map getFullMetric();
Map getStatusMetric();
Object[][] getGraphData();
}
7.3. Metric Controller
7.3.度量衡控制器
And her’s the basic controller responsible with exposing these metrics over HTTP:
她是负责通过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. Conclusion
8.结论
This case study is growing nicely. The app actually started as a simple tutorial on doing OAuth with the Reddit API; now, it’s evolving into a useful tool for the Reddit power-user – especially around the scheduling and re-submitting options.
这个案例研究正在很好地发展。这个应用程序实际上是作为一个简单的教程开始的,关于如何使用Reddit API的OAuth;现在,它正在演变成一个对Reddit权力用户有用的工具–特别是围绕调度和重新提交的选项。
Finally, since I’ve been using it, it looks like my own submissions to Reddit are generally picking up a lot more steam, so that’s always good to see.
最后,自从我使用它以来,看起来我自己提交给Reddit的材料一般都有了很大的起色,所以看到这一点总是好的。