后端设计
先是后端部分:
暴露如下两个端点:
- REST端口,用于获取用户JWT,这个是公开的。路径叫做
/auth
- REST端口,用于接受POST进来的投票和请求当前的投票结果,路径叫做
/vote
经过一下午奋战,现在已经编写完成。
/auth
接受x-www-formdata的POST请求,如果用户名和密码正确,则会返回一个200响应,并且将TOKEN附带在响应头的Authorization
键中返回。
/vote
接受GET和PUT请求。
如果返回403错误,说明TOKEN已经过期,需要重新登录并生成TOKEN。
携带正确的TOKEN向/vote
发起GET请求的时候,如果该用户已经投过票,就会返回包含投票列表的JSON:
{ "votes": [ { "name": "vote1", "score": 2 }, { "name": "vote2", "score": 2 }, { "name": "vote3", "score": 0 }, { "name": "vote4", "score": 0 } ], "username": "jenny2", "voted": true, "expireTimeMilli": 1571500800000 }
同时也会包含该用户的voted
字段。如果该用户没有投过票,则会返回不带有投票信息的JSON:
{ "votes": [], "username": "jenny12", "voted": false, "expireTimeMilli": 1571500800000 }
投票页面所需要的信息基本都在这里了。前端的逻辑是进入首页先检查是否有TOKEN,有TOKEN则去请求页面,没有TOKEN则跳转登录页面,登录成功后自动也进入首页。
这样显示投票页面的所有数据基本都在这里了。
写到这里的时候发现现在的设计是前端向后端也发送同样的JSON字符串,但其中的”voted”字段和用户名其实是不需要的,只需要发送投票内容和过期日即可,因为用户名在TOKEN阶段就验证掉了。
所以这里重构了一下从前端接受的代码,变成先检测过期时间是否正确,检测通过之后再写入Redis。
前端POST过来的JSON长这个样子:
{ "votes": [ { "name": "vote1", "score": 0 }, { "name": "vote2", "score": 0 } ], "currentTime": 1571500800000 }
总结一下/vote
的响应:
GET
请求返回403
表示TOKEN认证失败。GET
请求返回200
表示身份认证成功,未投票用户不返回投票结果,投票用户返回投票结果;必定返回当前用户用户名和过期毫秒数。POST
请求返回201
表示成功投票POST
请求返回403
表示用户尚在冷却时间中,无法投票POST
请求返回400
表示超过过期时间,投票无效
后端基本上就是这个思路了,感觉API设计的还可以;Redis的使用没有去进行复杂的对象映射,就是通过字符串和有序集合直接搞定了。
至于用户注册的部分不写了,其实没有什么难度,只是需要详细的设计API。
后端主要代码
JWT相关的认证基本上是沿用了上一节自己编写的内容,考虑到未来把前端直接部署在nginx的话,外加设置服务为允许跨域,应该根路径也无需放行了。就更加简化了一些。
重写的UserDetailsService的实现类,采用MongoDB查询用户:
package cc.conyli.vote.jwt; import cc.conyli.vote.dao.UserMongoRepo; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import cc.conyli.vote.domain.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; @Component public class UserService implements UserDetailsService { private Logger logger = LoggerFactory.getLogger(getClass()); //注入MongoDBRepo private UserMongoRepo userMongoRepo; @Autowired public UserService(UserMongoRepo userMongoRepo) { this.userMongoRepo = userMongoRepo; } @Override public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException { //从MongoDb中加载用户,找不到此时就是null User user = userMongoRepo.findByUsername(s); //找不到用户直接抛异常导致验证不通过即可 if (user == null) { System.out.println("Not found username: " + s); throw new UsernameNotFoundException("Not found username: " + s); } logger.info("UserDetailsService返回的对象是:" + user.toString()); return user; } }
核心的控制器的实现:
package cc.conyli.vote.controller; import cc.conyli.vote.config.VoteConfig; import cc.conyli.vote.dao.UserMongoRepo; import cc.conyli.vote.domain.PostedVote; import cc.conyli.vote.domain.User; import cc.conyli.vote.domain.Vote; import cc.conyli.vote.domain.VoteItem; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.http.HttpStatus; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.web.bind.annotation.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.time.Duration; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.Random; import static org.springframework.data.mongodb.core.query.Criteria.where; import static org.springframework.data.mongodb.core.query.Update.update; /** * /vote路径GET控制器主要作用: * 返回Redis中的投票集合。如果没有,就在Redis中默认创建一个,实际投票的内容,在前端确定,或者后端也可以。这个需要配置在属性文件中。 */ @RestController @RequestMapping("/vote") public class VoteController { //注入MongoDB,MongoRepo和mongoTemplate //其实MongoRepo和MongoTemplate用的同一个连接 private UserMongoRepo userMongoRepo; private MongoTemplate mongoTemplate; private RedisTemplate<String, String> redisTemplate; @Autowired public VoteController(UserMongoRepo userMongoRepo, MongoTemplate mongoTemplate, RedisTemplate<String, String> redisTemplate) { this.redisTemplate = redisTemplate; this.userMongoRepo = userMongoRepo; this.mongoTemplate = mongoTemplate; } @GetMapping public Vote getVotes(HttpServletRequest request) { //到这里的请求已经是成功取得TOKEN的了,但是还没有投过票,所以需要先投票 //判断当前用户是否已经投过票,没有则返回空的投票列表,前端可以去判断不显示投票结果 String username = request.getRemoteUser(); //获取数据库中该用户是否已经投过票 boolean voted = mongoTemplate.findOne(Query.query(where("username").is(username)), User.class).isVoted(); //创建一个新Vote对象,只有用户名和是否已经投过票 Vote vote = new Vote(username, voted); //如果投过票,给Vote对象设置上Redis查询的结果。如果没有投过票,就不显示Redis查询的投票结果 if (voted) { //从REDIS中逐个取出投票的内容,装到Vote对象中 VoteConfig.NAMELIST.forEach(name->{ double score = redisTemplate.opsForZSet().score(VoteConfig.REDIS_VOTE_KEY, name); int count = (int) score; VoteItem voteItem = new VoteItem(name, count); vote.addVoteItem(voteItem); }); } return vote; } @PostMapping public void postVotes(@RequestBody PostedVote postedVote, HttpServletRequest request, HttpServletResponse response) { //获取当前登录的用户名 String username = request.getRemoteUser(); //检测是否过期,大于过期时间则返回400错误,小于过期时间则按正常逻辑操作 long currentTime = postedVote.getCurrentTime(); if (currentTime <= VoteConfig.getExpireTime()) { //检查用户是否已经在冷却中,如果不在,可以投票;如果在冷却,返回403响应,表示投票失败。 if (redisTemplate.opsForValue().get(username) != null) { response.setStatus(HttpStatus.FORBIDDEN.value()); //前端在收到400响应的时候弹出东西提醒用户投票失败 //前端可以考虑解析TOKEN然后在指定的时间后让投票按钮可用 } else { //解析Vote中的数据,然后写入Redis中 postedVote.getVotes().forEach(voteItem -> { redisTemplate.opsForZSet().incrementScore(VoteConfig.REDIS_VOTE_KEY, voteItem.getName(), 1); }); //将用户名作为键写入Redis,在指定的时间后删除,这样用户可以再行投票 redisTemplate.opsForValue().set(username, "1", Duration.ofHours(VoteConfig.cooldownHour)); //将用户的voted属性设置为true mongoTemplate.updateFirst(Query.query(where("username").is(username)),update("voted",true), User.class); //返回CREATED 201响应,表示成功投票 response.setStatus(HttpStatus.CREATED.value()); } } else { //抛出400错误表示过期 response.setStatus(HttpStatus.BAD_REQUEST.value()); } } }
为了能无缝结合,继承UserDetails
的自行实现类User
:
package cc.conyli.vote.domain; import org.springframework.data.mongodb.core.index.Indexed; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; /** * User继承UserDetails,作为自定义的UserDetailsService取出来的User对象 * 在验证通过的时候,将这个User对象的信息设置到Authentication对象上 * 添加一个voted属性,用来表示该用户是否已经投过票,如果投过票,则可以显示投票结果,否则不返回投票结果,投票的时候则将其设置为true */ @Document public class User implements UserDetails, Serializable { //标识唯一字段用户名 @Indexed(unique = true) private String username; private String password; private boolean voted; private Collection<? extends GrantedAuthority> authorities; //这里简单一点,先返回一个权限列表,其中只有ROLE_USER权限 @Override public Collection<? extends GrantedAuthority> getAuthorities() { String role = "ROLE_USER"; Collection<GrantedAuthority> authorities = new ArrayList<>(); authorities.add(new SimpleGrantedAuthority(role)); return authorities; } public User(String username, String password, Collection<? extends GrantedAuthority> authorities) { this.username = username; this.password = password; this.authorities = authorities; this.voted = false; } public void setAuthorities(Collection<? extends GrantedAuthority> authorities) { this.authorities = authorities; } @Override public String getPassword() { return password; } @Override public String getUsername() { return username; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } public void setUsername(String username) { this.username = username; } public void setPassword(String password) { this.password = password; } @Override public String toString() { return "User{" + "username='" + username + '\'' + ", voted=" + voted + ", authorities=" + authorities + '}'; } public boolean isVoted() { return voted; } public void setVoted(boolean voted) { this.voted = voted; } }
剩下的主要是两个在前后端传递数据的类,Vote
用于后端向前端返回投票情况,PostedVote
用于接受前端的投票POST请求:
package cc.conyli.vote.domain; import cc.conyli.vote.config.VoteConfig; import java.util.ArrayList; import java.util.List; /** * Vote类,用于向前端返回投票情况和附加信息 * votes表示一个列表,用于保存被投票的项目和票数 * username用为用户名 * expireTimeMilli为投票截止时间的毫秒 * voted表示该用户是否投过票,所有用户默认都是未投票状态,在成功投票之后改为true */ public class Vote { private List<VoteItem> votes = new ArrayList<>(); private String username; private boolean voted; private long expireTimeMilli = VoteConfig.getExpireTime(); public Vote(String username, boolean voted) { this.username = username; this.voted = voted; } public List<VoteItem> getVotes() { return votes; } public void setVotes(List<VoteItem> votes) { this.votes = votes; } public String getUsername() { return username; } public void setUsername(String username) { this.username = username; } public boolean isVoted() { return voted; } public void setVoted(boolean voted) { this.voted = voted; } public void addVoteItem(VoteItem voteItem) { this.votes.add(voteItem); } public long getExpireTimeMilli() { return expireTimeMilli; } public void setExpireTimeMilli(long expireTimeMilli) { this.expireTimeMilli = expireTimeMilli; } }
package cc.conyli.vote.domain; import java.util.ArrayList; import java.util.List; /** * PostedVote类,用于对应从前端POST进来的JSON对象 * votes表示一个列表,用于保存被投票的项目 * currentTime 前端传递进来的实际投票时间的毫秒数 */ public class PostedVote { private List<VoteItem> votes = new ArrayList<>(); private long currentTime; public List<VoteItem> getVotes() { return votes; } public void setVotes(List<VoteItem> votes) { this.votes = votes; } public long getCurrentTime() { return currentTime; } public void setCurrentTime(long currentTime) { this.currentTime = currentTime; } }
以及一个很简单,存放投票名称和票数的VoteItem
类:
package cc.conyli.vote.domain; public class VoteItem { private String name; private int score; public VoteItem(String name, int score) { this.name = name; this.score = score; } public VoteItem() { } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getScore() { return score; } public void setScore(int score) { this.score = score; } @Override public String toString() { return "VoteItem{" + "name='" + name + '\'' + ", score=" + score + '}'; } }
用VoteItem
再封装一下,没有直接用一个Map
数据对象,也是考虑到前端拿到以后可以方便的排序,既可以保持原状按照后端投票项目的顺序,也可以自行按照投票数量排序。
最后是一个配置类,用来保存一些配置,以及初始化投票情况:
package cc.conyli.vote.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 { //用户TOKEN的有效期 public static final long EXPIRETIME = 1200000L; //用户再次投票的冷却小时 public static final int cooldownHour = 24; //投票截至时间的年月日时分数值 private static int EXPIRE_YEAR = 2019; private static int EXPIRE_MONTH = 10; private static int EXPIRE_DAYOFMONTH = 20; private static int EXPIRE_HOUR = 0; private static int EXPIRE_MINUTE = 0; //REDIS存放投票集合的键名 public static final String REDIS_VOTE_KEY = "vote"; //MongoDB的数据库名称 public static final String MONGO_DATABASE_NAME = "vote"; //MongoDB的主机地址 public static final String MONGO_HOST_ADDRESS = "localhost"; //各个投票项目的名称列表 public static List<String> NAMELIST = new ArrayList<>(); //返回到期日的毫秒数,用于传递给前端 public static long getExpireTime() { return LocalDateTime.of(EXPIRE_YEAR, EXPIRE_MONTH, EXPIRE_DAYOFMONTH, EXPIRE_HOUR, EXPIRE_MINUTE).toInstant(ZoneOffset.of("+8")).toEpochMilli(); } //注入redisTemplate private RedisTemplate<String, String> redisTemplate; //启动项目如果Redis中有数据,清空这个键对应的全部数据;然后设置所有的投票项目票数为0, @Autowired public VoteConfig(RedisTemplate<String, String> redisTemplate) { this.redisTemplate = redisTemplate; NAMELIST.add("vote1"); NAMELIST.add("vote2"); NAMELIST.add("vote3"); NAMELIST.add("vote4"); redisTemplate.opsForZSet().removeRange(VoteConfig.REDIS_VOTE_KEY, 0, -1); NAMELIST.forEach(name -> this.redisTemplate.opsForZSet().add(VoteConfig.REDIS_VOTE_KEY, name, 0)); } }
剩下都是一些小的辅助类,就不放了。
前后端分离的情况下,感觉后端一点工作也没有变少,对于业务和前后端交互的方式,都映射到对象上的等各种考虑就更加多了。还要仔细编写控制器来返回不同的状态码交给前端。
我这里还没有自定义一些异常类进行返回。不过写出来这样一个后端,感觉还算可以。要说需要改进的地方,可能还需要仔细的把配置类抽取一下。然后觉得直接通过拦截器拦截请求,不进入到控制器里,还是感觉不太好,有点Hack的感觉,估计以后要重新改进一下。
现在后端基本上OK了,开始写前端,突然发现用Vue写前端对我的挑战好像比后端还大一些,因为从来还没有用Vue正式的写过项目,战斗吧!
6月7日后记,考虑了一下还是决定可以直接将请求放行至过滤器,否则就不是真正意义上的用JSON交互,因为去获取TOKEN的请求需要用FORM格式来发送,虽然AXIOS也支持此类请求,但是这主要是给自己提出的要求。此外还更新了一下向前端返回的JSON格式,为了让前端更方便的计算,添加了一个总的投票数,现在的JSON是下边的格式,对照了一下作业要求,前端需要的信息应该齐备了:
{ "votes": [ { "name": "vote1", "score": 2 }, { "name": "vote2", "score": 0 }, { "name": "vote3", "score": 2 }, { "name": "vote4", "score": 0 } ], "username": "jenny12", "voted": true, "expireTimeMilli": 1571500800000, "totalVotes": 4 }