投票项目 – 后端简单重构

作者 柚爸

学编程到最后就是学设计果然如此,在脑子了构思了很多次前端的流程,发现上一版代码写的比较烂,代码还可以写的更好一点,还是需要重构一下。

经过考虑还是没有继续分业务层和DAO层,因为毕竟业务逻辑比较简单,直接在控制器内完成全部操作。

  1. 后端重构
  2. 重构用户和TOKEN部分
  3. 重构获取投票结果部分
  4. 重构进行投票的部分
  5. API的响应码一览

重构后端

这一次决定采用在控制器中处理JWT的方法,主要有如下考虑:

  1. 现在返回的错误码种类太多,不利于前端渲染数据。减少一些响应码,简单一些即可。
  2. 原来的后端,登录请求需要发送POST的x-www-formdata数据,如果改用控制器,就可以接受JSON字符串,比较方便
  3. 登录成功之后返回给前端的内容,除了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 + '\'' +
                '}';
    }
}

最后是控制器,配置mongoTemplateredisConnectionFactory两个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是给前端投票时所用,由于每个用户每项只能投一票,所以其中的VoteItemscore属性是冗余的,暂时先留着了。

重构进行投票的部分

重构进行投票的控制器就是/api/vote的POST请求处理。逻辑其实很简单,基础逻辑:

  1. 检查TOKEN
  2. 进行写入投票记录
  3. 将用户的voted属性设置为true
  4. 将用户的上次投票时间写入数据库
  5. 如果成功则返回201响应
  6. 如果失败返回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 用户成功投票,不返回任何响应体