Перейти к основному содержимому

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

· 9 мин. чтения

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. Почтовая сущность _

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

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

Вот 3 поля:

  • minUpvoteRatio : минимальное количество голосов, которое пользователь хочет, чтобы его сообщение достигло — соотношение положительных голосов показывает, как % от общего числа голосов соответствует [макс. = 100, мин. = 0].
  • keepIfHasComments : определите, хочет ли пользователь сохранить свой пост, если в нем есть комментарии, несмотря на то, что он не набрал требуемого балла.
  • 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;
}

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

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

Обратите внимание, что:

  • checkAndDeleteAll() : запускается каждые 3 минуты, чтобы увидеть, исчерпали ли какие-либо сообщения свои попытки и могут ли они быть удалены.
  • 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>example@example.com</to>
<from>example@example.com</from>
<username>example@example.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, чтобы получить эти сабреддиты и показать пользователю некоторые предложения. Не идеально.

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

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. Автозаполнение суббреддита

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

Теперь, когда все данные субреддита загружены в память, мы можем выполнять поиск по субреддитам, не обращаясь к 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. Фильтр сервлетов

Вот простой 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. Метрическая служба

А вот и наш 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, как правило, набирают гораздо больше популярности, так что это всегда приятно видеть.