Troisième série d’améliorations de l’application Reddit

Troisième série d'améliorations à l'application Reddit

1. Vue d'ensemble

Dans cet article, nous allons continuer à faire avancerour little case study app en mettant en œuvre des améliorations mineures mais utiles aux fonctionnalités déjà existantes.

2. De meilleures tables

Commençons par utiliser le plug-in jQuery DataTables pour remplacer les anciennes tables de base que l'application utilisait auparavant.

2.1. Référentiel et service post

Tout d'abord, nous allons ajouter une méthode àcount the scheduled posts of a user - en utilisant bien sûr la syntaxe Spring Data:

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

Ensuite, jetons un coup d'œil àthe service layer implementation - récupérant les messages d'un utilisateur en fonction des paramètres de pagination:

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

Nous sommesconverting 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. L'API avec pagination

Ensuite, nous allons publier cette opération avec pagination et tri complets, via l'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);
}

Notez comment nous utilisons un en-tête personnalisé pour transmettre les informations de pagination au client. Il existe d'autresways to do this, un peu plus standard, que nous pourrions explorer plus tard.

Cette implémentation est cependant tout simplement suffisante - nous disposons d’une méthode simple pour générer des informations de pagination:

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

Et lePagingInfo lui-même:

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. L'extrémité avant

Enfin, le frontal simple utilisera une méthode JS personnalisée pour interagir avec l'API et gérer lesjQuery DataTable parameters:

Post titleSubmission DateStatus Resubmit Attempts leftActions

2.4. Test d'API pour la pagination

Avec l'API maintenant publiée, nous pouvons écrirea few simple API tests pour nous assurer que les bases du mécanisme de pagination fonctionnent comme prévu:

@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. Notifications par email

Ensuite, nous allons créer un flux de notification par e-mail de base -where a user receives emails lorsque leurs publications planifiées sont envoyées:

3.1. Configuration des e-mails

Commençons par la configuration des e-mails:

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

Avec les propriétés nécessaires pour faire fonctionner 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. Déclencher un événement lors de la publication d'un article

Assurons-nous maintenant que nous déclenchons un événement lorsqu'une publication planifiée est publiée avec succès sur 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. Événement et auditeur

La mise en œuvre de l'événement est assez simple:

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

Et l'auditeur:

@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. Utilisation du total des votes après

Ensuite, nous allons faire un certain travail pour simplifier les options de nouvelle soumission à - au lieu de travailler avec le taux de vote positif (qui était difficile à comprendre) -it’s now working with the total number of votes.

Nous pouvons calculer le nombre total de votes en utilisant le score de post et le rapport de vote par vote:

  • Score = votes positifs - votes négatifs

  • Nombre total de votes = votes positifs + votes négatifs

  • Rapport de votes positifs = votes positifs / nombre total de votes

Et donc:

Nombre total de votes = Math.round (score / ((2 * upvote ratio) - 1))

Tout d'abord, nous modifierons notre logique de scores pour calculer et suivre ce nombre total de votes:

public PostScores getPostScores(Post post) {
    ...

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

    ...
}

Et bien sûr, nous allons l'utiliser lorsquechecking 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()));
}

Enfin, nous supprimerons bien sûr les anciens champsratio.

5. Valider les options de resoumettre

Enfin, nous aiderons l'utilisateur en ajoutant des validations aux options complexes de resoumission:

5.1. ServiceScheduledPost

Voici la méthode simplecheckIfValidResubmitOptions():

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

Nous ferons bon usage de cette validation lors de la planification d'un nouveau message:

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

Notez que si la logique de nouvelle soumission est activée, les champs suivants doivent avoir des valeurs non nulles:

  • Nombre de Tentatives

  • Intervalle de temps

  • Note minimale requise

5.2. Gestion des exceptions

Enfin, en cas d'entrée invalide, lesInvalidResubmitOptionsException sont traités dans notre principale logique de gestion des erreurs:

@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. Tester les options de nouvelle soumission

Enfin, testons maintenant nos options de nouvelle soumission - nous allons tester les conditions d'activation et de désactivation:

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

Dans cet épisode, nous avons apporté plusieurs améliorations qui sontmoving the case study app in the right direction - facilité d'utilisation.

L'idée de l'application Reddit Scheduler est de permettre à l'utilisateur de planifier rapidement de nouveaux articles sur Reddit, en entrant dans l'application, en effectuant le travail et en sortant.

Il y arrive.