后端设计

先是后端部分:

暴露如下两个端点:

  1. REST端口,用于获取用户JWT,这个是公开的。路径叫做/auth
  2. 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的响应:

  1. GET请求返回403表示TOKEN认证失败。
  2. GET请求返回200表示身份认证成功,未投票用户不返回投票结果,投票用户返回投票结果;必定返回当前用户用户名和过期毫秒数。
  3. POST请求返回201表示成功投票
  4. POST请求返回403表示用户尚在冷却时间中,无法投票
  5. 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
}