Terceira Rodada de Melhorias no Aplicativo Reddit

Terceira Rodada de Melhorias no Aplicativo Reddit

1. Visão geral

Neste artigo, vamos continuar avançandoour little case study app implementando melhorias pequenas, mas úteis, nos recursos já existentes.

2. Melhores tabelas

Vamos começar usando o plugin jQuery DataTables para substituir as tabelas básicas antigas que o aplicativo estava usando antes.

2.1. Pós-repositório e serviço

Primeiro, vamos adicionar um método acount the scheduled posts of a user - aproveitando a sintaxe Spring Data, é claro:

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

A seguir, vamos dar uma olhada rápida emthe service layer implementation - recuperando as postagens de um usuário com base nos parâmetros de paginação:

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

Somosconverting 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. A API com paginação

A seguir, publicaremos essa operação com paginação e classificação completas, por meio da 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);
}

Observe como estamos usando um cabeçalho personalizado para passar as informações de paginação ao cliente. Existem outras formas um pouco mais padronizadas deways to do this que podemos explorar mais tarde.

Essa implementação, no entanto, é simplesmente suficiente - temos um método simples para gerar informações de paginação:

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

E o próprioPagingInfo:

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. a parte dianteira

Por fim, o front-end simples usará um método JS personalizado para interagir com a API e lidar comjQuery DataTable parameters:

Post titleSubmission DateStatus Resubmit Attempts leftActions

2.4. Teste de API para Paging

Com a API publicada agora, podemos escrevera few simple API tests para garantir que os fundamentos do mecanismo de paginação funcionem conforme o esperado:

@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. Notificações por Email

A seguir, vamos construir um fluxo básico de notificação por e-mail -where a user receives emails quando suas postagens programadas estão sendo enviadas:

3.1. Configuração de Email

Primeiro, vamos fazer a configuração do e-mail:

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

Juntamente com as propriedades necessárias para fazer o SMTP funcionar:

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

3.2. Acione um evento quando uma postagem for publicada

Agora vamos nos certificar de disparar um evento quando uma postagem programada for publicada no Reddit com sucesso:

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. Evento e ouvinte

A implementação do evento é bem direta:

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

E o ouvinte:

@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. Usando postar votos totais

Em seguida, faremos algum trabalho para simplificar as opções de reenvio para - em vez de trabalhar com a proporção de votos positivos (que era difícil de entender) -it’s now working with the total number of votes.

Podemos calcular o número total de votos usando a pontuação do post e a proporção de votos positivos:

  • Pontuação = votos positivos - votos negativos

  • Número total de votos = votos positivos + votos negativos

  • Proporção de votos positivos = votos positivos / número total de votos

E entao:

Número total de votos = Math.round (pontuação / ((2 * proporção de votos positivos) - 1))

Primeiro, vamos modificar nossa lógica de pontuação para calcular e controlar esse número total de votos:

public PostScores getPostScores(Post post) {
    ...

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

    ...
}

E é claro que vamos fazer uso dele quandochecking 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()));
}

Por fim, é claro que removeremos os antigos camposratio de uso.

5. Validar opções de reenvio

Por fim, ajudaremos o usuário adicionando algumas validações às opções complexas de reenvio:

5.1. ScheduledPost Serviço

Aqui está o métodocheckIfValidResubmitOptions() simples:

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

Faremos bom uso dessa validação ao agendar uma nova postagem:

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

Observe que se a lógica de reenvio estiver ativada - os seguintes campos precisam ter valores diferentes de zero:

  • Número de tentativas

  • Intervalo de tempo

  • Pontuação mínima necessária

5.2. Manipulação de exceção

Finalmente - em caso de entrada inválida, oInvalidResubmitOptionsException é tratado em nossa principal lógica de tratamento de erros:

@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. Opções de reenvio de teste

Finalmente, vamos testar nossas opções de reenvio - testaremos as condições de ativação e desativação:

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. Conclusão

Nesta edição, fizemos várias melhorias que sãomoving the case study app in the right direction - facilidade de uso.

Toda a idéia do aplicativo Reddit Scheduler é permitir que o usuário agende rapidamente novos artigos para o Reddit, entrando no aplicativo, fazendo o trabalho e saindo.

Está chegando lá.