Skip to content

Latest commit

 

History

History
627 lines (498 loc) · 25.7 KB

base.md

File metadata and controls

627 lines (498 loc) · 25.7 KB

【进阶项目笔记 上】



效果展示

注册

商品列表

商品详情


项目架构


要点和细节

Data Object/Model/View Object

通常的做法是一张用户信息user_info表,包含了用户的所有信息。而企业级一般将用户的敏感信息从用户表从分离出来,比如密码,单独作为一张表。这样,就需要两个DAO来操作同一个用户,分别是UserDAOUserPasswordDAO,这就是Data Object,从数据库直接映射出来的Object。

但是在Service层操作的时候,又需要将两个Data Object对象合在一起操作,所以就把两个Data Object封装成一个Model对象,包含了用户的所有信息

但是在Controller层,我们并不希望将UserModel的“密码”、“注册信息”等无关信息暴露给前端,这就需要View Object,将需要暴露的字段从Model中剔除掉。

public class UserDO {
    private Integer id;
    private String name;
    private Byte gender;
    private Integer age;
    private String telphone;
    private String registerMode;
    private String thirdPartyId;
}
public class UserPasswordDO {
    private Integer id;
    private String encrptPassword;
    private Integer userId;
}
public class UserModel {
    private Integer id;
    private String name;
    private Byte gender;
    private Integer age;
    private String telphone;
    private String registerMode;
    private String thirdPartyId;
    //从UserPasswordDO得到的属性
    private String encrptPassword;
}
public class UserVO {
    private Integer id;
    private String name;
    private Byte gender;
    private Integer age;
    private String telphone;
}

同样,对于商品,库存是频繁操作的字段,也应该分离出来,成为两张表。一张item表,一张stock表。

通用返回对象

一般要使用一个统一的类,来返回后端处理的对象。不然默认给前端是对象的toString()方法,不易阅读,而且,不能包含是处理成功还是失败的信息。这个类就是response.CommonReturnType

public class CommonReturnType {
    //有success和fail
    private String status;
    //若status=success,data返回前端需要的JSON数据
    //若fail,则data内使用通用的错误码格式
    private Object data;
    public static CommonReturnType create(Object result){
        return CommonReturnType.create(result,"success");
    }
    public static CommonReturnType create(Object result,String status){
        CommonReturnType type=new CommonReturnType();
        type.setStatus(status);
        type.setData(result);
        return type;
    }
}

处理错误信息

当程序内部出错后,Spring Boot会显示默认的出错页面。这些页面对于用户来说,一脸懵逼。需要将错误封装起来,通过CommonReturnType返回给用户,告诉用户哪里出错了,比如“密码输入错误”、“服务器内部错误”等等。

这些内容,封装到了error包下面的三个类里面。一个是CommonError接口,一个是枚举异常类EmBizError,一个是异常处理类BizException

CommonError接口提供三个方法,一个获得错误码的方法getErrCode(),一个获得错误信息的方法getErrMsg(),一个设置错误信息的方法setErrMsg(String errMsg)

public interface CommonError {
    int getErrCode();
    String getErrMsg();
    CommonError setErrMsg(String errMsg);
}

错误类型枚举类EmBizError含有两个属性,一个是错误码errCode一个是错误信息errMsg。通过CommonError接口的方法,获得相应错误码和错误信息。

public enum EmBizError implements CommonError {
    //10000通用错误类型
    PARAMETER_VALIDATION_ERROR(100001,"参数不合法"),
    UNKNOWN_ERROR(100002,"未知错误"),
    //2000用户信息相关错误
    USER_NOT_EXIST(20001,"用户不存在"),
    USER_LOGIN_FAIL(20002,"用户手机或密码不正确"),
    USER_NOT_LOGIN(20003,"用户还未登录"),
    //3000交易信息错误
    STOCK_NOT_ENOUGH(30001,"库存不足"),
    MQ_SEND_FAIL(30002,"库存异步消息失败"),
    RATELIMIT(30003,"活动太火爆,请稍后再试");

    private int errCode;
    private String errMsg;

    EmBizError(int errCode, String errMsg) {
        this.errCode = errCode;
        this.errMsg = errMsg;
    }

    @Override
    public int getErrCode() {
        return this.errCode;
    }
    @Override
    public String getErrMsg() {
        return this.errMsg;
    }
    @Override
    public CommonError setErrMsg(String errMsg) {
        this.errMsg=errMsg;
        return this;
    }
}

BizException继承Exception类实现CommonError接口,用于在程序出错时,抛出异常。

public class BizException extends Exception implements CommonError{
    private CommonError commonError;
    //直接接受EmBizError的传参,用于构造业务异常,多态
    public BizException(CommonError commonError){
        super();
        this.commonError=commonError;
    }
    //接受自定义errMsg构造义务异常
    public BizException(CommonError commonError,String errMsg){
        super();
        this.commonError=commonError;
        this.commonError.setErrMsg(errMsg);
    }
    //省略Override Methods
}

这样,在程序中可以抛出自定义的异常了。

throw new BizException(EmBizError.PARAMETER_VALIDATION_ERROR);

异常拦截器处理自定义异常

虽然上面抛出了自定义的BizException异常,但是SpringBoot还是和之前一样,返回500页面。这是由于,BizException被抛给了Tomcat,而Tomcat不知道如何处理BizException。所以,需要一个拦截器,拦截抛出的BizException

controller.BaseController中新建一个handlerException()方法, 添加@ExceptionHandler@ResponseStatus注解。这样,抛出的异常,就会先进入这个方法进行处理,如果是BizException,那么创建一个Map来封装错误码和错误信息,返回给前端。

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.OK)
@ResponseBody
public Object handlerException(HttpServletRequest request, Exception ex){
    Map<String,Object> responseData=new HashMap<>();
    if(ex instanceof BizException){
        BizException bizException=(BizException)ex;
        responseData.put("errCode",bizException.getErrCode());
        responseData.put("errMsg",bizException.getErrMsg());
        //打印堆栈信息,开发过程需要。发布后不需要
        ex.printStackTrace();
     }else{
        responseData.put("errCode", EmBizError.UNKNOWN_ERROR.getErrCode());
        responseData.put("errMsg",EmBizError.UNKNOWN_ERROR.getErrMsg());
        ex.printStackTrace();
    }
    return CommonReturnType.create(responseData,"fail");
}

跨域问题

由于浏览器的安全机制,JS只能访问与所在页面同一个域(相同协议、域名、端口)的内容, 但是我们这里,需要通过Ajax请求,去请求后端接口并返回数据,这时候就会受到浏览器的安全限制,产生跨域问题(如果只是通过Ajax向后端服务器发送请求而不要求返回数据,是不受跨域限制的)。

所以,前端的HTML页面,在Ajax请求体里面,需要设置contentType:"application/x-www-form-urlencoded",并添加一个额外的字段xhrFields:{withCredentials: true}

后端的Controller类需要添加@CrossOrigin(allowCredentials = "true",allowedHeaders = "*")注解。Controller的每一个API的@RequestMapping注解,也要添上consumes = {"application/x-www-form-urlencoded"}字段。

这样就解决了Ajax跨域问题。

优化校验规则

校验规则

之前的入参,都是通过类似if(StringUtils.isNotBlank(attr1)||StringUtils.isNotBlank(attr12))的方式来校验的,很繁琐,对于像年龄这样的字段,不仅不能为空,其值还应该在一个范围内,那就更麻烦了。

所以应该封装一个类,专门来校验。这里使用了org.hibernate.validator包来进行校验。

比如像UserModel类,直接可以对字段添加注解,实现校验规则。

@NotBlank(message = "用户名不能为空")
private String name;

@NotNull(message = "年龄必须填写")
@Min(value = 0,message = "年龄必须大于0")
@Max(value = 120,message = "年龄必须小于120")
private Integer age;

封装校验结果

当然,上面只是定义了校验规则,我们还需要校验结果,所以创建一个validator.ValidationResult类,来封装校验结果。

public class ValidationResult {
    //校验结果是否有错
    private boolean hasErrors=false;
    //用Map来封装校验结果和校验出错信息
    private Map<String,String> errorMsgMap=new HashMap<>();
    //实现通过格式化字符串信息获取错误结果的msg方法
    public String getErrMsg(){
        return StringUtils.join(errorMsgMap.values().toArray(),",");
    }
    public boolean isHasErrors() {
        return hasErrors;
    }
    public void setHasErrors(boolean hasErrors) {
        this.hasErrors = hasErrors;
    }
    public Map<String, String> getErrorMsgMap() {
        return errorMsgMap;
    }
    public void setErrorMsgMap(Map<String, String> errorMsgMap) {
        this.errorMsgMap = errorMsgMap;
    }
}

创建校验器/使用校验

定义了校验规则和校验结果,那如何使用校验呢?新建一个validator.ValidatorImpl类,实现InitializingBean接口,为了能在这个Bean初始化的时候,初始化其中的javax.validation.Validator对象validator

自定义一个校验方法validate,这个方法实际上就是调用validator对象的validate(Object obj)方法。该方法会根据校验规则,返回一个Set,如果传入的Object出现校验错误,就会把错误加入到Set中。

最后,我们遍历这个Set,将错误封装到ValidationResult即可。

@Component
public class ValidatorImpl implements InitializingBean {
    //javax.validation.Validator校验器
    private Validator validator;
	
    //在Bean初始化时,初始化validator对象。
    @Override
    public void afterPropertiesSet() throws Exception {
        this.validator = Validation.buildDefaultValidatorFactory().getValidator();
    }

    //校验的方法
    public ValidationResult validate(Object bean) {
        //校验的结果
        final ValidationResult result = new ValidationResult();
        //javax.validation.Validator对象的validate(Object obj)方法
        Set<ConstraintViolation<Object>> constraintViolationSet = validator.validate(bean);

        if (constraintViolationSet.size() > 0) {
            result.setHasErrors(true);
            constraintViolationSet.forEach(constraintViolation -> {
                String errMsg = constraintViolation.getMessage();
                String propertyName = constraintViolation.getPropertyPath().toString();
                result.getErrorMsgMap().put(propertyName, errMsg);
            });
        }
        return result;
    }
}

这样,当我们需要进行参数校验时,就不用大张旗鼓地手动校验了。直接调用validate方法,根据ValidationResult对象的isHasErrors()方法,就能完成入参校验了。

public void register(UserModel userModel) throws BizException {
    if(userModel==null){
        throw new BizException(EmBizError.PARAMETER_VALIDATION_ERROR);
    }
    //校验器校验
    ValidationResult result=validator.validate(userModel);
    //根据ValidationResult的isHasErrors完成校验。
    if(result.isHasErrors()){
        throw new BizException(EmBizError.PARAMETER_VALIDATION_ERROR,result.getErrMsg());
}

用户业务

短信发送业务

注册之前,输入手机号,请求后端getOtp接口。接口生成验证码后,发送到用户手机,并且用Map将验证码和手机绑定起来。企业级开发将Map放到分布式Redis里面,这里直接放到Session里面。

@RequestMapping(value = "/getOtp",method = {RequestMethod.POST},consumes = {CONTENT_TYPE_FORMED})
@ResponseBody
public CommonReturnType getOtp(@RequestParam(name="telphone")String telphone){
    Random random=new Random();
    int randomInt=random.nextInt(99999);
    randomInt+=10000;
    String optCode=String.valueOf(randomInt);
    //将验证码与用户手机号进行关联,这里使用HttpSession
    httpServletRequest.getSession().setAttribute(telphone,optCode);
    //将OPT验证码通过短信通道发送给用户,省略
    System.out.println("telphone="+telphone+"& otpCode="+optCode);
    return CommonReturnType.create(null);
}

注册业务

注册请求后端UserController.register接口,先进行短信验证,然后将注册信息封装到UserModel,调用UserServiceImpl.register(),先对注册信息进行入参校验,再将UserModel转成UserDOUserPasswordDO存入到数据库。

同时需要注意的是,UserServiceImpl.register()方法,设计到了数据库写操作,需要加上@Transactional注解,以事务的方式进行处理。

详见:controller.UserController.register()service.impl.UserServiceImpl.register()

登录业务

登录请求后端UserController.login接口,前端传过来手机号密码。判空之后,调用UserServiceImpl.validateLogin方法,这个方法先通过手机号查询user_info表,看是否存在该用户,返回UserDO对象,再根据UserDO.iduser_password表中查询密码。如果密码匹配,则返回UserModel对象给login方法,最后login方法将UserModel对象存放到Session里面,即完成了登录。

@RequestMapping(value = "/login",method = {RequestMethod.POST},consumes = {CONTENT_TYPE_FORMED})
@ResponseBody
public CommonReturnType login(@RequestParam(name = "telphone")String telphone,
                              @RequestParam(name = "password")String password) throws BizException, UnsupportedEncodingException, NoSuchAlgorithmException {
    //入参校验
    if(org.apache.commons.lang3.StringUtils.isEmpty(telphone)||
       org.apache.commons.lang3.StringUtils.isEmpty(password))
        throw new BizException(EmBizError.PARAMETER_VALIDATION_ERROR);
    //调用Service的方法,验证手机号和密码
    UserModel 
      userModel=userService.validateLogin(telphone,this.EncodeByMD5(password));
    //没有任何异常,则加入到用户登录成功的session内。这里先不用分布式的处理方式。
    this.httpServletRequest.getSession().setAttribute("IS_LOGIN",true);
    this.httpServletRequest.getSession().setAttribute("LOGIN_USER",userModel);
    return CommonReturnType.create(null);
}

商品业务

商品添加业务

请求后端ItemController.create接口,传入商品创建的各种信息,封装到ItemModel对象,调用,ItemServiceImpl.createItem方法,进行入参校验,然后将ItemModel转换成ItemDOItemStockDO对象,分别写入数据库。

获取商品业务

请求后端ItemController.get接口,传入一个Id,通过ItemServiceImpl.getItemById先查询出ItemDO对象,再根据这个对象查出ItemStockDO对象,最后两个对象封装成一个ItemModel对象返回。

查询所有商品

请求后端ItemController.list接口,跟上面类似,查询所有商品。

交易业务

下单业务

请求后端OrderController.createOrder接口,传入商品IdItemId和下单数量amount。接着在Session中获取用户登录信息,如果用户没有登录,直接抛异常。

@RequestMapping(value = "/createorder",method = {RequestMethod.POST},consumes = {CONTENT_TYPE_FORMED})
@ResponseBody
public CommonReturnType createOrder(@RequestParam(name = "itemId")Integer itemId,@RequestParam(name = "promoId",required = false)Integer promoId,@RequestParam(name = "amount")Integer amount) throws BizException {
    Boolean isLogin = (Boolean)httpServletRequest.getSession().getAttribute("IS_LOGIN");
    if(isLogin==null||!isLogin.booleanValue())
        throw new BizException(EmBizError.USER_NOT_LOGIN,"用户还未登录,不能下单");
    //获取用户的登录信息
    UserModel userModel = (UserModel)httpServletRequest.getSession().getAttribute("LOGIN_USER");
    orderService.createOrder(userModel.getId(),itemId,promoId,amount);
    return CommonReturnType.create(null);
}

在将订单存入库之前,先要调用OrderServiceImpl.createOrder方法,对商品信息、用户信息、下单数量进行校验。

@Override
@Transactional
public OrderModel createOrder(Integer userId, Integer itemId,Integer promoId, Integer amount) throws BizException {
    //1. 校验下单状态。下单商品是否存在,用户是否合法,购买数量是否正确
    ItemModel itemModel=itemService.getItemById(itemId);
    if(itemModel==null)
        throw new BizException(EmBizError.PARAMETER_VALIDATION_ERROR,"商品信息不存在");
    UserModel userModel=userService.getUserById(userId);
    if(userModel==null)
        throw new BizException(EmBizError.PARAMETER_VALIDATION_ERROR,"用户信息不存在");
    if(amount<=0||amount>99)
        throw new BizException(EmBizError.PARAMETER_VALIDATION_ERROR,"数量信息不存在");

此外,还需要校验库存是否足够

boolean result=itemService.decreaseStock(itemId,amount);
if(!result)
    throw new BizException(EmBizError.STOCK_NOT_ENOUGH);

最后将订单入库,再让销量增加。

订单ID的生成

订单ID不能是简单的自增长,而是要符合一定的规则,比如前8位,是年月日;中间6位为自增序列;最后2位为分库分表信息。

有以下几个细节需要注意,在OrderServiceImpl.generatorOrderNo方法中可以查看实现细节。

  1. 前8位比较好实现,使用LocalDateTime,处理一下格式即可。
  2. 中间6位自增序列,需要新建一个sequence_info表,里面包含namecurrent_valuestep三个字段。这个表及其对应的DO专门用来产生自增序列
  3. generatorOrderNo方法需要将序列的更新信息写入到sequence_info表,而且该方法封装在OrderServiceImpl.createOrder方法中。如果createOrder执行失败,会进行回滚,默认情况下,generatorOrderNo也会回滚。而我们希望生成ID的事务不受影响,就算订单创建失败,ID还是继续生成,所以generatorOrderNo方法使用了REQUIRES_NEW事务传播方式。

秒杀业务

秒杀DO/Model和VO

PromoDO包含活动名称、起始、结束时间、参与活动的商品id、参与活动的价格。而我们希望在前端显示活动的状态,是开始?还是结束?还是正在进行中?所以PromoModel对象新加一个status字段,通过从数据库的start_timeend_time字段,与当前系统时间做比较,设置状态。

 //1是还未开始,2是进行中,3是已结束
if(promoModel.getStartDate().isAfterNow()) {
    promoModel.setStatus(1);
}else if(promoModel.getEndDate().isBeforeNow()){
    promoModel.setStatus(3);
}else{
    promoModel.setStatus(2);
}

对于ItemModel,需要将PromoModel属性添加进去,这样就完成了商品和活动信息的关联。

ItemServiceImpl.getItemById中,除了要查询商品信息ItemDO、库存信息ItemStockDO外,还需要查询出PromoModel

public ItemModel getItemById(Integer id) {
    ItemDO itemDO=itemDOMapper.selectByPrimaryKey(id);
    if(itemDO==null) return null;
    //操作获得库存数量
    ItemStockDO itemStockDO=itemStockDOMapper.selectByItemId(itemDO.getId());
    //将dataObj转换成Model
    ItemModel itemModel=convertModelFromDataObject(itemDO,itemStockDO);
    //获取商品的活动信息
    PromoModel promoModel= promoService.getPromoByItemId(itemModel.getId());
    if(promoModel!=null&&promoModel.getStatus()!=3){
        itemModel.setPromoModel(promoModel);
    }
    return itemModel;
}

对于ItemVO,也是一样的,我们需要把活动的信息(活动进行信息、活动价格等)显示给前端,所以需要在ItemVO里面添加promoStatuspromoPrice等属性。

private String imgUrl;
//商品是否在秒杀活动中,以及其状态
private Integer promoStatus;
private BigDecimal promoPrice;
private Integer promoId;
//开始时间,用来做倒计时展示
private String startDate;
//ItemController
private ItemVO convertVOFromModel(ItemModel itemModel){
    if(itemModel==null) return null;
    ItemVO itemVO=new ItemVO();
    BeanUtils.copyProperties(itemModel,itemVO);
    //有秒杀活动,就在ItemVO设置相应信息。
    if(itemModel.getPromoModel()!=null){
        itemVO.setPromoStatus(itemModel.getPromoModel().getStatus());
        itemVO.setPromoId(itemModel.getPromoModel().getId());
        itemVO.setStartDate(itemModel.getPromoModel().getStartDate().toString(DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss")));
               itemVO.setPromoPrice(itemModel.getPromoModel().getPromoItemPrice());
    }else{
        itemVO.setPromoStatus(0);
    }
}

下面我们会总结一下,获取商品信息的完整流程。

升级获取商品业务

之前获取的商品不包含秒杀活动信息,现在需要把活动信息添加进去。

还是先请求ItemController.list接口,获取所有商品信息。然后通过点击的商品Id,请求ItemController.get接口,查询商品详细信息。

首先根据Id,调用ItemServiceImpl.getItemById查询出商品信息库存信息秒杀活动信息,一并封装到ItemModel中。然后再调用上面的convertVOFromModel,将这个ItemModel对象转换成ItemVO对象,包含了秒杀活动的信息,最后返回给前端以供显示。

活动商品下单业务

秒杀活动商品的下单,需要单独处理,以“秒杀价格”入下单库。所以OrderDO也需要添加promoId属性。

public class OrderDO {
    private String id;
    private Integer userId;
    private Integer itemId;
    //若promoId非空,则是秒杀方式下单
    private Integer promoId;
    //若promoId非空,则是秒杀价格
    private Double itemPrice;
    private Integer amount;
    //若promoId非空,则是秒杀总价
    private Double orderPrice;

之前活动商品的下单,附带itemIdamount请求OrderController.createOrder接口,现在,会附带一个promoId请求接口,这个参数会作为OrderServiceImpl.createOrder的参数,进行参数校验。

//校验活动信息
if(promoId!=null){
     //1.校验对应活动是否适用于该商品
     if(promoId.intValue()!=itemModel.getPromoModel().getId()){
          throw new  BizException(EmBizError.PARAMETER_VALIDATION_ERROR,"活动信息不存在");
     //2.校验活动是否在进行中
     }else if (itemModel.getPromoModel().getStatus()!=2){
          throw new  BizException(EmBizError.PARAMETER_VALIDATION_ERROR,"活动还未开始");
     }
}

最后,如果promoId不为空,那么订单的价格就以活动价格为准。

if(promoId!=null){
    //以活动价格入库
    orderModel.setItemPrice(itemModel.getPromoModel().getPromoItemPrice());
}else{
    //以非活动价格入库
    orderModel.setItemPrice(itemModel.getPrice());
}
orderModel.setPromoId(promoId);

改进

  • 如何发现容量问题
  • 如何使得系统水平扩展
  • 查询效率低下
  • 活动开始前页面被疯狂刷新
  • 库存行锁问题
  • 下单操作多、缓慢
  • 浪涌流量如何解决

【进阶项目笔记 上】,包含云端部署jmeter性能压测Tomcat优化分布式扩展缓存优化等。