1. Overview
1.概述
Let’s continue moving forward the Reddit application from our ongoing case study.
让我们继续推进我们的正在进行的案例研究中的Reddit应用。
2. Send Email Notifications on Post Comments
2.对帖子的评论发送电子邮件通知
Reddit is missing email notifications – plain and simple. What I’d like to see is – whenever someone comments on one of my posts, I get a short email notification with the comment.
Reddit缺少电子邮件通知–简单明了。我希望看到的是–每当有人对我的文章发表评论时,我就会收到一个简短的电子邮件通知,并附上评论。
So – simply put – that’s the goal of this feature here – email notifications on comments.
所以–简单地说–这就是这个功能的目标–评论的电子邮件通知。
We’ll implement a simple scheduler that checks:
我们将实现一个简单的调度器来检查。
- which users should receive email notification with posts’ replies
- if the user got any post replies into their Reddit inbox
It will then simply send out an email notification with unread post replies.
然后,它将简单地发送一个带有未读帖子回复的电子邮件通知。
2.1. User Preferences
2.1.用户偏好
First, we will need to modify our Preference entity and DTO by adding:
首先,我们需要修改我们的Preference实体和DTO,添加。
private boolean sendEmailReplies;
To allow users to choose if they want to receive an email notification with posts’ replies.
允许用户选择是否要收到帖子回复的电子邮件通知。
2.2. Notification Scheduler
2.2.通知调度程序
Next, here is our simple scheduler:
接下来,这是我们的简单调度程序。
@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);
}
}
}
Notice that the scheduler runs every hour – but we can of course go with a much shorter cadence if we want to.
注意,调度器每小时运行一次–但如果我们想的话,当然可以采用更短的节奏。
2.3. The Notification Service
2.3.通知服务
Now, let’s discuss our notification service:
现在,让我们讨论一下我们的通知服务。
@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()));
}
}
Note that:
请注意,。
- We call Reddit API and get all replies then check them one by one to see if it is new “unread”.
- If there is unread replies, we fire an event to send this user an email notification.
2.4. New Reply Event
2.4.新的回复事件
Here is our simple event:
这里是我们的简单事件。
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. Reply Listener
2.5.回复听众
Finally, here is our listener:
最后,这里是我们的听众。
@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. Session Concurrency Control
3.会话并发控制
Next, let’s set up some stricter rules regarding the number of concurrent sessions the application allows. More to the point – let’s not allow concurrent sessions:
接下来,让我们对应用程序允许的并发会话的数量设置一些更严格的规则。更重要的是 – 我们不允许并发会话。
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.maximumSessions(1)
.maxSessionsPreventsLogin(true);
}
Note that – as we are using a custom UserDetails implementation – we need to override equals() and hashcode() because the session controls strategy stores all principals in a map and needs to be able to retrieve them:
请注意–由于我们使用的是自定义的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. Separate API Servlet
4.独立的API Servlet
The application is now serving both the front end as well as the API out of the same servlet – which is not ideal.
该应用程序现在从同一个servlet中同时为前端和API提供服务–这并不理想。
Let’s now split these two major responsibilities apart and pull them into two different servlets:
现在让我们把这两个主要职责分开,把它们拉到两个不同的servlet。
@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.baeldung.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.baeldung.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;
}
Note how we now have a front-end servlet that handles all front end requests and only bootstraps a Spring context specific for the front end; and then we have the API Servlet – bootstrapping an entirely different Spring context for the API.
注意我们现在有一个处理所有前端请求的前端Servlet,并且只为前端引导一个特定的Spring上下文;然后我们有API Servlet–为API引导一个完全不同的Spring上下文。
Also – very important – these two servlet Spring contexts are child contexts. The parent context – created by SpringApplicationBuilder – scans the root package for common configuration like persistence, service, … etc.
另外–非常重要–这两个Servlet Spring上下文是子上下文。父上下文–由SpringApplicationBuilder创建–扫描root包,以获得持久性、服务、…等通用配置。
Here is our WebFrontendConfig:
这里是我们的WebFrontendConfig。
@Configuration
@EnableWebMvc
@ComponentScan({ "org.baeldung.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/");
}
}
And WebApiConfig:
还有WebApiConfig。
@Configuration
@EnableWebMvc
@ComponentScan({ "org.baeldung.web.controller.rest", "org.baeldung.web.dto" })
public class WebApiConfig implements WebMvcConfigurer {
@Bean
public ModelMapper modelMapper() {
return new ModelMapper();
}
}
5. Unshorten Feeds URL
5.缩短饲料的URL
Finally – we’re going to make working with RSS better.
最后–我们要让使用RSS的工作变得更好。
Sometimes, RSS feeds are shortened or redirected through an external service such as Feedburner – so when we’re loading the URL of a feed in the application – we need to make sure we follow that URL through all the redirects until we reach the main URL we actually care about.
有时,RSS提要是通过外部服务(如Feedburner)缩短或重定向的–因此,当我们在应用程序中加载提要的URL时–我们需要确保我们通过所有重定向跟踪该URL,直到我们到达我们真正关心的主URL。
So – when we post the article’s link to Reddit, we actually post the correct, original 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;
}
A few things to take note of with this implementation:
在这个实施过程中,有几件事需要注意。
- We’re handling multiple levels of redirection
- We’re also keeping track of all visited URLs to avoid redirect loops
6. Conclusion
6.结论
And that’s it – a few solid improvements to make the Reddit application better. The next step is to do some performance testing of the API and see how it behaves in a production scenario.
就是这样–一些扎实的改进,使Reddit应用程序变得更好。下一步是对API做一些性能测试,看看它在生产情况下的表现。