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

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

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

1. Обзор

Давайте продолжим продвигать приложение Reddit из нашего текущего тематического исследования .

2. Отправляйте уведомления по электронной почте о комментариях к сообщениям

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

Итак, проще говоря, это цель этой функции — уведомления по электронной почте о комментариях.

Мы реализуем простой планировщик, который проверяет:

  • какие пользователи должны получать уведомления по электронной почте с ответами на сообщения
  • если пользователь получил какие-либо ответы на сообщения в свой почтовый ящик Reddit

Затем он просто отправит уведомление по электронной почте с непрочитанными ответами на сообщения.

2.1. Предпочтения пользователей

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

private boolean sendEmailReplies;

Чтобы разрешить пользователям выбирать, хотят ли они получать уведомления по электронной почте с ответами на сообщения.

2.2. Планировщик уведомлений

Далее, вот наш простой планировщик:

@Component
public class NotificationRedditScheduler {

@Autowired
private INotificationRedditService notificationRedditService;

@Autowired
private PreferenceRepository preferenceRepository;

@Scheduled(fixedRate = 60 * 60 * 1000)
public void checkInboxUnread() {
List<Preference> preferences = preferenceRepository.findBySendEmailRepliesTrue();
for (Preference preference : preferences) {
notificationRedditService.checkAndNotify(preference);
}
}
}

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

2.3. Служба уведомлений

Теперь давайте обсудим нашу службу уведомлений:

@Service
public class NotificationRedditService implements INotificationRedditService {
private Logger logger = LoggerFactory.getLogger(getClass());
private static String NOTIFICATION_TEMPLATE = "You have %d unread post replies.";
private static String MESSAGE_TEMPLATE = "%s replied on your post %s : %s";

@Autowired
@Qualifier("schedulerRedditTemplate")
private OAuth2RestTemplate redditRestTemplate;

@Autowired
private ApplicationEventPublisher eventPublisher;

@Autowired
private UserRepository userRepository;

@Override
public void checkAndNotify(Preference preference) {
try {
checkAndNotifyInternal(preference);
} catch (Exception e) {
logger.error(
"Error occurred while checking and notifying = " + preference.getEmail(), e);
}
}

private void checkAndNotifyInternal(Preference preference) {
User user = userRepository.findByPreference(preference);
if ((user == null) || (user.getAccessToken() == null)) {
return;
}

DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(user.getAccessToken());
token.setRefreshToken(new DefaultOAuth2RefreshToken((user.getRefreshToken())));
token.setExpiration(user.getTokenExpiration());
redditRestTemplate.getOAuth2ClientContext().setAccessToken(token);

JsonNode node = redditRestTemplate.getForObject(
"https://oauth.reddit.com/message/selfreply?mark=false", JsonNode.class);
parseRepliesNode(preference.getEmail(), node);
}

private void parseRepliesNode(String email, JsonNode node) {
JsonNode allReplies = node.get("data").get("children");
int unread = 0;
for (JsonNode msg : allReplies) {
if (msg.get("data").get("new").asBoolean()) {
unread++;
}
}
if (unread == 0) {
return;
}

JsonNode firstMsg = allReplies.get(0).get("data");
String author = firstMsg.get("author").asText();
String postTitle = firstMsg.get("link_title").asText();
String content = firstMsg.get("body").asText();

StringBuilder builder = new StringBuilder();
builder.append(String.format(NOTIFICATION_TEMPLATE, unread));
builder.append("\n");
builder.append(String.format(MESSAGE_TEMPLATE, author, postTitle, content));
builder.append("\n");
builder.append("Check all new replies at ");
builder.append("https://www.reddit.com/message/unread/");

eventPublisher.publishEvent(new OnNewPostReplyEvent(email, builder.toString()));
}
}

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

  • Мы вызываем API Reddit и получаем все ответы, а затем проверяем их один за другим, чтобы увидеть, является ли он новым «непрочитанным».
  • Если есть непрочитанные ответы, мы запускаем событие, чтобы отправить этому пользователю уведомление по электронной почте.

2.4. Новое событие ответа

Вот наше простое событие:

public class OnNewPostReplyEvent extends ApplicationEvent {
private String email;
private String content;

public OnNewPostReplyEvent(String email, String content) {
super(email);
this.email = email;
this.content = content;
}
}

2.5. Слушатель ответа

Наконец, вот наш слушатель:

@Component
public class ReplyListener implements ApplicationListener<OnNewPostReplyEvent> {
@Autowired
private JavaMailSender mailSender;

@Autowired
private Environment env;

@Override
public void onApplicationEvent(OnNewPostReplyEvent event) {
SimpleMailMessage email = constructEmailMessage(event);
mailSender.send(email);
}

private SimpleMailMessage constructEmailMessage(OnNewPostReplyEvent event) {
String recipientAddress = event.getEmail();
String subject = "New Post Replies";
SimpleMailMessage email = new SimpleMailMessage();
email.setTo(recipientAddress);
email.setSubject(subject);
email.setText(event.getContent());
email.setFrom(env.getProperty("support.email"));
return email;
}
}

3. Контроль параллелизма сеанса

Далее давайте установим несколько более строгих правил относительно количества одновременных сеансов, разрешенных приложением. Более конкретно — давайте не будем разрешать одновременные сеансы :

@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}

Обратите внимание, что, поскольку мы используем пользовательскую реализацию UserDetails , нам нужно переопределить equals() и hashcode() , потому что стратегия управления сеансом хранит все принципалы в карте и должна иметь возможность их извлекать:

public class UserPrincipal implements UserDetails {

private User user;

@Override
public int hashCode() {
int prime = 31;
int result = 1;
result = (prime * result) + ((user == null) ? 0 : user.hashCode());
return result;
}

@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
UserPrincipal other = (UserPrincipal) obj;
if (user == null) {
if (other.user != null) {
return false;
}
} else if (!user.equals(other.user)) {
return false;
}
return true;
}
}

4. Отдельный сервлет API

Теперь приложение обслуживает как внешний интерфейс, так и API из одного и того же сервлета, что не идеально.

Давайте теперь разделим эти две основные обязанности и разделим их на два разных сервлета :

@Bean
public ServletRegistrationBean frontendServlet() {
ServletRegistrationBean registration =
new ServletRegistrationBean(new DispatcherServlet(), "/*");

Map<String, String> params = new HashMap<String, String>();
params.put("contextClass",
"org.springframework.web.context.support.AnnotationConfigWebApplicationContext");
params.put("contextConfigLocation", "org.foreach.config.frontend");
registration.setInitParameters(params);

registration.setName("FrontendServlet");
registration.setLoadOnStartup(1);
return registration;
}

@Bean
public ServletRegistrationBean apiServlet() {
ServletRegistrationBean registration =
new ServletRegistrationBean(new DispatcherServlet(), "/api/*");

Map<String, String> params = new HashMap<String, String>();
params.put("contextClass",
"org.springframework.web.context.support.AnnotationConfigWebApplicationContext");
params.put("contextConfigLocation", "org.foreach.config.api");

registration.setInitParameters(params);
registration.setName("ApiServlet");
registration.setLoadOnStartup(2);
return registration;
}

@Override
protected SpringApplicationBuilder configure(final SpringApplicationBuilder application) {
application.sources(Application.class);
return application;
}

Обратите внимание, что теперь у нас есть внешний сервлет, который обрабатывает все запросы внешнего интерфейса и загружает только контекст Spring, специфичный для внешнего интерфейса; а затем у нас есть сервлет API — загрузка совершенно другого контекста Spring для API.

Также — очень важно — эти два контекста Spring сервлета являются дочерними контекстами. Родительский контекст, созданный SpringApplicationBuilder , сканирует корневой пакет на наличие общей конфигурации, такой как постоянство, служба и т. д.

Вот наш WebFrontendConfig :

@Configuration
@EnableWebMvc
@ComponentScan({ "org.foreach.web.controller.general" })
public class WebFrontendConfig implements WebMvcConfigurer {

@Bean
public static PropertySourcesPlaceholderConfigurer
propertySourcesPlaceholderConfigurer() {
return new PropertySourcesPlaceholderConfigurer();
}

@Bean
public ViewResolver viewResolver() {
InternalResourceViewResolver viewResolver = new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/jsp/");
viewResolver.setSuffix(".jsp");
return viewResolver;
}

@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}

@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home");
...
}

@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/resources/**").addResourceLocations("/resources/");
}
}

И WebApiConfig :

@Configuration
@EnableWebMvc
@ComponentScan({ "org.foreach.web.controller.rest", "org.foreach.web.dto" })
public class WebApiConfig implements WebMvcConfigurer {

@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
}

5. Сократите URL-адрес фидов

Наконец – мы собираемся улучшить работу с RSS.

Иногда RSS-каналы укорачиваются или перенаправляются через внешнюю службу, такую как Feedburner, поэтому, когда мы загружаем URL-адрес канала в приложение, нам нужно убедиться, что мы следуем этому URL-адресу через все перенаправления, пока не достигнем основного URL-адреса. мы действительно заботимся о.

Итак, когда мы размещаем ссылку на статью на Reddit, мы фактически размещаем правильный исходный URL:

@RequestMapping(value = "/url/original")
@ResponseBody
public String getOriginalLink(@RequestParam("url") String sourceUrl) {
try {
List<String> visited = new ArrayList<String>();
String currentUrl = sourceUrl;
while (!visited.contains(currentUrl)) {
visited.add(currentUrl);
currentUrl = getOriginalUrl(currentUrl);
}
return currentUrl;
} catch (Exception ex) {
// log the exception
return sourceUrl;
}
}

private String getOriginalUrl(String oldUrl) throws IOException {
URL url = new URL(oldUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setInstanceFollowRedirects(false);
String originalUrl = connection.getHeaderField("Location");
connection.disconnect();
if (originalUrl == null) {
return oldUrl;
}
if (originalUrl.indexOf("?") != -1) {
return originalUrl.substring(0, originalUrl.indexOf("?"));
}
return originalUrl;
}

Несколько вещей, на которые следует обратить внимание в этой реализации:

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

6. Заключение

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