项目实战九-登录功能一

登录有:用户名、密码、验证码

基本登录功能的实现

  1. 新增登录的vo
    1. 登录请求LoginReqVo:在req下直接添加

       @Data
       public class LoginReqVo {
           //用户名
           @NotBlank
           private String username;
              
           //密码
           @NotBlank
           private String password;
              
           //验证码
           @NotBlank
           private String captcha;
       }
      
    2. 登录响应LoginVo:在vo下直接添加

       @Data
       public class LoginVo {
           private Integer id;
           private String nickname;
           private String username;
           private String token;
       }
      
  2. controller层
    1. 新增登录、获取验证码方法

       @PostMapping("/login")
       public DataJsonVo<LoginVo> login(@Valid LoginReqVo reqVo, HttpServletRequest request) {
           //验证验证码是否正确
           if (CaptchaUtil.ver(reqVo.getCaptcha(),request)){
               return JsonVos.ok(service.login(reqVo));
           }
           //验证码错误
           return JsonVos.raise(CodeMsg.WRONG_CAPTCHA);
       }
          
       //生成验证码
       @GetMapping("/captcha")
       public void captcha(HttpServletRequest request,
                           HttpServletResponse response) throws Exception {
          
           //返回给前端的验证码图片,CaptchaUtil是使用功能第三方jar包easy-captcha
           CaptchaUtil.out(request, response);
       }
      
  3. service层

     //新增接口
     LoginVo login(LoginReqVo reqVo);
        
     //impl新增方法实现
     @Override
     public LoginVo login(LoginReqVo reqVo) {
         //根据用户名查询用户
         MpLambdaQueryWrapper<SysUser> wrapper = new MpLambdaQueryWrapper<>();
         wrapper.eq(SysUser::getUsername,reqVo.getUsername());
         SysUser po = baseMapper.selectOne(wrapper);
         if (po == null) { //用户名不存在
             return JsonVos.raise(CodeMsg.WRONG_USERNAME);
         }
         //密码不正确
         if (!po.getPassword().equals(reqVo.getPassword())){
             return JsonVos.raise(CodeMsg.WRONG_PASSWORD);
         }
         //账号锁定
         if(po.getStatus() == 1){
             return JsonVos.raise(CodeMsg.USER_LOCKED);
         }
         //更新登录时间
         po.setLoginTime(new Date());
         baseMapper.updateById(po);
            
         return MapStructs.INSTANCE.po2loginVo(po);
     }
    
    1. 注意:MapStructs需要添加LoginVo-》SysUser转化

       LoginVo po2loginVo(SysUser po);
      

后端获取图片验证码

  1. 给前端提供验证码接口,使用第三方jar包easy-captcha,pom.xml中导入

     <!-- 验证码 -->
     <dependency>
         <groupId>com.github.whvcse</groupId>
         <artifactId>easy-captcha</artifactId>
         <version>1.6.2</version>
     </dependency>
    
  2. 前端必须要传递cookie到后台,才能正确识别验证码,后台要允许跨域以及允许传递cookie

    1. 后台:目前在WebCfg类中已经全局配置

       allowCredentials(true)
       allowedOrigins(origins): 要指定特定的主机地址,不能使用*
      
    2. 前端

       Ajaxs.loadPost({
           uri: 'sysUsers/login',
           data: data.field,
           success: (response) => {
               //登录成功跳转到首页
               location.href = '../index.html'
           },
           //必须要加这个,需要跨域带上cookie
           xhrFields: { 
               withCredentials: true
           }
       })
      

会话管理

下面主要是讲市场上常用的会话管理方案

客户端身份认证-基于Cookie、Session

  1. 执行流程
    1. 客户端:发送用户名密码
    2. 服务器:验证成功后,创建并保留一个跟客户端相关联的Session,返回Session_ID给客户端
    3. 客户端:将Session_ID保存到Cookie中
    4. 客户端:后续的请求都带上包含了Session_ID的Cookie
    5. 服务器:通过验证Cookie中的Session_ID确认用户身份
  2. 优点
    1. 服务器、客户端基本自动完成一系列的流程,不用编写太多额外的代码
  3. 缺点
    1. 默认不支持分布式架构,在分布式架构下,需要解决分布式Session共享的问题,比如存放到Redis中
    2. 默认不支持非浏览器环境(没有Cookie机制的环境)

简单的分布式架构

图1

  1. 一套后台代码服务部署到n台主机上,客户端只需要访问一台服务器的地址(Nginx服务器),这台服务器仅仅用来做负载均衡这个算法,自动计算出到底去访问哪台服务器
  2. Nginx服务器:负载均衡服务器、反向代理服务器(相当于n台服务器的代理人)
  3. 分布式缓存:
    1. n台服务器肯定要访问数据库,数据库本质是一个文件,如果n台服务器都要访问,就需要进行大量的IO操作,效率低下,因此可以将数据放入到缓存中。缓存是放在服务器的内存中,提高效率
    2. 这样这个缓存就要n台服务器访问,因此专门搞一台服务器搭建缓存,再搞一台服务器存放数据库
    3. 访问数据时,先到缓存中获取,缓存中没有在到数据库中去查询,数据库查询到之后存放到缓存,以备下次访问
    4. 注意: n台服务器是不能设置缓存的,因为缓存是需要n台服务器共享,共同访问
    5. Redis就是一种分布式缓存
  4. Cookie、Session是不支持分布式的,因为Session在某台服务器的主机中存储,当客户端下次访问时不一定还是到这台主机中查找,如果要做必须将Session存储到分布式缓存中

客户端身份认证-基于token

  1. 执行流程
    1. 客户端:发送用户名密码
    2. 服务器:验证成功后,生成一个跟客户端相关联的token,返回token给客户端
    3. 客户端:存储token到本地
    4. 客户端:后续的请求都带上token
    5. 服务器:通过验证token确认用户身份
  2. 优点
    1. 支持分布式架构、支持非浏览器环境(没有Cookie机制的环境)
  3. 缺点
    1. 需要客户端手动存储、发送token
    2. 有些token会比session_id大,需要消耗更多流量
    3. 有些token方案,默认无法在服务器主动销毁token
  4. 如果在分布式缓存redis环境下,需要将token存储到redis中去

EhCache

  1. EhCache是一款简单易用的缓存框架
  2. 支持3层缓存:Heap(JVM的堆内存,内存会自动管理)、Off-Heap(堆外内存,内存需要手动管理)、Disk(磁盘)
  3. Off-Heap、Disk要求对象支持序列化和反序列化
  4. 该缓存可以设置缓存时效
  5. pom

     <dependency>
         <groupId>org.ehcache</groupId>
         <artifactId>ehcache</artifactId>
     </dependency>
    

基于token登录控制

  1. 只有登录过才能访问相关的页面
  2. 返回给客户端添加token字段LoginVo

     //返回给用户token
     private String token;
    
  3. pom.xml添加 EhCache依赖
  4. resources下添加ehCache.xml配置文件

     <?xml version="1.0" encoding="UTF-8"?>
     <config xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
             xmlns='http://www.ehcache.org/v3'
             xsi:schemaLocation="http://www.ehcache.org/v3
             http://www.ehcache.org/schema/ehcache-core.xsd">
     <!--    <persistence directory="F:/ehcache"/>-->
        
         <!-- 设置缓存的数据类型,以及缓存的位置-->
         <cache-template name="common">
        
             <key-type>java.lang.Object</key-type>
             <value-type>java.lang.Object</value-type>
             <!-- 设置资源存储到哪里-->
             <resources>
                 <!-- 默认unit是unit="entries",对象-->
                 <heap>10000</heap>
     <!--            <offheap unit="MB">50</offheap>-->
     <!--            <disk unit="GB" persistent="true">1</disk>-->
             </resources>
         </cache-template>
        
         <!--
            11月1号~11月7号,11月8号过期
            11月6号~11月12号,11月13号过期
            11月12号~11月18号,11月19号过期
         -->
        
         <!-- 创建一个缓存别名为token,存放token的缓存:只要7天内不访问,就失效 -->
         <cache alias="token" uses-template="common">
             <!--
             设置时长
             ttl:time to leave 生命周期,不管中间有没有访问,从1号开始,到8号一定过期
             tti:time to idle 只要1-8号内有访问,声明周期就是从最后一次访问开始直到7天结束,
             只要中间有访问,生命周期就延长
             -->
             <expiry>
                 <tti unit="days">7</tti>
                 <!-- <tti unit="seconds">5</tti>-->
             </expiry>
         </cache>
        
         <!-- 默认缓存:永不过期 -->
         <cache alias="default" uses-template="common">
             <!-- 设置时长-->
             <expiry>
                 <none/>
             </expiry>
         </cache>
     </config>
    
  5. 封装一个Caches类,读取xml文件

     //com.zh.jk.common.cache
     public class Caches {
         //注意:缓存管理器、缓存对象必须是唯一的!!!
         private static final CacheManager MGR;
         private static final Cache<Object, Object> DEFAULT_CACHE;
         private static final Cache<Object, Object> TOKEN_CACHE;
         static {
             // 初始化缓存管理器,读取xml配置
             URL url = Caches.class.getClassLoader().getResource("ehcache.xml");
             assert url != null;
             Configuration cfg = new XmlConfiguration(url);
             MGR = CacheManagerBuilder.newCacheManager(cfg);
             MGR.init();
        
             // 缓存对象
             DEFAULT_CACHE = MGR.getCache("default", Object.class, Object.class);
             TOKEN_CACHE = MGR.getCache("token", Object.class, Object.class);
         }
        
         public static void put(Object key, Object value) {
             if (key == null || value == null) return;
             DEFAULT_CACHE.put(key, value);
         }
        
         public static void remove(Object key) {
             if (key == null) return;
             DEFAULT_CACHE.remove(key);
         }
        
         public static <T> T get(Object key) {
             if (key == null) return null;
             return (T) DEFAULT_CACHE.get(key);
         }
        
         public static void clear() {
             DEFAULT_CACHE.clear();
         }
        
         public static void putToken(Object key, Object value) {
             if (key == null || value == null) return;
             TOKEN_CACHE.put(key, value);
         }
        
         public static void removeToken(Object key) {
             if (key == null) return;
             TOKEN_CACHE.remove(key);
         }
        
         public static <T> T getToken(Object key) {
             if (key == null) return null;
             return (T) TOKEN_CACHE.get(key);
         }
        
         public static void clearToken() {
             TOKEN_CACHE.clear();
         }
     }
    
  6. service层SysUserServiceImpl的login方法改造

     @Override
     public LoginVo login(LoginReqVo reqVo) {
         //根据用户名查询用户
         MpLambdaQueryWrapper<SysUser> wrapper = new MpLambdaQueryWrapper<>();
         wrapper.eq(SysUser::getUsername,reqVo.getUsername());
         SysUser po = baseMapper.selectOne(wrapper);
         if (po == null) { //用户名不存在
             return JsonVos.raise(CodeMsg.WRONG_USERNAME);
         }
         //密码不正确
         if (!po.getPassword().equals(reqVo.getPassword())){
             return JsonVos.raise(CodeMsg.WRONG_PASSWORD);
         }
         //账号锁定
         if(po.getStatus() == Constants.SysUserStatus.LOCKED){
             return JsonVos.raise(CodeMsg.USER_LOCKED);
         }
         //更新登录时间
         po.setLoginTime(new Date());
         baseMapper.updateById(po);
    
         //生成token,发送token给用户(这里先用一个随便的方法生成token)
         String token = UUID.randomUUID().toString();
         //存储token到缓存中,使用EhCache封装的类
         Caches.putToken(token,po);
    
         //返回给客户端的具体数据
         LoginVo vo = MapStructs.INSTANCE.po2loginVo(po);
         vo.setToken(token);
    
         return vo;
     }
    

生成token的常用第三方库

  1. 以上为随意生成的token,在开发中经常使用生成token的方法:JWT(JSON Web Token),-种基于JSON的token标准
  2. 库:

     <dependency>
         <groupId>com.authg</groupId>
         <artifactId>java-jwt</artifactId>
         <version>3.12.0</version>
     </dependency>
    
  3. 还有一个常用的jwt库

     <dependency>
         <groupId>io.jsonwebtoken</groupId>
         <artifactId>jjwt</artifactId>
         <version>0.9.1</version>
     </dependency>
    
  4. 具体使用方法网络查询。

使用shiro鉴权

具体使用详看最后一节shiro讲解

  1. pom.xml添加依赖

     <!--权限控制-->
     <dependency>
         <groupId>org.apache.shiro</groupId>
         <artifactId>shiro-spring-boot-starter</artifactId>
         <version>1.7.0</version>
     </dependency>
    
  2. 运行项目,此时会报错

     Description:
     No bean of type 'org.apache.shiro.realm.Realm' found.
     Action:
     Please create bean of type 'Realm' or add a shiro.ini in the root classpath (src/main/resources/shiro.ini) or in the META-INF folder (src/main/resources/META-INF/shiro.ini).
     Disconnected from the target VM, address: '127.0.0.1:65420', transport: 'socket'
     Process finished with exit code 0
    
    1. 就是容器中必须要有个Realm类型的bean对象,或者在classpath、META-INF下添加shiro.ini文件
  3. 在common下新建一个shiro包

    1. 新建TokenRealm类,继承自AuthorizingRealm

       //com.zh.jk.common.shiro
       public class TokenRealm extends AuthorizingRealm {
           //自己定义一个密码的比对算法类(TokenMatcher的子类),传进来
           public TokenRealm (TokenMatcher matcher) {
               super(matcher);
           }
           //自己定义如何获取数据授权
           @Override
           protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
               return null;
           }
           //自己定义如何获取数据认证
           @Override
           protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
               return null;
           }
       }
      
    2. 新建一个ShiroCfg类,专门配置那些bean放入容器,将TokenRealm类的bean放入容器

       //com.zh.jk.common.shiro
       @Configuration
       public class ShiroCfg {
           @Bean
           public Realm realm() {
               return new TokenRealm(new TokenMatcher());
           }
              
           /**
            * ShiroFilterFactoryBean用来告诉Shiro如何进行拦截
            * 1.拦截哪些URL
            * 2.每个URL需要进行哪些filter进行拦截
            */
           //这里的参数realm会自动注入,注入的是上面的Realm
           @Bean
           public ShiroFilterFactoryBean shiroFilterFactoryBean(Realm realm) {
               ShiroFilterFactoryBean filterBean = new ShiroFilterFactoryBean();
              
               //设置安全管理器
               filterBean.setSecurityManager(new DefaultWebSecurityManager(realm));
              
               // 添加一些自定义Filter,每个自定义的filter还需要设置一个别名,比如下面TokenFilter设置了一个别名为token
               Map<String, Filter> filters = new HashMap<>();
               filters.put("token", new TokenFilter());
               filterBean.setFilters(filters);
              
               // 设置URL如何拦截,必须要用LinkedHashMap,因为是有序存储,HashMap是无序存储
               Map<String, String> urlMap = new LinkedHashMap<>();
               /*设置哪些请求路径需要拦截*/
               //验证码、登录不需要token认证
               // 验证码接口,anon代表的是shiro内置的filter: org.apache.shiro.web.filter.authc.AnonymousFilter
               //不需要认证就可以访问
               urlMap.put("/sysUsers/captcha", "anon");
               // 用户登录
               urlMap.put("/sysUsers/login", "anon");
              
               // 其他所有的请求路径,使用token这个别名自定义的filter进行拦截
               urlMap.put("/**", "token");
               filterBean.setFilterChainDefinitionMap(urlMap);
              
               return filterBean;
           }
       }
      
    3. 自定义TokenMatcher,如何匹配校验算法

       public class TokenMatcher implements CredentialsMatcher {
           /*自定义实现,客户端传递过来的值与数据库的值进行匹配的算法*/
           @Override
           public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
               return false;
           }
       }
      
    4. 自定义TokenFilter,如何过滤某些url

       public class TokenFilter extends AccessControlFilter {
           @Override
           protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
               return false;
           }
              
           @Override
           protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
               return false;
           }
       }
      
    5. 上面的层级关系如下图

      图1

      1. ShiroFilterFactoryBean: 用来告诉Shiro如何进行拦截url
      2. 上面对象需要设置一个setSecurityManager-DefaultWebSecurityManager
      3. 要告诉上面那个manager要使用那个Realm-AuthorizingRealm
      4. 要告诉AuthorizingRealm使用什么算法匹配CredentialsMatcher

TokenFilter

  1. 在该filter中拦截相应的url,进行token鉴权
/**
 * 作用:验证用户的合法性、是否有相关权限
 */
@Slf4j
public class TokenFilter extends AccessControlFilter {
    public static final String HEADER_TOKEN = "Token";

    /**
     * 当请求被TokenFilter拦截时,就会调用这个方法
     * 可以在这个方法中做初步判断
     *
     * 如果返回true:允许访问。可以进入下一个链条调用(比如Filter,可以有n个filter、拦截器、控制器等)
     * 如果返回false:不允许访问。会进入onAccessDenied方法,不会进入下一个链条调用(比如Filter、拦截器、控制器等)
     */
    @Override
    protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        // 放行所有的OPTIONS请求
        //return "OPTIONS".equals(request.getMethod());

        log.debug("TokenFilter - isAccessAllowed - " + request.getRequestURI());
        return false;
    }

    /**
     * 当isAccessAllowed返回false时,就会调用这个方法
     * 在这个方法中进行token的校验
     *
     * 如果返回true:允许访问。可以进入下一个链条调用(比如Filter、拦截器、控制器等)
     * 如果返回false:不允许访问。不会进入下一个链条调用(比如Filter、拦截器、控制器等)
     */
    @Override
    protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        log.debug("TokenFilter - onAccessDenied - " + request.getRequestURI());

        // 取出Token
        String token = request.getHeader(HEADER_TOKEN);

        // 如果没有Token
        if (token == null) {
            return JsonVos.raise(CodeMsg.NO_TOKEN);
        }

        // 如果Token过期了
        if (Caches.getToken(token) == null) {
            return JsonVos.raise(CodeMsg.TOKEN_EXPIRED);
        }

        log.debug("TokenFilter - onAccessDenied - " + token);

        // 鉴权(进入Realm)
        // 这里代码后序添加
        return true;
    }
}

Filter的异常处理

问题分析

  1. 问题:当TokenFilter中的onAccessDenied方法中抛出异常时,CommonExceptionHandler是无法拦截到的
  2. 原因分析:
    1. CommonExceptionHandler中的异常拦截

       @RestControllerAdvice
       @Slf4j
       public class CommonExceptionHandler {
           @ExceptionHandler(Throwable.class)
           @ResponseStatus(code = HttpStatus.BAD_REQUEST)
           public JsonVo handle(Throwable t)  {
               log.error(null,t);
               return JsonVos.error(t);
           }
       }
      
    2. @RestControllerAdvice只能拦截到达Controller以后的异常
    3. 当项目中存在filter时,客户端发送请求,先到filter,然后才到controller,所以如果filter出现异常抛出,是无法通过@RestControllerAdvice拦截到异常的
  3. 解决方案: 图1
    1. 搞一个ErrorFilter放在所有filter的最前面
    2. 然后用这个ErrorFilter拦截所有其他filter的异常信息
    3. 自定义一个ErrorController,专门用于透传抛出异常
    4. ErrorFilter拦截到的异常信息通过转发的形式发送给ErrorController
    5. ErrorController抛出异常,此时CommonExceptionHandler可以拦截到异常
    6. 这样就实现了CommonExceptionHandler能拦截filter抛出的异常

异常封装

  1. 具体实现步骤
    1. 新建ErrorFilter,然后放在所有filter的最前面
      1. 在WebCfg(WebMvcConfigurer)中,注册ErrorFilter,并设置为最前面的Filter
       /*
       * 作用:
       * 1. 注册ErrorFilter
       * 2.保证ErrorFilter在所有filter的最前面
       * */
       @Bean
       public FilterRegistrationBean<Filter> filterRegistrationBean() {
           FilterRegistrationBean<Filter> bean = new FilterRegistrationBean<>();
           // 设置Filter
           bean.setFilter(new ErrorFilter());
           //拦截所有的路径
           bean.addUrlPatterns("/*");
           // 最高权限
           bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
           return bean;
       }
      
    2. 用这个ErrorFilter拦截所有其他filter的异常信息,并将异常转发给ErrorController
      1. 如何拦截其他filter的异常呢?
        1. 如果有Filter1、Filter2、Filter3,那么当客户端发送请求时,会先经过Filter1、Filter2、Filter3
        2. 伪代码如下

           Filter1.doFilter {
               chain.doFilter {
                   Filter2.doFilter {
                       chain.doFilter {
                           Filter3.doFilter {
                               chain.doFilter {
                                   Controller.login
                               }
                           }
                       }
                   }
               }
           }
          
        3. 那么如果在Filter1的doFilter中添加try-catch,就可以捕获Filter2、Filter3的异常信息

           Filter1.doFilter {
               try {
                   chain.doFilter {
                       Filter2.doFilter {
                           chain.doFilter {
                               Filter3.doFilter {
                                   chain.doFilter {
                                       Controller.login
                                   }
                               }
                           }
                       }
                   }
               } catch (Exception e) {
                   // 将异常提交给ErrorController
               }
           }
          
      2. ErrorFilter具体实现:

         public class ErrorFilter implements Filter {
             public static final String ERROR_URI = "/handleError";
                    
             @Override
             public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
                 try {
                     chain.doFilter(request, response);
                 } catch (Exception e) {
                     //设置异常,临时存储
                     request.setAttribute(ERROR_URI, e);
                     //将异常转发给ErrorController,转发路径ERROR_URI
                     request.getRequestDispatcher(ERROR_URI).forward(request, response);
                 }
             }
         }
        
    3. ErrorController透传抛出异常:

       @RestController
       public class ErrorController {
           //监听ErrorFilter转发过来的异常
           //路径ERROR_URI
           @RequestMapping(ErrorFilter.ERROR_URI)
           public void handle(HttpServletRequest request) throws Exception {
               // 抛出异常
               throw (Exception) request.getAttribute(ErrorFilter.ERROR_URI);
           }
       }
      
    4. 这样CommonExceptionHandler就能拦截filter的异常了
  2. 注意: ShiroCfg中的ShiroFilterFactoryBean需要对ErrorController的接收路径ERROR_URI放行,不做token鉴权

     // 全局Filter的异常处理
     urlMap.put(ErrorFilter.ERROR_URI, "anon");
    
  3. CommonExceptionHandler针对各种异常做丰富处理改造,将JsonVos中的异常类型处理删除,JsonVos只做响应封装

     @RestControllerAdvice
     @Slf4j
     public class CommonExceptionHandler {
        
         @ExceptionHandler(Throwable.class)
         @ResponseStatus(code = HttpStatus.BAD_REQUEST)
         public JsonVo handle(Throwable t) {
             log.error("handle", t);
        
             // 一些可以直接处理的异常
             if (t instanceof CommonException) {
                 return handle((CommonException) t);
             } else if (t instanceof BindException) {
                 return handle((BindException) t);
             } else if (t instanceof ConstraintViolationException) {
                 return handle((ConstraintViolationException) t);
             } else if (t instanceof AuthorizationException) {
                 return JsonVos.error(CodeMsg.NO_PERMISSION);
             }
        
             // 处理cause异常(导致产生t的异常)
             Throwable cause = t.getCause();
             if (cause != null) {
                 //递归
                 return handle(cause);
             }
        
             // 其他异常(没有cause的异常)
             return JsonVos.error();
         }
        
         private JsonVo handle(CommonException ce) {
             return JsonVos.error(ce.getCode(), ce.getMessage());
         }
        
         private JsonVo handle(BindException be) {
             List<ObjectError> errors = be.getBindingResult().getAllErrors();
             // 函数式编程的方式:stream
             List<String> defaultMsgs = Streams.map(errors, ObjectError::getDefaultMessage);
             String msg = StringUtils.collectionToDelimitedString(defaultMsgs, ", ");
             return JsonVos.error(msg);
         }
        
         private JsonVo handle(ConstraintViolationException cve) {
             List<String> msgs = Streams.map(cve.getConstraintViolations(), ConstraintViolation::getMessage);
             String msg = StringUtils.collectionToDelimitedString(msgs, ", ");
             return JsonVos.error(msg);
         }
     }
    
Table of Contents