跳至主要內容

前后分离项目搭建

chenkunwebwebspringboot大约 12 分钟

温馨提示

项目地址open in new window,每个节点的代码使用commitId作为区分,想看某个节点代码,直接还原到对应commitid即可,执行git reset --hard commitId

1、后端篇

1.1 初始化springboot项目

git reset --hard 20e22c237e51fb9c7f01bdfd589a90f47fa73c34

1.1.1 使用maven聚合模块以及parent依赖的方式初始化好了项目

问题1
分模块后,如何读取到其他模块中的bean,比如全局异常处理放在了common模块,在业务模块依赖了common,如何让common中的全局异常拦截生效?
首先要明白无法common模块的component在core-biz不生效的原因是在biz模块默认扫描的component的包范围是启动类所在的包,也就是com.chensino.core,而全局异常类所在的包是com.chensino.common.security.exception,根本没有被扫描到。

1

解决方法有三种
参考此处文档open in new window

  1. 把扫描范围搞大一点
package com.chensino.core;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

/**
 * @author 204506
 */
@SpringBootApplication
@ComponentScan("com.chensino")
public class App {

    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

}
  1. 在原有的基础上加上新的包,和方法1本质一样都是增加包的扫描范围
@ComponentScan({"com.chensino.core","com.chensino.common"})
  1. 使用spi,利用自动装配

在resource目录新建META-INF/spring.factories,内容如下

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.chensino.common.security.exception.GlobalExceptionHandlerResolver

1

  1. 使用@import注解(会把import的实体加入ioc)

import作用
2

@SpringBootApplication
@Import(GlobalExceptionHandlerResolver.class)
public class App {

    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

}
  1. @import进行封装

在原common模块加上注解EnableGlobalExceptionHandlerConfiguration,在注解中import全局异常处理类
在启动类加上注解EnableGlobalExceptionHandlerConfiguration

@SpringBootApplication
@EnableGlobalExceptionHandlerConfiguration
public class App {

    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

}

本次项目采用spi的方式

1.2 全局异常处理

git reset --hard feb6a4a830a9c323dd58427e8aa0be9af0eb1ef3
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandlerResolver {

    /**
     * 全局异常
     * @param e 异常
     * @return 返回统一实体
     */
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<String> handlerGlobalException(Exception e){
        log.error("全局异常信息 exception={}",e.getMessage(),e);
        return ResponseEntity.fail(e.getLocalizedMessage());
    }
}

springboot全局异常处理比较简单,直接拦截Exception,设置response响应码为500,然后返回统一实体。

1.3 aop统一日志处理

1.3.1 添加基础日志

git reset --hard 1c4d8022ba4b34187a1627534e05ec69399fc4a9

springboot作为开箱即用的框架,默认使用slfj+logback日志框架
即使不添加logback.xml配置,springboot也会默认输出console上的日志,生产环境肯定还是需要把日志写入到文件的,所以先添加一下logback.xml配置,这个模板可以直接用,要改的就是日志存储位置以及包名

<?xml version="1.0" encoding="UTF-8"?>
<!--
    小技巧: 在根pom里面设置统一存放路径,统一管理方便维护
    <properties>
        <log-path>/Users/ccs</log-path>
    </properties>
    1. 其他模块加日志输出,直接copy本文件放在resources 目录即可
    2. 注意修改 <property name="${log-path}/log.path" value=""/> 的value模块
-->
<configuration debug="false" scan="false">
    <property name="log.path" value="logs/core-biz}"/>
    <!-- 彩色日志格式 -->
    <property name="CONSOLE_LOG_PATTERN"
              value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
    <!-- 彩色日志依赖的渲染类 -->
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter"/>
    <conversionRule conversionWord="wex"
                    converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter"/>
    <conversionRule conversionWord="wEx"
                    converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>
    <!-- Console log output -->
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>

    <!-- Log file debug output -->
    <appender name="info" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${log.path}/info.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${log.path}/%d{yyyy-MM, aux}/info.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>50MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%date [%thread] %-5level [%logger{50}] %file:%line - %msg%n</pattern>
        </encoder>
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>info</level>
        </filter>
    </appender>

    <!-- Log file error output -->
    <appender name="error" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${log.path}/error.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${log.path}/%d{yyyy-MM}/error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>50MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>%date [%thread] %-5level [%logger{50}] %file:%line - %msg%n</pattern>
        </encoder>
        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
            <level>ERROR</level>
        </filter>
    </appender>

    <logger name="com.chensino">
        <appender-ref ref="error"/>
    </logger>
    <logger name="com.chensino">
        <appender-ref ref="info"/>
    </logger>
    <!-- Level: FATAL 0  ERROR 3  WARN 4  INFO 6  DEBUG 7 -->
    <root level="INFO">
        <appender-ref ref="console"/>
    </root>
</configuration>

1.4 添加Mybatis-plus

1.4.1 maven依赖

<!-- mybatis-plus-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>${mybatis-plus.version}</version>
</dependency>
<!-- 开发环境sql监控性能分析-->
<dependency>
    <groupId>p6spy</groupId>
    <artifactId>p6spy</artifactId>
    <version>${p6spy.version}</version>
</dependency>
<!-- jdbc驱动-->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>${jdbc.version}</version>
</dependency>

1.4.2 springboot配置

spring:
  datasource:
    driver-class-name: com.p6spy.engine.spy.P6SpyDriver #此处用p6sy驱动代替原jdbc驱动
#    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: 123456
    url: jdbc:p6spy:mysql://${MYSQL_HOST:chensino-mysql}:${MYSQL_PORT:3306}/${MYSQL_DB:chensino}?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=GMT%2B8&allowMultiQueries=true&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: delFlag  #逻辑删除
      logic-delete-value: 1 
      logic-not-delete-value: 0

1.4.3 其他配置

最重要的是开启注解扫描,指定mapper所在的包

@SpringBootApplication()
@MapperScan("com.chensino.core.mapper")
public class App {

    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

}

其他的service、mapper.xml、entity推荐使用idea插件连接数据库自动生成,插件名字是MybatisX-Generator,下载好后连接数据库,在对应表上右键选择MybatisX-Generator,再填写相应信息即可自动生成相应的文件

20220901150908

spy.properties配置:

#3.2.1以上使用
modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory

# 自定义日志打印
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
#日志输出到控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 使用日志系统记录 sql
#appender=com.p6spy.engine.spy.appender.Slf4JLogger
# 设置 p6spy driver 代理
deregisterdrivers=true
# 取消JDBC URL前缀
useprefix=true
# 配置记录 Log 例外,可去掉的结果集有error,info,batch,debug,statement,commit,rollback,result,resultset.
excludecategories=info,debug,result,commit,resultset
# 日期格式
dateformat=yyyy-MM-dd HH:mm:ss
# 实际驱动可多个
#driverlist=org.h2.Driver
# 是否开启慢SQL记录
outagedetection=true
# 慢SQL记录标准 2 秒
outagedetectioninterval=2

1.5 添加Redis

1.5.1 maven依赖以及配置

      <!--缓存依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>
spring:
  redis:
    database: 0
    password: 123456
    host: chensino-redis
    port: 6379
    timeout: 1000
    jedis:
      pool:
        enabled: true
        max-active: 8 #池中最大连接数
        max-idle: 8 #最大空闲连接数
        max-wait: 1000
        min-idle: 0

1.5.2 redis封装

redis配置直接参考此作者代码open in new window,主要是封装reids方法,实现序列化反序列化

代码参考此处open in new window

1.5.3 遇到的坑

坑1

在EnableAutoConfiguration配置时,在最后多了一个逗号,如下,最后有个逗号,会导致项目无法启动,启动直接报错

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.chensino.common.data.configuration.mybatis.MybatisPlusConfiguration,\
  com.chensino.common.data.configuration.cache.RedisConfiguration,\ # 此处手抖加了一个逗号

报错内容

Caused by: java.io.FileNotFoundException: class path resource [.class] cannot be opened because it does not exist
	at org.springframework.core.io.ClassPathResource.getInputStream(ClassPathResource.java:199)
	at org.springframework.core.type.classreading.SimpleMetadataReader.getClassReader(SimpleMetadataReader.java:55)
	at org.springframework.core.type.classreading.SimpleMetadataReader.<init>(SimpleMetadataReader.java:49)
	at org.springframework.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(SimpleMetadataReaderFactory.java:103)
	at org.springframework.boot.type.classreading.ConcurrentReferenceCachingMetadataReaderFactory.createMetadataReader(ConcurrentReferenceCachingMetadataReaderFactory.java:86)
	at org.springframework.boot.type.classreading.ConcurrentReferenceCachingMetadataReaderFactory.getMetadataReader(ConcurrentReferenceCachingMetadataReaderFactory.java:73)
	at org.springframework.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(SimpleMetadataReaderFactory.java:81)
	at org.springframework.boot.autoconfigure.AutoConfigurationSorter$AutoConfigurationClass.getAnnotationMetadata(AutoConfigurationSorter.java:233)
	... 27 common frames omitted

根据idea的提示,直接在报错位置打断点会看到如下异常 20220901174408

坑2

我想把user对象存入redis报错,jackson序列化失败了

Caused by: com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Java 8 date/time type `java.time.LocalDateTime` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: com.chensino.core.api.entity.SysUser["createTime"])

解决方法就是在LocalDateTime字段加上序列化和反序列化的注解,让jackson知道如何进行序列化和反序列化

 /**
     * 创建时间
     */
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    private LocalDateTime updateTime;

1.6 添加swagger

1.6.1 依赖

使用3.0.0只需要下面这一个依赖

<dependency>
    <groupId>io.springfox</groupId>
    <artifactId>springfox-boot-starter</artifactId>
    <version>3.0.0</version>
</dependency>

1.6.2 swagger配置

swagger基本配置open in new window

1.6.3 使用

  1. 在Controller添加注解@Api(value = "用户管理",tags = {"用户管理"})
  2. 在方法上添加注解@ApiOperation(value = "根据id查询-value")

注意,Controller上注解中的tags是一个逻辑分组,比如如果把两个不同的Contrller都用同样的tags,则这两个不同Controller中的接口会被放到一个分组下,一般情况下我们只需要按照上面配置即可,没必要高的过于复杂 20220902135942

1.6.4 访问入口

http://<IP:PORT>/swagger-ui/index.html

1.7 添加Security

1.7.1 maven依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

1.7.2 启动

引入依赖后直接启动项目在控制台会生成随机密码,用户名:user 密码:xx登陆。此时swagger也需要用户名和密码才能访问

20220902155557

1.7.3 security对接数据库,从数据库读取角色和权限

要从数据库读取用户到security上下文,需要重写com.chensino.core.security.service.CustomUserDetailsService#loadUserByUsername,在重写方法中去进行具体的业务处理,其实也就是查询数据库那些,当然数据库表也要创建,至少包括用户表t_user、角色表t_role、权限表t_menu、用户角色表t_user_role、角色权限表t_role_menu,也就是常说的RBAC(role based access control基于角色得权限控制),这里得权限指的是页面权限,因此我得表取名为menu,实际上权限包含数据权限、界面权限两种。

我重写UserDetailsService的如下:


@AllArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final SysUserService sysUserService;

    /**
     * @param username the username identifying the user whose data is required.
     * @return
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = Optional.ofNullable(sysUserService.getOne(Wrappers.<SysUser>query().lambda().eq(SysUser::getUserName, username))).orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
        UserInfo userInfo = sysUserService.findUserInfo(sysUser);
        return getUserDetails(userInfo);
    }

    /**
     * 根据sysUser构造UserDetails
     *
     * @param info
     * @return
     */
    private UserDetails getUserDetails(UserInfo info) {
        Set<String> dbAuthsSet = new HashSet<>();
        if (ArrayUtil.isNotEmpty(info.getRoles())) {
            // 把角色写入用户信息
            info.getRoles().forEach(role -> dbAuthsSet.add(SecurityConstants.ROLE + role.getRoleCode()));
            // 把权限(资源)写入用户信息
            dbAuthsSet.addAll(Arrays.asList(info.getPermissions()));
        }
        Collection<? extends GrantedAuthority> authorities = AuthorityUtils
                .createAuthorityList(dbAuthsSet.toArray(new String[0]));
        SysUser user = info.getSysUser();
        return new CustomSecurityUser(user.getUserId(), user.getDeptId(), user.getPhone(), user.getAvatar(), user.getUserName(), user.getPassword(), !user.getLockFlag(), true, true, !user.getLockFlag(), authorities);
    }

重写后,需要在Security的配置类中引入对应的Bean

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    /**
     * 自定义密码加密方式,解密会自动调用PasswordEncoder的match方法
     *
     * @return
     */
    @Bean
    PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * WebSecurity 处理静态资源不走过滤器,注意区别HttpSecurity,HttpSecurity主要用来处理后端接口,比如login接口虽然可以ignore,但是
     * 还有其他逻辑还要走过滤器,如果使用WebSecurity,则login直接就不会受到任何过滤器处理,代表这个接口已经超脱于Security之外了。一句话:
     * WebSecurity负责过滤不需要处理的静态资源,HttpSecurity负责处理普通的api接口。
     *
     * @return
     */
    @Bean
    WebSecurityCustomizer webSecurityCustomizer() {
        return web -> web.ignoring().antMatchers("/user/list");
    }

    /**
     * 处理接口权限
     */
    @Bean
    @Order(2)
    public SecurityFilterChain webSiteSecurityFilterChain(HttpSecurity http) throws Exception {
        return http.antMatcher("/**")
                .authorizeRequests()
                // 所有请求都必须要认证才可以访问
                .anyRequest()
                .hasRole("ADMIN")
//                .permitAll()
                .and()
                // 禁用csrf
                .csrf()
                .disable()
                // 启用表单登录
                .formLogin()
                .permitAll()
                .and()
                // 捕获成功认证后无权限访问异常,直接跳转到 百度
                .exceptionHandling()
                .accessDeniedHandler((request, response, exception) -> response.sendRedirect("http://www.baidu.com"))
                .and()
                .build();
    }

    @Bean
    UserDetailsService userDetailsService(SysUserService sysUserService) {
//        System.out.println(new BCryptPasswordEncoder().encode("123456"));
        return new CustomUserDetailsService(sysUserService);
    }
}

1.8 角色权限控制

1.8.1 接口权限控制

定义一个Component,从Security上下文读取用户权限信息,注释中有写明,其实用户得角色和菜单权限都在authorities 中,不同得是角色会有个ROLE_前缀

代码展示open in new window

使用权限控制接口

    //此接口需要ADMIN角色
	@SysLog("获取权限")
    @GetMapping("authentication")
    @PreAuthorize("@pms.hasRole('ADMIN')")
    public ResponseEntity<Object> getAuthentication() {
        return ResponseEntity.ok(SecurityContextHolder.getContext().getAuthentication());
    }

//此接口需要user_query权限
   @ApiOperation(value = "根据id查询-value")
    @SysLog("根据用户id查询")
    @GetMapping("/{userId}")
    @PreAuthorize("@pms.hasPermission('user_query')")
    public ResponseEntity<SysUser> getUserById(@PathVariable Long userId) {
        SysUser user = sysUserService.getById(userId);
        globalCache.set("user:" + user.getUserName(), user);
        return ResponseEntity.ok(user, "根据id查询用户,username=" + user.getUserName());
    }

1.9 自定义token认证(前后分离)

1.9.1 新版本security注入AuthenticationManager方式

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

1.9.2 认证逻辑

@Service
@Data
public class LoginServiceImpl implements LoginService {
    @Autowired
    IGlobalCache redisTemplate;
    @Autowired
    private AuthenticationManager authenticationManager;

    @Value("${token.expiration}")
    private Long expiration;

    @Override
    public ResponseEntity login(SysUser sysUser) {
        //构造一个未认证的对象
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(sysUser.getUserName(), sysUser.getPassword());
        //1. 使用AuthenticationManager认证用户
        Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
        //2. 认证不通过
        if (!Objects.nonNull(authenticate)) {
            throw new RuntimeException("认证失败");
        }
        //3. 认证通过,生成token,key->token,value->用户信息
        String token = UUID.fastUUID().toString(true);
        CustomSecurityUser customSecurityUser = (CustomSecurityUser) authenticate.getPrincipal();
        //4. token存入redis
        redisTemplate.set(CacheConst.TOKEN_PREFIX + StrPool.COLON + token,customSecurityUser,expiration);
        return ResponseEntity.ok(token);
    }
}

1.9.3 放行登录接口,去掉表单登录

20221206161556

1.9.4 过滤器校验请求权限


@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    @Autowired
    private IGlobalCache redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //1. 解析token
        String token = request.getHeader("X-token");
        //2. token不存在直接放行,后续的FilterInterceptor会校验权限,没有权限依然无法访问接口
        if (!StringUtils.hasText(token)) {
            filterChain.doFilter(request, response);
            return;
        }
        //3. 根据token查询用户信息,目标是设置SecurityContext
        CustomSecurityUser customSecurityUser = redisTemplate.get(CacheConst.TOKEN_PREFIX + StrPool.COLON + token);
        if (Objects.nonNull(customSecurityUser)) {
            UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(customSecurityUser.getUsername(), customSecurityUser.getPassword(), null);
            SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        }

        filterChain.doFilter(request, response);
    }
}

1.9.5 过滤器配置

20221206161730

1.9.6 思考

虽然以上完成了自定义用uuid当token,但是token的设计并不严谨,token的key到底该如何设计,以下两个方法是否合理?

  • 方法1:使用前缀加用户名当成key,value存入token
  • 方法2:前缀加token当成key,用户信息当成value

方法1,如果使用用户名做key,那么每个请求都需要客户端携带一个用户名和token,然后后端在过滤器根据用户名从redis查询token, 对比两个token来做权限验证,这个貌似不太合理,至少我在实际项目开发中没看到过要求前端每次都要传递用户名的。使用这个方法有 一个优点是很容易做重复登录的限制,只需要根据用户名限制登录就好了,每次登录前检查redis中是否已经有了这个用户的登陆记录,如果 有,可以提示用户已经登陆,或者挤掉已登录用户,这个根据自己业务来做。

方法2中使用的token当key,用户请求时只需要带上token在头部,但是用token当key,不太方便限制重复登录,需要额外维护一个已经登录用户列表, 或许有其他更好的方法。

next 菜单权限

2、前端篇

2.1 初始化vue2

2.2 axios统一处理