Второй раунд улучшений в приложении Reddit
1. обзор
Давайте продолжим нашongoing Reddit web app case study с новым раундом улучшений с целью сделать приложение более удобным и простым в использовании.
2. Пагинация запланированных сообщений
Во-первых, давайте составим список запланированных публикацийwith pagination, чтобы упростить просмотр и понимание.
2.1. Операции с разбивкой на страницы
Мы будем использовать Spring Data для генерации нужной нам операции, эффективно используя интерфейсPageable для получения запланированных сообщений пользователя:
public interface PostRepository extends JpaRepository {
Page findByUser(User user, Pageable pageable);
}
А вот наш метод контроллераgetScheduledPosts():
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. Отображение сообщений с разбивкой на страницы
А теперь давайте реализуем простой элемент управления разбиением на страницы в интерфейсе:
Post title
А вот как мы загружаем страницы простым 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+' ');
});
});
}
По мере продвижения вперед эта таблица будет быстро заменена более зрелым плагином таблицы, но сейчас это работает просто отлично.
3. Показывать страницу входа не авторизованным пользователям
Когда пользователь обращается к корню,they should get different pages if they’re logged in or not.
Если пользователь вошел в систему, он должен увидеть свою домашнюю страницу / панель инструментов. Если они не вошли в систему - они должны увидеть страницу входа:
@RequestMapping("/")
public String homePage() {
if (SecurityContextHolder.getContext().getAuthentication() != null) {
return "home";
}
return "index";
}
4. Дополнительные параметры для повторной отправки сообщения
Удаление и повторная отправка сообщений в Reddit - это полезный, очень эффективный функционал. Тем не менее, мы хотим быть осторожными с этим, иhave full control закончились, когда мы должны и когда не должны этого делать.
Например - мы можем не захотеть удалить сообщение, если оно уже имеет комментарии. В конце дня комментарии являются обязательством, и мы хотим уважать платформу и людей, комментирующих пост.
So – that’s the first small yet highly useful feature we’ll add - новая опция, которая позволит нам удалить сообщение только в том случае, если к нему нет комментариев.
Еще один очень интересный вопрос, на который нужно ответить: если сообщение отправляется повторно столько раз, но все еще не получает должного внимания, оставляем ли мы его после последней попытки или нет? Ну, как и все интересные вопросы, ответ здесь - «это зависит». Если это нормальный пост, мы могли бы просто прекратить его работу и оставить. Однако, если это очень важный пост, и мы действительно хотим убедиться, что он набирает обороты, мы можем удалить его в конце.
Это вторая небольшая, но очень удобная функция, которую мы здесь создадим.
Наконец - как насчет спорных постов? Сообщение может иметь 2 голоса на Reddit, потому что там есть положительные голоса, или потому что у него 100 положительных и 98 отрицательных голосов. Первый вариант означает, что он не набирает обороты, а второй означает, что он набирает обороты и голосование раздельное.
So – this is the third small feature we’re going to add - новая опция, позволяющая учитывать это соотношение голосов "за" и "против" при определении, нужно ли нам удалить публикацию или нет.
4.1. СущностьPost
Во-первых, нам нужно изменить нашу сущность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 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() -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()));
}
Нам также необходимо изменить логику, которая извлекает информациюPost из 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:
5. Отправить важные журналы по электронной почте
Затем мы реализуем быстрый, но очень полезный параметр в нашей конфигурации логбэка -emailing of important logs (ERROR level). Это, конечно, очень удобно, чтобы легко отслеживать ошибки на ранних этапах жизненного цикла приложения.
Сначала мы добавим несколько необходимых зависимостей в нашpom.xml:
javax.activation
activation
1.1.1
javax.mail
mail
1.4.1
Затем мы добавимSMTPAppender к нашемуlogback.xml:
ERROR
ACCEPT
DENY
smtp.example.com
[email protected]
[email protected]
[email protected]
password
%logger{20} - %m
И это все - теперь развернутое приложение отправит по электронной почте любую проблему, как только это произойдет.
6. Кеш-субреддиты
Получается,auto-completing subreddits expensive. Каждый раз, когда пользователь начинает вводить subreddit при планировании публикации - нам нужно нажать на Reddit API, чтобы получить эти subreddits и показать пользователю некоторые предложения. Не идеально.
Вместо вызова 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);
}
}
Это зрелая реализация? No. Нужно ли что-нибудь еще? Нет, не делаем. Нам нужно двигаться дальше.
6.2. Subbreddit Autocomplete
Затем давайте удостоверимся, чтоthe subreddits are loaded into memory on application startup - если сервис реализуетInitializingBean:
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);
}
}
Теперь, когда все данные сабреддита загружены в память,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());
}
Конечно, API, предлагающий субреддитные предложения, остается прежним:
@RequestMapping(value = "/subredditAutoComplete")
@ResponseBody
public List subredditAutoComplete(@RequestParam("term") String term) {
return service.searchSubreddit(term);
}
7. метрика
Наконец, мы интегрируем в приложение несколько простых показателей. Чтобы узнать больше о построении таких показателей,I wrote about them in some detail here.
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 с помощью Reddit API; Теперь он превращается в полезный инструмент для опытных пользователей Reddit, особенно в отношении параметров планирования и повторной отправки.
Наконец, поскольку я его использую, похоже, что мои собственные публикации в Reddit в целом набирают обороты, так что всегда приятно видеть.