1. 概述

我们之前基于 Spring 开发的 Reddit 应用案例 已经初具雏形,功能逐步完善,正在向一个真正可用的应用演进。

本文将聚焦于一系列小而关键的优化点,涵盖配置校验、API 限制规避、模板引擎升级、用户体验增强以及最终部署上线。这些改进看似琐碎,实则对稳定性、可用性和可维护性至关重要。

2. 启动时的配置检查

应用启动时做一次全面的“体检”非常有必要,可以避免运行时才发现关键配置缺失,导致服务不可用。

我们通过 @PostConstruct 注解,在 Spring 完成依赖注入后自动执行检查逻辑:

@Autowired
private UserRepository repo;

@PostConstruct
public void startupCheck() {
    if (StringUtils.isBlank(accessTokenUri) || 
      StringUtils.isBlank(userAuthorizationUri) || 
      StringUtils.isBlank(clientID) || StringUtils.isBlank(clientSecret)) {
        throw new RuntimeException("Reddit API 配置不完整");
    }
    repo.findAll();
}

检查项包括:

  • 必需的 Reddit OAuth 配置项(accessTokenUri, userAuthorizationUri, clientID, clientSecret)是否齐全
  • 数据访问层是否正常,通过执行一次 findAll() 调用来验证数据库连接和 JPA 配置

⚠️ 这种“快速失败”(Fail Fast)策略能让我们在启动阶段就暴露问题,而不是等到用户请求时才报错,极大提升了问题定位效率。

3. 解决 Reddit API 的 “Too Many Requests” 问题

Reddit API 对请求频率限制非常严格,如果请求头中缺少唯一标识的 User-Agent,很容易触发 429 状态码。

3.1. 创建自定义拦截器

解决方案是为所有发往 Reddit 的请求统一添加一个有意义的 User-Agent 头。我们通过实现 ClientHttpRequestInterceptor 接口来完成:

public class UserAgentInterceptor implements ClientHttpRequestInterceptor {

    @Override
    public ClientHttpResponse intercept(
      HttpRequest request, byte[] body, 
      ClientHttpRequestExecution execution) throws IOException {

        HttpHeaders headers = request.getHeaders();
        headers.add("User-Agent", "Schedule with Reddit");
        return execution.execute(request, body);
    }
}

这个拦截器会在每次请求发出前,自动将 User-Agent 设置为 Schedule with Reddit

3.2. 将拦截器注入 RestTemplate

接下来,我们需要把这个拦截器注册到用于调用 Reddit API 的 redditRestTemplate 中:

@Bean
public OAuth2RestTemplate redditRestTemplate(OAuth2ClientContext clientContext) {
    OAuth2RestTemplate template = new OAuth2RestTemplate(reddit(), clientContext);
    List<ClientHttpRequestInterceptor> list = new ArrayList<>();
    list.add(new UserAgentInterceptor());
    template.setInterceptors(list);
    return template;
}

效果: 通过此配置,所有由 redditRestTemplate 发出的请求都会携带指定的 User-Agent,有效规避了因缺少标识导致的限流问题。

4. 为测试配置 H2 内存数据库

为了在单元测试和集成测试中快速启动,避免依赖外部数据库,我们引入 H2 内存数据库。

4.1. 添加 Maven 依赖

pom.xml 中添加 H2 驱动:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>1.4.187</version>
</dependency>

4.2. 创建测试环境配置文件

创建 persistence-test.properties 文件:

## DataSource Configuration ###
jdbc.driverClassName=org.h2.Driver
jdbc.url=jdbc:h2:mem:oauth_reddit;DB_CLOSE_DELAY=-1
jdbc.user=sa
jdbc.pass=
## Hibernate Configuration ##
hibernate.dialect=org.hibernate.dialect.H2Dialect
hibernate.hbm2ddl.auto=update
  • jdbc:h2:mem:oauth_reddit 指定使用名为 oauth_reddit 的内存数据库。
  • DB_CLOSE_DELAY=-1 确保在 JVM 退出前数据库不关闭。
  • hibernate.hbm2ddl.auto=update 让 Hibernate 自动根据实体类创建或更新表结构。

这样,测试时应用会自动使用轻量级的 H2,无需手动管理数据库实例。

5. 从 JSP 迁移到 Thymeleaf

JSP 早已不是现代 Spring Boot 应用的首选视图技术。Thymeleaf 以其自然模板、与 Spring 深度集成和易于测试等优势,成为更佳选择。

5.1. 添加 Thymeleaf 依赖

更新 pom.xml

<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf</artifactId>
    <version>2.1.4.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.thymeleaf</groupId>
    <artifactId>thymeleaf-spring4</artifactId>
    <version>2.1.4.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.thymeleaf.extras</groupId>
    <artifactId>thymeleaf-extras-springsecurity3</artifactId>
    <version>2.1.2.RELEASE</version>
</dependency>

5.2. 配置 Thymeleaf

创建配置类 ThymeleafConfig

@Configuration
public class ThymeleafConfig {
    @Bean
    public TemplateResolver templateResolver() {
        ServletContextTemplateResolver templateResolver = new ServletContextTemplateResolver();
        templateResolver.setPrefix("/WEB-INF/jsp/");
        templateResolver.setSuffix(".jsp");
        return templateResolver;
    }

    @Bean
    public SpringTemplateEngine templateEngine() {
        SpringTemplateEngine templateEngine = new SpringTemplateEngine();
        templateEngine.setTemplateResolver(templateResolver());
        templateEngine.addDialect(new SpringSecurityDialect());
        return templateEngine;
    }

    @Bean
    public ViewResolver viewResolver() {
        ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
        viewResolver.setTemplateEngine(templateEngine());
        viewResolver.setOrder(1);
        return viewResolver;
    }
}

并将 ThymeleafConfig.class 注册到你的 ServletInitializer 中:

@Override
protected WebApplicationContext createServletApplicationContext() {
    AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
    context.register(PersistenceJPAConfig.class, WebConfig.class, 
      SecurityConfig.class, ThymeleafConfig.class);
    return context;
}

5.3. 修改首页

将原来的 JSP 文件(如 home.jsp)重命名为 home.html 并调整内容:

<html>
<head>
<title>Schedule to Reddit</title>
</head>
<body>
<div class="container">
        <h1>Welcome, <small><span sec:authentication="principal.username">Bob</span></small></h1>
        <br/>
        <a href="posts" >My Scheduled Posts</a>
        <a href="post" >Post to Reddit</a>
        <a href="postSchedule" >Schedule Post to Reddit</a>
</div>
</body>
</html>

注意 sec:authentication 属性依然有效,得益于我们配置的 SpringSecurityDialect

6. 实现登出功能

一个完整的应用不能缺少登出功能。我们通过修改 Spring Security 配置来实现:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        // ... 其他配置
        .and()
        .logout()
        .deleteCookies("JSESSIONID")
        .logoutUrl("/logout")
        .logoutSuccessUrl("/");
}
  • logoutUrl("/logout"):指定登出请求的接口。
  • logoutSuccessUrl("/"):登出成功后重定向到首页。
  • deleteCookies("JSESSIONID"):清除会话 Cookie,确保用户会话失效。

用户访问 /logout 接口即可安全登出。

7. 实现子版块(Subreddit)自动补全

让用户手动输入子版块名称容易出错且体验差。我们为其添加自动补全功能。

7.1. 前端实现

在表单中加入输入框和 jQuery UI 自动补全脚本:

<input id="sr" name="sr"/>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.2/jquery-ui.min.js"></script>
<script>
$(function() {
    $( "#sr" ).autocomplete({
        source: "/subredditAutoComplete"
    });
});
</script>

7.2. 后端接口

提供一个返回匹配子版块名称列表的接口:

@RequestMapping(value = "/subredditAutoComplete")
@ResponseBody
public String subredditAutoComplete(@RequestParam("term") String term) {
    MultiValueMap<String, String> param = new LinkedMultiValueMap<String, String>();
    param.add("query", term);
    JsonNode node = redditRestTemplate.postForObject(
      "https://oauth.reddit.com//api/search_reddit_names", param, JsonNode.class);
    return node.get("names").toString();
}

该接口调用 Reddit 的 search_reddit_names API,返回与输入 term 匹配的子版块名称数组(JSON 格式),jQuery UI 会自动将其用于下拉提示。

8. 检查链接是否已提交

为了避免重复提交,我们增加一个功能,检查用户要提交的链接是否已在目标子版块中存在。

8.1. 前端实现

<input name="url" />
<input name="sr">

<a href="#" onclick="checkIfAlreadySubmitted()">Check if already submitted</a>
<span id="checkResult" style="display:none"></span>

<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.2/jquery.min.js"></script>
<script>
$(function() {
    $("input[name='url'],input[name='sr']").focus(function (){
        $("#checkResult").hide();
    });
});
function checkIfAlreadySubmitted(){
    var url = $("input[name='url']").val();
    var sr = $("input[name='sr']").val();
    if(url.length >3 && sr.length > 3){
        $.post("checkIfAlreadySubmitted",{url: url, sr: sr}, function(data){
            var result = JSON.parse(data);
            if(result.length == 0){
                $("#checkResult").show().html("Not submitted before");
            }else{
                $("#checkResult").show().html(
               'Already submitted <b><a target="_blank" href="http://www.reddit.com'
               +result[0].data.permalink+'">here</a></b>');
            }
        });
    }
    else{
        $("#checkResult").show().html("Too short url and/or subreddit");
    }
}           
</script>

点击链接后,会向后端发起 POST 请求,并根据返回结果显示“未提交过”或提供已存在的帖子链接。

8.2. 后端接口

@RequestMapping(value = "/checkIfAlreadySubmitted", method = RequestMethod.POST)
@ResponseBody
public String checkIfAlreadySubmitted(
  @RequestParam("url") String url, @RequestParam("sr") String sr) {
    JsonNode node = redditRestTemplate.getForObject(
      "https://oauth.reddit.com/r/" + sr + "/search?q=url:" + url + "&restrict_sr=on", JsonNode.class);
    return node.get("data").get("children").toString();
}

该接口利用 Reddit 的搜索 API,通过 q=url:{url} 参数查询指定子版块中是否已有相同 URL 的帖子。

9. 部署到 Heroku

最后,我们将应用部署到 Heroku 云平台,利用其免费套餐进行演示。

9.1. 修改 pom.xml

首先,添加 webapp-runner 插件,用于在 Heroku 上运行 WAR 包:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <version>2.3</version>
    <executions>
        <execution>
            <phase>package</phase>
            <goals><goal>copy</goal></goals>
            <configuration>
                <artifactItems>
                    <artifactItem>
                        <groupId>com.github.jsimone</groupId>
                        <artifactId>webapp-runner</artifactId>
                        <version>7.0.57.2</version>
                        <destFileName>webapp-runner.jar</destFileName>
                    </artifactItem>
                </artifactItems>
            </configuration>
        </execution>
    </executions>
</plugin>

同时,添加 PostgreSQL 驱动依赖,因为 Heroku 默认提供 PostgreSQL 数据库:

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>9.4-1201-jdbc41</version>
</dependency>

9.2. 创建 Procfile

在项目根目录创建 Procfile,定义启动命令:

web:    java $JAVA_OPTS -jar target/dependency/webapp-runner.jar --port $PORT target/*.war

Heroku 会读取此文件,使用 webapp-runner 启动应用,并将 $PORT 环境变量传递给应用。

9.3. 创建 Heroku 应用

在项目目录下执行:

cd /path/to/your/reddit-app
heroku login
heroku create

heroku create 会为你创建一个随机名称的应用和 Git 远程仓库。

9.4. 生产环境数据库配置

创建 persistence-prod.properties 文件,使用 Heroku 提供的数据库信息:

## DataSource Configuration ##
jdbc.driverClassName=org.postgresql.Driver
jdbc.url=jdbc:postgresql://ec2-54-225-234-147.compute-1.amazonaws.com:5432/d9snvvpd21v8vp
jdbc.user=wqjzjyqzjzjzjz
jdbc.pass=xxxxxxxxxxxxxxxxxxxx

## Hibernate Configuration ##
hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
hibernate.hbm2ddl.auto=update

⚠️ 重要: PostgreSQL 将 user 视为保留关键字。因此,我们的 User 实体必须显式指定一个非保留字的表名:

@Entity
@Table(name = "APP_USER")
public class User { 
    // ...
}

9.5. 推送代码

将代码推送到 Heroku 的远程仓库,触发自动构建和部署:

git add .
git commit -m "Prepare for Heroku deployment"
git push heroku master

部署成功后,Heroku 会提供一个 URL,你的 Reddit 应用即可通过公网访问。

10. 总结

本文通过一系列小而关键的优化,显著提升了 Reddit 应用的健壮性、用户体验和可部署性。从启动检查、API 限流规避,到前端交互增强和云端部署,这些实践都是构建生产级应用不可或缺的环节。积跬步以至千里,正是这些细节的打磨,让一个简单的 Demo 逐步演变为一个真正可用的产品。


原始标题:First Round of Improvements to the Reddit App

« 上一篇: Baeldung周报20期
» 下一篇: Baeldung周报21