学编程到最后就是学设计果然如此,在脑子了构思了很多次前端的流程,发现上一版代码写的比较烂,代码还可以写的更好一点,还是需要重构一下。
经过考虑还是没有继续分业务层和DAO层,因为毕竟业务逻辑比较简单,直接在控制器内完成全部操作。
- 后端重构
- 重构用户和TOKEN部分
- 重构获取投票结果部分
- 重构进行投票的部分
- API的响应码一览
重构后端
这一次决定采用在控制器中处理JWT的方法,主要有如下考虑:
- 现在返回的错误码种类太多,不利于前端渲染数据。减少一些响应码,简单一些即可。
- 原来的后端,登录请求需要发送POST的x-www-formdata数据,如果改用控制器,就可以接受JSON字符串,比较方便
- 登录成功之后返回给前端的内容,除了TOKEN和用户名,投票与否,再加上上次投票的时间
这次发现后端设置Spring Security的时候,无需配置跨域,只需要设置http.cors()
即可,这样会将跨域请求交给Spring MVC处理,这样在控制器上就可以用@CrossOrigin
来方便灵活的控制跨域。
重构用户和TOKEN部分
首先是将用户请求放行到控制器里来,那么就取消了Spring Security的验证,针对需要放行的api单独设置好,然后禁止其他所有请求。
package cc.conyli.votebackend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.cors()
.and()
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/token").permitAll()
.antMatchers("/api/vote").permitAll()
.anyRequest().denyAll()
.and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
//如果设置了http.cors(),跨域就会交给SpringMVC来控制,没有必要写下边的CORS配置
// @Bean
// CorsConfigurationSource corsConfigurationSource()
// {
// CorsConfiguration configuration = new CorsConfiguration();
// configuration.setAllowedOrigins(Arrays.asList("*"));
// configuration.setAllowedMethods(Arrays.asList("GET","POST", "OPTIONS"));
// UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// source.registerCorsConfiguration("/**", configuration);
// return source;
// }
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
这里注释掉的部分,根据Spring官网文档上允许Spring Security支持跨域请求的设置,但是不能加上去,否则会导致Spring Security拦截跨域请求,会造成奇怪的错误。具体可以看文档这句:
If you are using Spring MVC’s CORS support, you can omit specifying the CorsConfigurationSource and Spring Security will leverage the CORS configuration provided to Spring MVC.
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
// if Spring MVC is on classpath and no CorsConfigurationSource is provided,
// Spring Security will use CORS configuration provided to Spring MVC
.cors().and()
...
}
}
之后重新编写JWTUtils类,将生成TOKEN和设置响应头的部分都放进来:
package cc.conyli.votebackend.support;
import cc.conyli.votebackend.config.VoteConfig;
import cc.conyli.votebackend.domain.User;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletResponse;
import java.security.Key;
import java.util.Base64;
import java.util.Date;
@Component
public class JWTUtils {
private static final Key KEY = Keys.hmacShaKeyFor(VoteConfig.JWT_SECRET_RAW_STRING.getBytes());
static Key getKey() {
return JWTUtils.KEY;
}
public String getKeyString() {
return Base64.getEncoder().encodeToString(KEY.getEncoded());
}
public static String getToken(User user) {
return Jwts.builder()
.setSubject(user.getUsername())
.setIssuer(VoteConfig.TOKEN_ISSUER)
.setExpiration(new Date(System.currentTimeMillis() + VoteConfig.TOKEN_EXPIRE_TIME))
.signWith(JWTUtils.getKey())
.compact();
}
public static void setJWTHeader(String token, HttpServletResponse response) {
response.setHeader("Access-Control-Expose-Headers","Authorization");
response.setHeader(VoteConfig.TOKEN_HEADER, token);
response.setStatus(HttpStatus.OK.value());
}
}
其中要特别注意红色这行,由于跨域请求的标准限制,服务器不加上这个设置的话,axios发送的请求就收不到Authorization头部信息。
关于验证JWT TOKEN的代码属于投票业务的逻辑,待之后来补充。
然后是重写了User
类,以及一个专门用于接受前端数据的UserPostedIn
类,本来想搞一下继承,后来发现复杂度不高,没有这个必要,就没搞。两个类如下:
package cc.conyli.votebackend.domain;
import com.fasterxml.jackson.annotation.JsonView;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import javax.validation.constraints.NotBlank;
@Document
public class User {
public interface userSimpleView {}
public interface userDetailView extends userSimpleView {}
@JsonView(userSimpleView.class)
@NotBlank(message = "用户名不能为空")
@Indexed(unique = true)
private String username;
@NotBlank(message = "密码不能为空")
@JsonView(userDetailView.class)
private String password;
@JsonView(userSimpleView.class)
private boolean voted = false;
@JsonView(userSimpleView.class)
private long lastVotedAt = 0;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public boolean isVoted() {
return voted;
}
public void setVoted(boolean voted) {
this.voted = voted;
}
public long getLastVotedAt() {
return lastVotedAt;
}
public void setLastVotedAt(long lastVotedAt) {
this.lastVotedAt = lastVotedAt;
}
public User() {
}
public User(String username, String password) {
this.username = username;
this.password = password;
}
public User(String username, String password, long lastVotedAt) {
this(username, password);
this.lastVotedAt = lastVotedAt;
}
@Override
public String toString() {
return "User{" +
"username='" + username + '\'' +
", voted=" + voted +
", lastVotedAt=" + lastVotedAt +
'}';
}
}
package cc.conyli.votebackend.domain;
import javax.validation.constraints.NotBlank;
public class UserPostedIn {
private String username;
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public UserPostedIn() {
}
public UserPostedIn(@NotBlank(message = "用户名不能为空") String username, @NotBlank(message = "密码不能为空") String password) {
this.username = username;
this.password = password;
}
@Override
public String toString() {
return "UserPostedIn{" +
"username='" + username + '\'' +
", password='" + password + '\'' +
'}';
}
}
最后是控制器,配置mongoTemplate
和redisConnectionFactory
两个Bean
的类就省略了。
@RestController
@RequestMapping("/api")
public class VoteController {
private Logger logger = LoggerFactory.getLogger(getClass());
private MongoTemplate mongoTemplate;
private PasswordEncoder passwordEncoder;
@Autowired
public VoteController(MongoTemplate mongoTemplate, PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
this.mongoTemplate = mongoTemplate;
}
@CrossOrigin(allowCredentials = "true")
@PostMapping(value = "/token", consumes = "application/json")
@JsonView(User.userSimpleView.class)
public User getToken(@RequestBody UserPostedIn userPostedIn, HttpServletRequest request, HttpServletResponse response) {
User user = mongoTemplate.findOne(Query.query(where("username").is(userPostedIn.getUsername())), User.class);
if (user == null) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return null;
} else {
if (passwordEncoder.matches(userPostedIn.getPassword(), user.getPassword())) {
JWTUtils.setJWTHeader(JWTUtils.getToken(user), response);
return user;
} else {
response.setStatus(HttpStatus.NOT_FOUND.value());
return null;
}
}
}
}
重构获取投票结果部分
这里主要是重新编写控制器方法,确定精简返回投票结果的对象。
首先是解析JWT的方法,写在JWTUtils类中:
public static Map<String, String> parseToken(String token) {
Jws<Claims> jws = Jwts.parser().setSigningKey(JWTUtils.getKey()).parseClaimsJws(token);
Map<String, String> tokenMap = new HashMap<>();
tokenMap.put("username", jws.getBody().getSubject());
tokenMap.put("issuer", jws.getBody().getIssuer());
tokenMap.put("expire",((Long)jws.getBody().getExpiration().getTime()).toString());
return tokenMap;
}
这个方法很简单,想了一下就用一个Map对象把解析出来的东西封装一下。除了username之外,其他两个数据暂时还没什么用处。
有了解析JWT的方法,就可以编写控制器方法了:
@CrossOrigin(allowCredentials = "true")
@GetMapping("/vote")
public Vote getVote(HttpServletRequest request, HttpServletResponse response) {
//1 尝试从Header中取得JWT TOKEN,如果没有,返回401错误
String token = request.getHeader("Authorization");
if (token == null) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return null;
}
//2 解析JWT并取得一个Map对象。如果解析出错,返回401错误和空响应
Map<String, String> tokenMap;
//解析TOKEN 不成功就返回401错误和空响应
try {
tokenMap = JWTUtils.parseToken(token);
} catch (Exception ex) {
logger.info(ex.toString());
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return null;
}
//3 检测用户的.isVoted。 false返回空的VoteItem,true返回带有数据的VoteItem
//3-1检测用户是不是存在,不存在则返回401错误+空响应
User user = mongoTemplate.findOne(Query.query(where("username").is(tokenMap.get("username"))), User.class);
if (user == null) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return null;
}
//4 如果用户已经投过票,组装Vote对象并返回
if (user.isVoted()) {
Vote vote = new Vote();
//组装Vote对象的List<VoteItem> votes属性
VoteConfig.NAMELIST.forEach(name -> {
Object score = redisTemplate.opsForZSet().score(VoteConfig.REDIS_VOTE_KEY, name);
double count = 0;
if (score != null) {
count = Math.floor((double)score);
}
VoteItem voteItem = new VoteItem(name, count);
vote.addVoteItem(voteItem);
});
//统计投票的合计数并设置在VoteItem上
double totalScore = 0;
for (VoteItem voteItem : vote.getVotes()) {
totalScore += voteItem.getScore();
}
vote.setTotalVotes(totalScore);
return vote;
} else {
//用户没有投过票,返回404响应,响应体为空
response.setStatus(HttpStatus.NOT_FOUND.value());
return null;
}
}
然后是几个domain类:
package cc.conyli.votebackend.domain;
import cc.conyli.votebackend.config.VoteConfig;
import java.util.ArrayList;
import java.util.List;
public class Vote {
private List<VoteItem> votes = new ArrayList<>();
private long expireTime = VoteConfig.getVoteEndTime();
private double totalVotes = 0;
public List<VoteItem> getVotes() {
return votes;
}
public void setVotes(List<VoteItem> votes) {
this.votes = votes;
}
public void addVoteItem(VoteItem voteItem) {
this.votes.add(voteItem);
}
public double getTotalVotes() {
return totalVotes;
}
public void setTotalVotes(double totalVotes) {
this.totalVotes = totalVotes;
}
}
package cc.conyli.votebackend.domain;
public class VoteItem {
private String name;
private double score;
public VoteItem(String name, double score) {
this.name = name;
this.score = score;
}
public VoteItem() {
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public double getScore() {
return score;
}
public void setScore(double score) {
this.score = score;
}
@Override
public String toString() {
return "VoteItem{" +
"name='" + name + '\'' +
", score=" + score +
'}';
}
}
package cc.conyli.votebackend.domain;
import cc.conyli.votebackend.config.VoteConfig;
import java.util.ArrayList;
import java.util.List;
public class VotePostedIn {
private List<VoteItem> votes = new ArrayList<>();
public List<VoteItem> getVotes() {
return votes;
}
public void setVotes(List<VoteItem> votes) {
this.votes = votes;
}
public void addVoteItem(VoteItem voteItem) {
this.votes.add(voteItem);
}
}
VotePostedIn
是给前端投票时所用,由于每个用户每项只能投一票,所以其中的VoteItem
的score
属性是冗余的,暂时先留着了。
重构进行投票的部分
重构进行投票的控制器就是/api/vote
的POST请求处理。逻辑其实很简单,基础逻辑:
- 检查TOKEN
- 进行写入投票记录
- 将用户的
voted
属性设置为true
- 将用户的上次投票时间写入数据库
- 如果成功则返回201响应
- 如果失败返回4XX系列响应
重写之后的控制器方法:
@CrossOrigin(allowCredentials = "true")
@PostMapping(value = "/vote", consumes = "application/json")
public void vote(@RequestBody VotePostedIn votePostedIn, HttpServletRequest request, HttpServletResponse response) {
long currentTime = System.currentTimeMillis();
//1 尝试从Header中取得JWT TOKEN,如果没有,返回401错误
String token = request.getHeader("Authorization");
if (token == null) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
//2 解析JWT并取得一个Map对象。如果解析出错,返回401错误和空响应
Map<String, String> tokenMap;
//解析TOKEN 不成功就返回401错误和空响应
try {
tokenMap = JWTUtils.parseToken(token);
} catch (Exception ex) {
logger.info(ex.toString());
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
//获取之后需要反复使用的用户对象和用户名字符串。如果找不到用户,返回401错误和空响应
String username = tokenMap.get("username");
User user = mongoTemplate.findOne(Query.query(where("username").is(username)), User.class);
if (user == null) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
return;
}
//3 检查用户是否在冷却中,如果在冷却中,不进行投票,直接返回406错误。如果正确则处理投票,根据名称对有序集合中的键增加1
if (redisTemplate.opsForValue().get(username) != null) {
response.setStatus(HttpStatus.NOT_ACCEPTABLE.value());
return;
}
//4 获取用户Post进来的投票信息,在有序集合中增加对应的投票名称1
votePostedIn.getVotes().forEach(voteItem -> {
redisTemplate.opsForZSet().incrementScore(VoteConfig.REDIS_VOTE_KEY, voteItem.getName(), 1);
});
//5 如果用户未投过票,将其设置为投过票。之后将用户的上次投票时间记录在数据库中,然后在redis中存入用户名称的键,设置一定的时间过期
// Redis中设置用户投票冷却时间
redisTemplate.opsForValue().set(username, "1", Duration.ofSeconds(VoteConfig.COOLDOWN_SECONDS));
// 在mongodb中设置用户投过票和记录投票时间
if (!user.isVoted()) {
mongoTemplate.updateFirst(Query.query(where("username").is(username)), Update.update("voted", true), User.class);
}
mongoTemplate.updateFirst(Query.query(where("username").is(username)), Update.update("lastVotedAt", currentTime), User.class);
response.setStatus(HttpStatus.CREATED.value());
}
最后是配置类VoteConfig
,暂时能想到的配置都塞到里边去了,包含投票项目的初始化,每一个投票项目的名称,用户TOKEN过期时间和冷却时间,还有整个投票关闭的时间都包含在内了。
package cc.conyli.votebackend.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
/**
*
*/
@Configuration
public class VoteConfig {
//投票项目列表,每一项目的列表
public static List<String> NAMELIST = new ArrayList<>();
static {
NAMELIST.add("VoteItemA");
NAMELIST.add("VoteItemB");
NAMELIST.add("VoteItemC");
NAMELIST.add("VoteItemD");
NAMELIST.add("VoteItemE");
}
//用户登录地址
public static String LOGIN_URL = "/api/token";
//获取投票信息和进行投票的地址
public static String GET_AND_POST = "/api/vote";
//填充用户密码
public static String DUMMY_PASSWORD = "[CREDENTIAL]";
//用户TOKEN的有效期毫秒数
public static final long TOKEN_EXPIRE_TIME = 1200000L;
//用户再次投票的冷却小时
public static final int COOLDOWN_SECONDS = 30;
//到期时间的年月日
private static final int VOTE_END_YEAR = 2019;
private static final int VOTE_END_MONTH = 6;
private static final int VOTE_END_DAY = 30;
private static final int VOTE_END_HOUR = 0;
private static final int VOTE_END_MINUTE = 0;
//REDIS存储投票结果有序集合的键名
public static final String REDIS_VOTE_KEY = "vote";
//MongoDB的数据库名称
public static final String MONGO_DATABASE_NAME = "vote";
//JWT生成密钥的原始字符串
public static final String JWT_SECRET_RAW_STRING = "FD*(S()FS*D()#09-g0fd043jkkjxcv980(*)*(@#vbcoioai989F*D(S(4932jk4f*&(*324jk$(*8gf98g89d0fdzkjeri789*&E*R(";
//设置TOKEN到哪个请求头的键上
public static String TOKEN_HEADER = "Authorization";
//TOKEN发布者
public static String TOKEN_ISSUER = "http://conyli.cc";
//返回到期日的毫秒数
public static long getVoteEndTime() {
return LocalDateTime.of(VOTE_END_YEAR, VOTE_END_MONTH, VOTE_END_DAY, VOTE_END_HOUR, VOTE_END_MINUTE).toInstant(ZoneOffset.of("+8")).toEpochMilli();
}
//注入redisTemplate
private RedisTemplate<String, String> redisTemplate;
//启动项目如果Redis中有数据,清空这个键对应的全部数据;然后设置所有的投票项目票数为0,
@Autowired
public VoteConfig(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
redisTemplate.opsForZSet().removeRange(VoteConfig.REDIS_VOTE_KEY, 0, -1);
NAMELIST.forEach(name -> this.redisTemplate.opsForZSet().add(VoteConfig.REDIS_VOTE_KEY, name, 0));
}
}
API的响应码一览
最后还是没有上自定义的错误对象,等功力再深厚一点吧。在控制器中用响应码区分了不同情况下的响应码,列下来,在前端开发的时候用得到:
凡是4XX的代码,都不返回响应体。
API响应码一览
API |
方法 |
响应码 |
说明 |
/api/token |
POST |
404 |
用户名和密码匹配出现任意错误都返回404 |
/api/vote |
GET |
401 |
身份验证出现问题,包括请求头不包含TOKEN,TOKEN验证失败,TOKEN验证成功但找不到用户 |
404 |
用户没有投过票 |
200 |
用户投过票,成功返回带响应体的响应 |
/api/vote |
POST |
401 |
身份验证出现问题,包括请求头不包含TOKEN,TOKEN验证失败,TOKEN验证成功但找不到用户 |
406 |
用户已经投过票,还在冷却时间中 |
201 |
用户成功投票,不返回任何响应体 |