Third Round of Improvements to the Reddit Application – Reddit应用程序的第三轮改进

最后修改: 2015年 7月 29日

中文/混合/英文(键盘快捷键:t)

1. Overview

1.概述

In this article we’re going to keep moving our little case study app forward by implementing small but useful improvements to the already existing features.

在这篇文章中,我们将继续推进我们的小案例研究应用程序,对已有的功能进行小而有用的改进。

2. Better Tables

2.更好的表格

Let’s start by using the jQuery DataTables plugin to replace the old, basic tables the app was using before.

让我们首先使用jQuery DataTables插件来替换应用程序之前使用的旧的、基本的表格。

2.1. Post Repository and Service

2.1.邮政储存库和服务

First, we’ll add a method to count the scheduled posts of a user – leveraging the Spring Data syntax of course:

首先,我们将添加一个方法来计算一个用户的预定帖子–当然是利用Spring Data的语法。

public interface PostRepository extends JpaRepository<Post, Long> {
    ...
    Long countByUser(User user);
}

Next, let’s take a quick look at the service layer implementation – retrieving the posts of a user based on pagination parameters:

接下来,让我们快速看一下服务层的实现–根据分页参数检索用户的帖子。

@Override
public List<SimplePostDto> getPostsList(int page, int size, String sortDir, String sort) {
    PageRequest pageReq = new PageRequest(page, size, Sort.Direction.fromString(sortDir), sort);
    Page<Post> posts = postRepository.findByUser(userService.getCurrentUser(), pageReq);
    return constructDataAccordingToUserTimezone(posts.getContent());
}

We’re converting the dates based on the timezone of the user:

我们正在根据用户的时区来转换日期

private List<SimplePostDto> constructDataAccordingToUserTimezone(List<Post> posts) {
    String timeZone = userService.getCurrentUser().getPreference().getTimezone();
    return posts.stream().map(post -> new SimplePostDto(
      post, convertToUserTomeZone(post.getSubmissionDate(), timeZone)))
      .collect(Collectors.toList());
}
private String convertToUserTomeZone(Date date, String timeZone) { 
    dateFormat.setTimeZone(TimeZone.getTimeZone(timeZone)); 
    return dateFormat.format(date); 
}

2.2. The API With Pagination

2.2.带有分页功能的API

Next, we’re going to publish this operation with full pagination and sorting, via the API:

接下来,我们将通过API发布这个具有完整分页和排序的操作。

@RequestMapping(method = RequestMethod.GET)
@ResponseBody
public List<SimplePost> getScheduledPosts(
  @RequestParam(value = "page", required = false, defaultValue = "0") int page, 
  @RequestParam(value = "size", required = false, defaultValue = "10") int size,
  @RequestParam(value = "sortDir", required = false, defaultValue = "asc") String sortDir, 
  @RequestParam(value = "sort", required = false, defaultValue = "title") String sort, 
  HttpServletResponse response) {
    response.addHeader("PAGING_INFO", 
      scheduledPostService.generatePagingInfo(page, size).toString());
    return scheduledPostService.getPostsList(page, size, sortDir, sort);
}

Note how we’re using a custom header to pass the pagination info to the client. There are other, slightly more standard ways to do this – ways we might explore later.

请注意我们是如何使用一个自定义头来将分页信息传递给客户端的。还有其他更标准的方法可以做到这一点–我们以后可能会探讨这些方法。

This implementation however is simply enough – we have a simple method to generate paging information:

然而,这种实现很简单–我们有一个简单的方法来生成分页信息。

public PagingInfo generatePagingInfo(int page, int size) {
    long total = postRepository.countByUser(userService.getCurrentUser());
    return new PagingInfo(page, size, total);
}

And the PagingInfo itself:

还有PagingInfo本身。

public class PagingInfo {
    private long totalNoRecords;
    private int totalNoPages;
    private String uriToNextPage;
    private String uriToPrevPage;

    public PagingInfo(int page, int size, long totalNoRecords) {
        this.totalNoRecords = totalNoRecords;
        this.totalNoPages = Math.round(totalNoRecords / size);
        if (page > 0) {
            this.uriToPrevPage = "page=" + (page - 1) + "&size=" + size;
        }
        if (page < this.totalNoPages) {
            this.uriToNextPage = "page=" + (page + 1) + "&size=" + size;
        }
    }
}

2.3. Front End

2.3.前端

Finally, the simple front-end will use a custom JS method to interact with the API and handle the jQuery DataTable parameters:

最后,简单的前端将使用一个自定义的JS方法与API交互,并处理jQuery DataTable参数

<table>
<thead><tr>
<th>Post title</th><th>Submission Date</th><th>Status</th>
<th>Resubmit Attempts left</th><th>Actions</th>
</tr></thead>   
</table>

<script>
$(document).ready(function() {
    $('table').dataTable( {
        "processing": true,
        "searching":false,
        "columnDefs": [
            { "name": "title", "targets": 0 },
            { "name": "submissionDate", "targets": 1 },
            { "name": "submissionResponse", "targets": 2 },
            { "name": "noOfAttempts", "targets": 3 } ],
        "columns": [
            { "data": "title" },
            { "data": "submissionDate" },
            { "data": "submissionResponse" },
            { "data": "noOfAttempts" }],
        "serverSide": true,
        "ajax": function(data, callback, settings) {
            $.get('api/scheduledPosts', {
              size: data.length,
              page: (data.start/data.length),
              sortDir: data.order[0].dir,
              sort: data.columns[data.order[0].column].name
              }, function(res,textStatus, request) {
                var pagingInfo = request.getResponseHeader('PAGING_INFO');
                var total = pagingInfo.split(",")[0].split("=")[1];
                callback({recordsTotal: total, recordsFiltered: total,data: res});
              });
          }
    } );
} );
</script>

2.4. API Testing for Paging

2.4.寻呼的API测试

With the API now published, we can write a few simple API tests to make sure the basics of the paging mechanism work as expected:

随着API的发布,我们可以编写几个简单的API测试,以确保分页机制的基本工作符合预期。

@Test
public void givenMoreThanOnePage_whenGettingUserScheduledPosts_thenNextPageExist() 
  throws ParseException, IOException {
    createPost();
    createPost();
    createPost();

    Response response = givenAuth().
      params("page", 0, "size", 2).get(urlPrefix + "/api/scheduledPosts");

    assertEquals(200, response.statusCode());
    assertTrue(response.as(List.class).size() > 0);

    String pagingInfo = response.getHeader("PAGING_INFO");
    long totalNoRecords = Long.parseLong(pagingInfo.split(",")[0].split("=")[1]);
    String uriToNextPage = pagingInfo.split(",")[2].replace("uriToNextPage=", "").trim();

    assertTrue(totalNoRecords > 2);
    assertEquals(uriToNextPage, "page=1&size=2");
}

@Test
public void givenMoreThanOnePage_whenGettingUserScheduledPostsForSecondPage_thenCorrect() 
  throws ParseException, IOException {
    createPost();
    createPost();
    createPost();

    Response response = givenAuth().
      params("page", 1, "size", 2).get(urlPrefix + "/api/scheduledPosts");

    assertEquals(200, response.statusCode());
    assertTrue(response.as(List.class).size() > 0);

    String pagingInfo = response.getHeader("PAGING_INFO");
    long totalNoRecords = Long.parseLong(pagingInfo.split(",")[0].split("=")[1]);
    String uriToPrevPage = pagingInfo.split(",")[3].replace("uriToPrevPage=", "").trim();

    assertTrue(totalNoRecords > 2);
    assertEquals(uriToPrevPage, "page=0&size=2");
}

3. Email Notifications

3.电子邮件通知

Next, we’re going to build out a basic email notification flow – where a user receives emails when their scheduled posts are being sent:

接下来,我们要建立一个基本的电子邮件通知流程–当用户的预定帖子被发送时,用户会收到电子邮件

3.1. Email Configuration

3.1.电子邮件配置

First, let’s do the email configuration:

首先,让我们做一下电子邮件的配置。

@Bean
public JavaMailSenderImpl javaMailSenderImpl() {
    JavaMailSenderImpl mailSenderImpl = new JavaMailSenderImpl();
    mailSenderImpl.setHost(env.getProperty("smtp.host"));
    mailSenderImpl.setPort(env.getProperty("smtp.port", Integer.class));
    mailSenderImpl.setProtocol(env.getProperty("smtp.protocol"));
    mailSenderImpl.setUsername(env.getProperty("smtp.username"));
    mailSenderImpl.setPassword(env.getProperty("smtp.password"));
    Properties javaMailProps = new Properties();
    javaMailProps.put("mail.smtp.auth", true);
    javaMailProps.put("mail.smtp.starttls.enable", true);
    mailSenderImpl.setJavaMailProperties(javaMailProps);
    return mailSenderImpl;
}

Along with the necessary properties to get SMTP working:

连同必要的属性一起,使SMTP工作。

smtp.host=email-smtp.us-east-1.amazonaws.com
smtp.port=465
smtp.protocol=smtps
smtp.username=example
smtp.password=
support.email=example@example.com

3.2. Fire an Event When a Post Is Published

3.2.当一个帖子被发布时触发一个事件

Let’s now make sure we fire off an event when a scheduled post gets published to Reddit successfully:

现在让我们确保当一个预定的帖子被成功发布到Reddit时,我们会触发一个事件。

private void updatePostFromResponse(JsonNode node, Post post) {
    JsonNode errorNode = node.get("json").get("errors").get(0);
    if (errorNode == null) {
        ...
        String email = post.getUser().getPreference().getEmail();
        eventPublisher.publishEvent(new OnPostSubmittedEvent(post, email));
    } 
    ...
}

3.3. Event and Listener

3.3.事件和监听器

The event implementation is pretty straightforward:

事件的实现是非常直接的。

public class OnPostSubmittedEvent extends ApplicationEvent {
    private Post post;
    private String email;

    public OnPostSubmittedEvent(Post post, String email) {
        super(post);
        this.post = post;
        this.email = email;
    }
}

And the listener:

还有听众。

@Component
public class SubmissionListner implements ApplicationListener<OnPostSubmittedEvent> {
    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private Environment env;

    @Override
    public void onApplicationEvent(OnPostSubmittedEvent event) {
        SimpleMailMessage email = constructEmailMessage(event);
        mailSender.send(email);
    }

    private SimpleMailMessage constructEmailMessage(OnPostSubmittedEvent event) {
        String recipientAddress = event.getEmail();
        String subject = "Your scheduled post submitted";
        SimpleMailMessage email = new SimpleMailMessage();
        email.setTo(recipientAddress);
        email.setSubject(subject);
        email.setText(constructMailContent(event.getPost()));
        email.setFrom(env.getProperty("support.email"));
        return email;
    }

    private String constructMailContent(Post post) {
        return "Your post " + post.getTitle() + " is submitted.\n" +
          "http://www.reddit.com/r/" + post.getSubreddit() + 
          "/comments/" + post.getRedditID();
    }
}

4. Using Post Total Votes

4.使用职位总票数

Next, we’ll do some work to simplify the resubmit options to – instead of working with the upvote ratio (which was difficult to understand) – it’s now working with the total number of votes.

下一步,我们将做一些工作来简化重新提交的选项–而不是用上升票数的比例(这很难理解)–它现在是用总票数

We can calculate total number of votes using post score and upvote ratio:

我们可以用帖子得分和上升票率来计算总票数。

  • Score = upvotes – downvotes
  • Total number of votes = upvotes + downvotes
  • Upvote ratio = upvotes/total number of votes

And so:

就这样。

Total number of votes = Math.round( score / ((2 * upvote ratio) – 1) )

总票数 = Math.round( score / ( (2 * upvote ratio) – 1) )

First, we’ll modify our scores logic to calculate and keep track of this total number of votes:

首先,我们要修改我们的分数逻辑,以计算和跟踪这个总票数。

public PostScores getPostScores(Post post) {
    ...

    float ratio = node.get("upvote_ratio").floatValue();
    postScore.setTotalVotes(Math.round(postScore.getScore() / ((2 * ratio) - 1)));
    
    ...
}

And of course we’re going to make use of it when checking if a post is considered failed or not:

当然,在检查一个帖子是否被认为是失败的时,我们也要利用它。

private boolean didPostGoalFail(Post post) {
    PostScores postScores = getPostScores(post);
    int totalVotes = postScores.getTotalVotes();
    ...
    return (((score < post.getMinScoreRequired()) || 
            (totalVotes < post.getMinTotalVotes())) && 
            !((noOfComments > 0) && post.isKeepIfHasComments()));
}

Finally, we’ll of course remove the old ratio fields from use.

最后,我们当然会删除旧的ratio字段的使用。

5. Validate Resubmit Options

5.验证重新提交的选项

Finally, we will help the user by adding some validations to the complex resubmit options:

最后,我们将通过为复杂的重新提交选项添加一些验证来帮助用户。

5.1. ScheduledPost Service

5.1.ScheduledPost服务

Here is the simple checkIfValidResubmitOptions() method:

这里是简单的checkIfValidResubmitOptions()方法。

private boolean checkIfValidResubmitOptions(Post post) {
    if (checkIfAllNonZero(
          post.getNoOfAttempts(), 
          post.getTimeInterval(), 
          post.getMinScoreRequired())) {
        return true;
    } else {
        return false;
    }
}
private boolean checkIfAllNonZero(int... args) {
    for (int tmp : args) {
       if (tmp == 0) {
           return false;
        }
    }
    return true;
}

We’ll make good use of this validation when scheduling a new post:

在安排一个新的帖子时,我们会很好地利用这个验证。

public Post schedulePost(boolean isSuperUser, Post post, boolean resubmitOptionsActivated) 
  throws ParseException {
    if (resubmitOptionsActivated && !checkIfValidResubmitOptions(post)) {
        throw new InvalidResubmitOptionsException("Invalid Resubmit Options");
    }
    ...        
}

Note that if the resubmit logic is on – the following fields need to have non-zero values:

请注意,如果重新提交逻辑是开启的–以下字段需要有非零值。

  • Number of attempts
  • Time interval
  • Minimum score required

5.2. Exception Handling

5.2.异常处理

Finally – in case of invalid input, the InvalidResubmitOptionsException is handled in our main error handling logic:

最后–在无效输入的情况下,InvalidResubmitOptionsException在我们的主要错误处理逻辑中被处理。

@ExceptionHandler({ InvalidResubmitOptionsException.class })
public ResponseEntity<Object> handleInvalidResubmitOptions
  (RuntimeException ex, WebRequest request) {
    
    logger.error("400 Status Code", ex);
    String bodyOfResponse = ex.getLocalizedMessage();
    return new ResponseEntity<Object>(
      bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST);
}

5.3. Test Resubmit Options

5.3.测试重新提交选项

Finally, let’s now test our resubmit options – we will test both activating and deactivating conditions:

最后,现在让我们测试一下我们的重新提交选项–我们将测试激活和停用条件。

public class ResubmitOptionsLiveTest extends AbstractLiveTest {
    private static final String date = "2016-01-01 00:00";

    @Test
    public void 
      givenResubmitOptionsDeactivated_whenSchedulingANewPost_thenCreated() 
      throws ParseException, IOException {
        Post post = createPost();

        Response response = withRequestBody(givenAuth(), post)
          .queryParams("resubmitOptionsActivated", false)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(201, response.statusCode());
        Post result = objectMapper.reader().forType(Post.class).readValue(response.asString());
        assertEquals(result.getUrl(), post.getUrl());
    }

    @Test
    public void 
      givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroAttempts_thenInvalid() 
      throws ParseException, IOException {
        Post post = createPost();
        post.setNoOfAttempts(0);
        post.setMinScoreRequired(5);
        post.setTimeInterval(60);

        Response response = withRequestBody(givenAuth(), post)
          .queryParams("resubmitOptionsActivated", true)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(400, response.statusCode());
        assertTrue(response.asString().contains("Invalid Resubmit Options"));
    }

    @Test
    public void 
      givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroMinScore_thenInvalid() 
      throws ParseException, IOException {
        Post post = createPost();
        post.setMinScoreRequired(0);
        post.setNoOfAttempts(3);
        post.setTimeInterval(60);

        Response response = withRequestBody(givenAuth(), post)
          .queryParams"resubmitOptionsActivated", true)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(400, response.statusCode());
        assertTrue(response.asString().contains("Invalid Resubmit Options"));
    }

    @Test
    public void 
      givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroTimeInterval_thenInvalid() 
      throws ParseException, IOException {
        Post post = createPost();
        post.setTimeInterval(0);
        post.setMinScoreRequired(5);
        post.setNoOfAttempts(3);

        Response response = withRequestBody(givenAuth(), post)
          .queryParams("resubmitOptionsActivated", true)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(400, response.statusCode());
        assertTrue(response.asString().contains("Invalid Resubmit Options"));
    }

    @Test
    public void 
      givenResubmitOptionsActivated_whenSchedulingNewPostWithValidResubmitOptions_thenCreated() 
      throws ParseException, IOException {
        Post post = createPost();
        post.setMinScoreRequired(5);
        post.setNoOfAttempts(3);
        post.setTimeInterval(60);

        Response response = withRequestBody(givenAuth(), post)
          .queryParams("resubmitOptionsActivated", true)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(201, response.statusCode());
        Post result = objectMapper.reader().forType(Post.class).readValue(response.asString());
        assertEquals(result.getUrl(), post.getUrl());
    }

    private Post createPost() throws ParseException {
        Post post = new Post();
        post.setTitle(randomAlphabetic(6));
        post.setUrl("test.com");
        post.setSubreddit(randomAlphabetic(6));
        post.setSubmissionDate(dateFormat.parse(date));
        return post;
    }
}

6. Conclusion

6.结论

In this installment, we made several improvements that are moving the case study app in the right direction – ease of use.

在这一期中,我们做了几项改进,使案例研究应用程序朝着正确的方向发展–易于使用。

The whole idea of the Reddit Scheduler app is to allow the user to quickly schedule new articles to Reddit, by getting into the app, doing the work and getting out.

Reddit Scheduler应用程序的整个想法是允许用户快速安排新的文章到Reddit,通过进入应用程序,做工作和离开。

It’s getting there.

渐渐地,它就到了。