学编程到最后就是学设计果然如此,在脑子了构思了很多次前端的流程,发现上一版代码写的比较烂,代码还可以写的更好一点,还是需要重构一下。
经过考虑还是没有继续分业务层和DAO层,因为毕竟业务逻辑比较简单,直接在控制器内完成全部操作。
重构后端
这一次决定采用在控制器中处理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/token | POST | 404 | 用户名和密码匹配出现任意错误都返回404 |
/api/vote | GET | 401 | 身份验证出现问题,包括请求头不包含TOKEN,TOKEN验证失败,TOKEN验证成功但找不到用户 |
404 | 用户没有投过票 | ||
200 | 用户投过票,成功返回带响应体的响应 | ||
/api/vote | POST | 401 | 身份验证出现问题,包括请求头不包含TOKEN,TOKEN验证失败,TOKEN验证成功但找不到用户 |
406 | 用户已经投过票,还在冷却时间中 | ||
201 | 用户成功投票,不返回任何响应体 |
老大,你这个博客是用什么cms做的?
这个博客是用Wordpress搭的