后端设计
先是后端部分:
暴露如下两个端点:
- 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
}