Reddit應用第五次改進

REST,Spring
Remote
0
05:39 PM · Dec 01 ,2025

1. 概述

讓我們繼續推進 Reddit 應用程序,來自我們的持續案例研究。

2. Send Email Notifications on Post Comments

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.

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

First, we will need to modify our Preference entity and DTO by adding:

private boolean sendEmailReplies;

To allow users to choose if they want to receive an email notification with posts’ replies.

2.2. Notification Scheduler

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

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

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

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. 會話併發控制

接下來,我們設置更嚴格的規則,限制應用程序允許的併發會話數量。更重要的是——我們不應允許併發會話:

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

請注意——由於我們使用自定義 UserDetails 實現——我們需要覆蓋 equals()hashcode() 方法,因為會話控制策略將所有 principals 存儲在 map 中,並且需要能夠檢索它們:

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 Servlet應用程序現在既為前端也為 API 服務,都由同一個 Servlet 提供——這並不是理想的做法。

現在,讓我們將這些兩個主要職責分開,並將它們放入 兩個不同的 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;
}

注意,我們現在有一個處理所有前端請求的前端 Servlet,並且只啓動了針對前端的 Spring 上下文;然後我們有一個 API Servlet,它啓動了針對 API 的完全不同的 Spring 上下文。

此外,這兩個 Servlet 的 Spring 上下文是子上下文。由 SpringApplicationBuilder 創建的父上下文掃描 包中的常見配置,如持久性、服務等。

以下是我們的 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/");
    }
}

以及 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. 縮短 RSS 鏈接

最後 – 我們將改進與 RSS 的協作。

有時,RSS 鏈接會通過外部服務(如 Feedburner)進行縮短或重定向 – 因此,當我們加載應用程序中的 RSS 鏈接時,我們需要確保通過所有重定向直到我們到達真正關心的主 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) {
        // 記錄異常
        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 性能測試,以評估其在生產環境中的表現。

user avatar
0 位用戶收藏了這個故事!
收藏

發佈 評論

Some HTML is okay.