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

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

1. Vue d'ensemble

Continuons nosongoing Reddit web app case study avec une nouvelle série d'améliorations, dans le but de rendre l'application plus conviviale et plus facile à utiliser.

2. Pagination des messages programmés

Tout d'abord, listons les posts programméswith pagination, pour rendre le tout plus facile à regarder et à comprendre.

2.1. Les opérations paginées

Nous utiliserons Spring Data pour générer l'opération dont nous avons besoin, en utilisant à bon escient l'interfacePageable pour récupérer les publications programmées de l'utilisateur:

public interface PostRepository extends JpaRepository {
    Page findByUser(User user, Pageable pageable);
}

Et voici notre méthode de contrôleurgetScheduledPosts():

private static final int PAGE_SIZE = 10;

@RequestMapping("/scheduledPosts")
@ResponseBody
public List getScheduledPosts(
  @RequestParam(value = "page", required = false) int page) {
    User user = getCurrentUser();
    Page posts =
      postReopsitory.findByUser(user, new PageRequest(page, PAGE_SIZE));

    return posts.getContent();
}

2.2. Afficher les articles paginés

Maintenant, implémentons un contrôle de pagination simple dans le front-end:

Post title

Et voici comment nous chargeons les pages avec plain 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(''+post.title+'');
        });
    });
}

À mesure que nous avancerons, cette table de manuel sera rapidement remplacée par un plugin de table plus mature, mais pour l'instant, cela fonctionne très bien.

3. Afficher la page de connexion aux utilisateurs non connectés

Lorsqu'un utilisateur accède à la racine,they should get different pages if they’re logged in or not.

Si l'utilisateur est connecté, il doit voir sa page d'accueil / son tableau de bord. S'ils ne sont pas connectés, ils devraient voir la page de connexion:

@RequestMapping("/")
public String homePage() {
    if (SecurityContextHolder.getContext().getAuthentication() != null) {
        return "home";
    }
    return "index";
}

4. Options avancées pour la soumission de nouveau

Supprimer et soumettre à nouveau des publications dans Reddit est une fonctionnalité utile et extrêmement efficace. Cependant, nous voulons être prudents avec cela ethave full control quand nous devrions et quand nous ne devrions pas le faire.

Par exemple, nous ne souhaitons peut-être pas supprimer un message s'il contient déjà des commentaires. En fin de compte, les commentaires sont un engagement et nous voulons respecter la plate-forme et les personnes commentant l'article.

So – that’s the first small yet highly useful feature we’ll add - une nouvelle option qui va nous permettre de supprimer uniquement un post s'il ne contient pas de commentaires.

Une autre question très intéressante à laquelle répondre est la suivante: si le message est soumis à nouveau autant de fois mais n’obtient toujours pas la traction dont il a besoin, le laissons-nous après la dernière tentative ou non? Eh bien, comme pour toutes les questions intéressantes, la réponse est: «ça dépend». S'il s'agit d'un message normal, nous pourrions simplement l'appeler un jour et le laisser en place. Cependant, s'il s'agit d'un article très important et que nous voulons vraiment nous assurer qu'il obtient une certaine adhérence, nous pourrions le supprimer à la fin.

C'est donc la deuxième fonctionnalité, petite mais très pratique, que nous allons créer ici.

Enfin - qu'en est-il des messages controversés? Un message peut avoir 2 votes sur reddit parce qu’il a des votes positifs, ou parce qu’il a 100 votes positifs et 98 votes négatifs. La première option signifie qu’elle n’obtient pas de traction, tandis que la seconde signifie qu’elle obtient beaucoup de force et que le vote est divisé.

So – this is the third small feature we’re going to add - une nouvelle option pour prendre en compte ce ratio vote positif / vote négatif pour déterminer si nous devons supprimer le message ou non.

4.1. L'entitéPost

Tout d'abord, nous devons modifier notre entitéPost:

@Entity
public class Post {
    ...
    private int minUpvoteRatio;
    private boolean keepIfHasComments;
    private boolean deleteAfterLastAttempt;
}

Voici les 3 champs:

  • minUpvoteRatio: Le ratio de vote positif minimum que l'utilisateur souhaite que son message atteigne - le ratio de vote positif représente le pourcentage du total des votes par rapport aux votes positifs [max = 100, min = 0]

  • keepIfHasComments: détermine si l'utilisateur souhaite conserver son message s'il contient des commentaires alors qu'il n'a pas atteint le score requis.

  • deleteAfterLastAttempt: détermine si l'utilisateur souhaite supprimer le message après la fin de la dernière tentative sans atteindre le score requis.

4.2. Le planificateur

Intégrons maintenant ces nouvelles options intéressantes dans le planificateur:

@Scheduled(fixedRate = 3 * 60 * 1000)
public void checkAndDeleteAll() {
    List submitted =
      postReopsitory.findByRedditIDNotNullAndNoOfAttemptsAndDeleteAfterLastAttemptTrue(0);

    for (Post post : submitted) {
        checkAndDelete(post);
    }
}

Sur la partie la plus intéressante - la logique réelle decheckAndDelete():

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

Et voici l'implémentation dedidPostGoalFail() -checking if the post failed to reach the predefined goal/score:

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

Nous devons également modifier la logique qui récupère les informationsPost de Reddit - pour nous assurer que nous recueillons plus de données:

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

Nous utilisons un objet de valeur simple pour représenter les scores au fur et à mesure que nous les extrayons de l'API Reddit:

public class PostScores {
    private int score;
    private int upvoteRatio;
    private int noOfComments;
}

Enfin, nous devons modifiercheckAndReSubmit() pour définir leredditID du message renvoyé avec succès surnull:

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

Notez que:

  • checkAndDeleteAll(): s'exécute toutes les 3 minutes pour voir si des messages ont consommé leurs tentatives et peuvent être supprimés

  • getPostScores(): retour du message \ {score, taux de vote positif, nombre de commentaires}

4.3. Modifier la page de planification

Nous devons ajouter les nouvelles modifications à nosschedulePostForm.html:



5. Envoyer les journaux importants par e-mail

Ensuite, nous allons implémenter un paramètre rapide mais très utile dans notre configuration de connexion -emailing of important logs (ERROR level). Ceci est bien sûr très pratique pour suivre facilement les erreurs au début du cycle de vie d’une application.

Tout d'abord, nous allons ajouter quelques dépendances obligatoires à nospom.xml:


    javax.activation
    activation
    1.1.1


    javax.mail
    mail
    1.4.1

Ensuite, nous ajouterons unSMTPAppender à noslogback.xml:



    
        
            ERROR
            ACCEPT
            DENY
        

        smtp.example.com
        [email protected]
        [email protected]
        [email protected]
        password
        %logger{20} - %m
        
    

    
        
        
    

Et c'est à peu près tout - maintenant, l'application déployée enverra par e-mail tout problème au fur et à mesure.

6. Sous-réseaux de cache

Il s'avère queauto-completing subreddits expensive. Chaque fois qu'un utilisateur commence à taper un subreddit lors de la planification d'un message, nous devons utiliser l'API Reddit pour obtenir ces subreddits et montrer à l'utilisateur quelques suggestions. Pas idéal.

Au lieu d'appeler l'API Reddit, nous allons simplement mettre en cache les subreddits populaires et les utiliser pour la saisie semi-automatique.

6.1. Récupérer les sous-réseaux

Commençons par récupérer les sous-redits les plus populaires et les enregistrer dans un fichier simple:

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

Est-ce une implémentation mature? No. Avons-nous besoin de plus? Non, non. Nous devons avancer.

6.2. Saisie semi-automatique des sous-titres

Ensuite, vérifionsthe subreddits are loaded into memory on application startup - en demandant au service d’implémenterInitializingBean:

public void afterPropertiesSet() {
    loadSubreddits();
}
private void loadSubreddits() {
    subreddits = new ArrayList();
    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);
    }
}

Maintenant que les données du sous-répertoire sont toutes chargées en mémoire,we can search over the subreddits without hitting the Reddit API:

public List searchSubreddit(String query) {
    return subreddits.stream().
      filter(sr -> sr.startsWith(query)).
      limit(9).
      collect(Collectors.toList());
}

L'API qui expose les suggestions de sous-titres reste bien sûr la même:

@RequestMapping(value = "/subredditAutoComplete")
@ResponseBody
public List subredditAutoComplete(@RequestParam("term") String term) {
    return service.searchSubreddit(term);
}

7. Métrique

Enfin, nous allons intégrer quelques métriques simples dans l'application. Pour en savoir plus sur la création de ces types de métriques,I wrote about them in some detail here.

7.1. Filtre de servlet

Voici les simplesMetricFilter:

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

Nous devons également l'ajouter dans nosServletInitializer:

@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. Service métrique

Et voici nosMetricService:

public interface IMetricService {
    void increaseCount(String request, int status);

    Map getFullMetric();
    Map getStatusMetric();

    Object[][] getGraphData();
}

7.3. Contrôleur métrique

Et elle est le contrôleur de base chargé d'exposer ces métriques via 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

Cette étude de cas se développe bien. L'application a en fait commencé comme un simple tutoriel sur la réalisation d'OAuth avec l'API Reddit; maintenant, il est en train de devenir un outil utile pour l'utilisateur expérimenté de Reddit - en particulier en ce qui concerne les options de planification et de re-soumission.

Enfin, depuis que je l'utilise, il semble que mes propres soumissions à Reddit prennent généralement beaucoup plus de vapeur, donc c'est toujours bon à voir.