«1. Обзор

Давайте продолжим наш текущий пример веб-приложения Reddit с новым раундом улучшений, с целью сделать приложение более удобным и простым в использовании.

2. Разбивка запланированных сообщений на страницы

Во-первых, давайте перечислим запланированные сообщения с разбиением на страницы, чтобы все было легче смотреть и понимать.

2.1. Операции с разбивкой на страницы

Мы будем использовать Spring Data для генерации нужной нам операции, эффективно используя интерфейс Pageable для получения запланированных сообщений пользователя:

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

А вот наш метод контроллера getScheduledPosts(): ~~ ~

private static final int PAGE_SIZE = 10;

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

2.2. Отображение сообщений с разбивкой на страницы

Теперь давайте реализуем простой элемент управления разбиением на страницы во внешнем интерфейсе:

<table>
<thead><tr><th>Post title</th></thead>
</table>
<br/>
<button id="prev" onclick="loadPrev()">Previous</button> 
<button id="next" onclick="loadNext()">Next</button>

А вот как мы загружаем страницы с помощью простого 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('<tr><td>'+post.title+'</td><td></tr>');
        });
    });
}

По мере продвижения вперед, это таблица вручную будет быстро заменена более зрелым плагином таблицы, но на данный момент это работает просто отлично.

3. Показать страницу входа не вошедшим в систему пользователям

Когда пользователь получает доступ к корню, он должен получить разные страницы, независимо от того, вошли они в систему или нет.

Если пользователь вошел в систему, он должен увидеть свою домашнюю страницу/панель инструментов. Если они не вошли в систему, они должны увидеть страницу входа:

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

4. Дополнительные параметры для повторной отправки сообщения

Удаление и повторная отправка сообщений в Reddit — полезная и очень эффективная функция. Однако мы хотим быть осторожными с этим и иметь полный контроль над тем, когда мы должны и когда мы не должны этого делать.

Например, мы можем не захотеть удалять сообщение, если к нему уже есть комментарии. В конце концов, комментарии — это взаимодействие, и мы хотим уважать платформу и людей, комментирующих пост.

Итак – это первая небольшая, но очень полезная функция, которую мы добавим – новая опция, которая позволит нам удалять сообщения только в том случае, если к ним нет комментариев.

Еще один очень интересный вопрос, на который нужно ответить: если сообщение повторно отправляется сколько угодно раз, но все еще не набирает оборотов, в которых оно нуждается, — оставляем ли мы его после последней попытки или нет? Что ж, как и на все интересные вопросы, ответ здесь — «это зависит». Если это обычный пост, мы можем просто закрыть его и оставить. Однако, если это очень важный пост, и мы действительно хотим убедиться, что он наберет обороты, мы можем удалить его в конце.

Итак, это вторая небольшая, но очень удобная функция, которую мы здесь создадим.

Наконец, как насчет противоречивых постов? Пост может иметь 2 голоса на Reddit, потому что там он должен проголосовать за, или потому что он имеет 100 положительных и 98 отрицательных голосов. Первый вариант означает, что он не получает поддержки, а второй означает, что он получает большую поддержку и что голоса разделились.

Итак — это третья небольшая функция, которую мы собираемся добавить — новая опция, учитывающая соотношение голосов «за» и «против» при определении, нужно ли нам удалять сообщение или нет.

4.1. Сущность сообщения

Во-первых, нам нужно изменить сущность сообщения:

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

Вот 3 поля:

    minUpvoteRatio: минимальное количество голосов, которое пользователь хочет, чтобы его сообщение достигло — соотношение голосов. представляет, как % от общего числа голосов соответствует [макс. = 100, мин. = 0]. deleteAfterLastAttempt: определите, хочет ли пользователь удалить сообщение после завершения последней попытки, не набрав требуемого количества баллов.

4.2. Планировщик

Давайте теперь интегрируем эти интересные новые опции в планировщик:

@Scheduled(fixedRate = 3 * 60 * 1000)
public void checkAndDeleteAll() {
    List<Post> submitted = 
      postReopsitory.findByRedditIDNotNullAndNoOfAttemptsAndDeleteAfterLastAttemptTrue(0);
    
    for (Post post : submitted) {
        checkAndDelete(post);
    }
}

Что касается более интересной части – реальной логики checkAndDelete():

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

А вот и didPostGoalFail( ) реализация — проверка того, не удалось ли сообщению достичь предопределенной цели/счета:

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

Нам также нужно изменить логику, которая извлекает информацию о сообщении из Reddit, — чтобы убедиться, что мы собираем больше данных: ~ ~~

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

Мы используем простой объект значения для представления результатов, которые мы извлекаем из Reddit API:

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

«

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

«Наконец, нам нужно изменить функцию checkAndReSubmit(), чтобы установить для redditID успешно повторно отправленной записи значение null:

    Обратите внимание, что: можно удалить getPostScores(): вернуть посту {оценку, рейтинг, количество комментариев}

4.3. Изменить страницу расписания

Нам нужно добавить новые изменения в наш schedulePostForm.html:

<input type="number" name="minUpvoteRatio"/>
<input type="checkbox" name="keepIfHasComments" value="true"/>
<input type="checkbox" name="deleteAfterLastAttempt" value="true"/>

5. Отправить важные журналы по электронной почте

Далее мы реализуем быструю, но очень полезную настройку в нашей конфигурации журнала. – отправка важных журналов по электронной почте (уровень ERROR). Это, конечно, очень удобно, чтобы легко отслеживать ошибки на ранних этапах жизненного цикла приложения.

Во-первых, мы добавим несколько необходимых зависимостей в наш pom.xml:

<dependency>
    <groupId>javax.activation</groupId>
    <artifactId>activation</artifactId>
    <version>1.1.1</version>
</dependency>
<dependency>
    <groupId>javax.mail</groupId>
    <artifactId>mail</artifactId>
    <version>1.4.1</version>
</dependency>

Затем мы добавим SMTPAppender в наш logback.xml:

<configuration>

    <appender name="STDOUT" ...

    <appender name="EMAIL" class="ch.qos.logback.classic.net.SMTPAppender">
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>

        <smtpHost>smtp.example.com</smtpHost>
        <to>[email protected]</to>
        <from>[email protected]</from>
        <username>[email protected]mple.com</username>
        <password>password</password>
        <subject>%logger{20} - %m</subject>
        <layout class="ch.qos.logback.classic.html.HTMLLayout"/>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT" />
        <appender-ref ref="EMAIL" />
    </root>

</configuration>

И это все — «Теперь развернутое приложение будет отправлять по электронной почте любую проблему по мере ее возникновения.

6. Кэшировать сабреддиты

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


Вместо того, чтобы вызывать Reddit API, мы просто кэшируем популярные субреддиты и используем их для автозаполнения.

6.1. Получение субреддитов

Во-первых, давайте извлечем самые популярные субреддиты и сохраним их в обычный файл:

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

Является ли это зрелой реализацией? Нет. Нужно ли нам что-то еще? Нет, мы не знаем. Нам нужно двигаться дальше.

6.2. Subbreddit Autocomplete

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

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

Теперь, когда все данные subreddit загружены в память, мы можем искать по сабреддитам, не затрагивая Reddit API:

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

API, раскрывающий предложения сабреддитов, конечно, остается прежним:

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

7. Метрики

Наконец, мы интегрируем некоторые простые метрики в приложение. Чтобы узнать больше о построении таких метрик, я подробно описал их здесь.

7.1. Servlet Filter

Вот простой MetricFilter:

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

Нам также нужно добавить его в наш ServletInitializer:

@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. Metric Service

А вот и наш MetricService:

public interface IMetricService {
    void increaseCount(String request, int status);
    
    Map getFullMetric();
    Map getStatusMetric();
    
    Object[][] getGraphData();
}

7.3. Контроллер метрик

И она является основным контроллером, ответственным за предоставление этих метрик через 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. Заключение

Это тематическое исследование хорошо растет. На самом деле приложение начиналось как простое руководство по использованию OAuth с API Reddit; теперь он превращается в полезный инструмент для опытных пользователей Reddit, особенно в отношении параметров планирования и повторной отправки.

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