Третий раунд улучшений в приложении Reddit

Третий раунд улучшений в приложении Reddit

1. обзор

В этой статье мы продолжим продвигатьour little case study app, внося небольшие, но полезные улучшения в уже существующие функции.

2. Лучшие таблицы

Давайте начнем с использования плагина jQuery DataTables для замены старых базовых таблиц, которые приложение использовало раньше.

2.1. Почтовый репозиторий и сервис

Во-первых, мы добавим метод кcount the scheduled posts of a user - конечно, используя синтаксис Spring Data:

public interface PostRepository extends JpaRepository {
    ...
    Long countByUser(User user);
}

Затем давайте кратко рассмотримthe service layer implementation - получение сообщений пользователя на основе параметров пагинации:

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

Мыconverting the dates based on the timezone of the user:

private List constructDataAccordingToUserTimezone(List 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. API с разбивкой на страницы

Далее мы собираемся опубликовать эту операцию с полной разбивкой на страницы и сортировкой через API:

@RequestMapping(method = RequestMethod.GET)
@ResponseBody
public List 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);
}

Обратите внимание, как мы используем настраиваемый заголовок для передачи клиенту информации о разбиении на страницы. Есть и другие, чуть более стандартныеways to do this способы, которые мы можем изучить позже.

Эта реализация, однако, достаточно проста - у нас есть простой метод для генерации информации подкачки:

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

И сам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. Внешний интерфейс

Наконец, простой интерфейс будет использовать собственный метод JS для взаимодействия с API и обработкиjQuery DataTable parameters:

Post titleSubmission DateStatus Resubmit Attempts leftActions

2.4. Тестирование API для пейджинга

Теперь, когда API опубликован, мы можем написатьa few simple API tests, чтобы убедиться, что основы механизма подкачки работают должным образом:

@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. Уведомления по электронной почте

Затем мы собираемся создать базовый поток уведомлений по электронной почте -where a user receives emails, когда отправляются их запланированные сообщения:

3.1. Конфигурация электронной почты

Сначала займемся настройкой электронной почты:

@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;
}

Наряду с необходимыми свойствами для работы SMTP:

smtp.host=email-smtp.us-east-1.amazonaws.com
smtp.port=465
smtp.protocol=smtps
smtp.username=example
smtp.password=
[email protected]

3.2. Запуск события при публикации сообщения

Давайте теперь удостоверимся, что мы запускаем событие, когда запланированный пост будет успешно опубликован в 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. Событие и слушатель

Реализация события довольно проста:

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;
    }
}

И слушатель:

@Component
public class SubmissionListner implements ApplicationListener {
    @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. Использование общего количества голосов за публикацию

Затем мы поработаем, чтобы упростить параметры повторной отправки до - вместо работы с соотношением голосов (что было трудно понять) -it’s now working with the total number of votes.

Мы можем подсчитать общее количество голосов, используя оценку по баллам:

  • Оценка = положительные отзывы - отрицательные

  • Общее количество голосов = положительных голосов + отрицательных голосов

  • Соотношение голосов = количество голосов / общее количество голосов

Так что:

Общее количество голосов = Math.round (балл / ((2 * рейтинг) - 1))

Во-первых, мы изменим нашу логику подсчета очков, чтобы рассчитывать и отслеживать это общее количество голосов:

public PostScores getPostScores(Post post) {
    ...

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

    ...
}

И, конечно же, мы воспользуемся этим, когда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()));
}

Наконец, мы, конечно, удалим старые поляratio из использования.

5. Проверить параметры повторной отправки

Наконец, мы поможем пользователю, добавив некоторые проверки в сложные параметры повторной отправки:

5.1. ScheduledPost Сервис

Вот простой метод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;
}

Мы будем использовать эту проверку при планировании новой публикации:

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

Обратите внимание, что если логика повторной отправки включена - следующие поля должны иметь ненулевые значения:

  • Количество попыток

  • Интервал времени

  • Требуется минимальный балл

5.2. Обработка исключений

Наконец, в случае неверного вводаInvalidResubmitOptionsException обрабатывается в нашей основной логике обработки ошибок:

@ExceptionHandler({ InvalidResubmitOptionsException.class })
public ResponseEntity handleInvalidResubmitOptions
  (RuntimeException ex, WebRequest request) {

    logger.error("400 Status Code", ex);
    String bodyOfResponse = ex.getLocalizedMessage();
    return new ResponseEntity(
      bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST);
}



5.3. Параметры повторной отправки теста

Наконец, давайте теперь протестируем наши варианты повторной отправки - мы проверим условия активации и деактивации:

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. Заключение

В этом выпуске мы внесли несколько улучшений, которые включаютmoving the case study app in the right direction - простоту использования.

Основная идея приложения Reddit Scheduler заключается в том, чтобы позволить пользователю быстро планировать новые статьи в Reddit, входя в приложение, выполняя работу и выходя из него.

Это приближается.