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 逐步演变为一个真正可用的产品。