Vue CLI

关于Vue CLI

Vue CLI是Vue官方推出的一个脚手架客户端工具,使用它可以快速的构建一个基于Vue的单页面应用。

安装Node.js

下载 https://mirrors.tuna.tsinghua.edu.cn/nodejs-release/v16.14.2/node-v16.14.2-x64.msi 并安装,安装过程中没有特殊选项。

安装完成后,可以在命令提示符窗口或终端中执行npm -v检查是否安装成功:

npm -v

安装Node.js的主要目的就是为了使用npm。

npm = Node Package Manager

在使用npm之前,需要先将npm源配置为国内的某个npm源服务器

npm config set registry https://registry.npm.taobao.org

设置后,还可以通过get命令查看npm源:

npm config get registry

注意:以上命令并不能检查你的配置值是否正确!

安装Vue CLI

需要安装Vue CLI以后,才可以通过它的命令来创建Vue CLI项目、启动项目等。

当安装了npm并配置npm源之后,安装Vue CLI的命令是:

npm install -g @vue/cli

安装过程中没有出现Error字样即为成功

安装过程中出现Error字样即为失败,可以:

  • 先通过npm config get registry检查npm源是否是:https://registry.npm.taobao.org/
  • 重新执行以上安装Vue CLI命令
  • 不要在Power Shell下执行命令(命令提示符前面为PS字样)
  • 如果使用Mac OS(苹果操作系统),建议在命令前添加sudo以使用管理员权限来执行命令

如果安装过程中卡住长时间没有反应,可以按下Ctrl + C强制终止,然后再次执行命令进行尝试。

当安装完成之后,可以使用vue -V来查看Vue CLI版本,也可以用于检验刚才的安装是否成功:

vue -V

创建Vue CLI项目

通常,应该创建某个文件夹,用于存放项目,例如在D盘下创建Vue-Workspace文件夹,然后,在命令提示符窗口中进入此文件夹:

D:

cd D:\Vue-Workspace

接下,通过vue create 项目名称命令来创建Vue CLI项目:

vue create jsd2204-csmall-web-client-teacher

注意:敲完以后命令之后只能按1下回车键,即使卡住了,也不要反复按回车!

注意:如果接下来的操作过程中选错,按下Ctrl + C强制终止,再重新创建项目。

按1下回车后,稍微等待一会,会出现创建项目时的选项,需要选择:

Manually select features

Babel

Vuex

Router

2.x

直接回车

In package.json

直接回车

最后,看到Successfully created project jsd2204-csmall-web-client-teacher字样,即表示创建成功。

启动项目

通过IntelliJ IDEA打开项目,在IntelliJ IDEA的Terminal窗口中执行:

npm run serve

执行以上命令即可启动项目,启动成功后,即可看提示:

App running at:

- Local:   http://localhost:8080/

提示:可能某些电脑上会显示多个网址,这并不重要。

打开浏览器,通过 http://localhost:8080/ 网址进行访问,即可看到默认的页面。

关于占用端口:通过npm run serve启动的Vue CLI会默认尝试占用8080端口,如果尝试占用的端口号已经被其它进程占用,则会自动顺延一位,即尝试占用8081端口,如果仍被占用,会继续顺延……

也可以显式的指定某个端口号,在package.json中修改scripts的serve属性,例如配置为:

"serve": "vue-cli-service serve --port 8888"

则当前项目启动时会占用8888端口。

停止服务

当项目启动后,在提示了启动成功的端口窗口中,按下Ctrl + C即可停止服务。

提示:有时按下Ctrl + C后没有响应,可能反复多按几次,或按了Ctrl + C后回车。

提示:其实,只要按下了Ctrl + C,当前服务就已经停止了,后续可能出现终止批处理操作吗(Y/N)?提示,无论选择Y还是N,都无所谓。

重启服务

没有此功能

Vue CLI项目结构

  • package.json:相当于Maven项目中的pom.xml文件,主要配置了当前项目的依赖项,如果不太熟悉此文件,不建议手动修改
  • package-lock.json:此文件是自动生成的,不建议手动修改
  • [node_modules]:当前项目中各依赖项对应的源文件,通常,此文件夹的内容较多,且共享项目时,通常不会包含此文件夹,例如GIT仓库中的项目文件通常不包含此文件夹的内容,执行npm install命令将根据package.json下载相关的依赖项到此文件夹中
  • [src/views]:是建议的存放.vue视图文件的文件夹
  • [src/router/index.js]:是项目的路由配置文件,它配置了各路径与.vue视图组件的对应关系
  • public/index.html:项目中唯一的HTML文件,其内部在页面设计中添加了<div id="app"></div>标签
  • src/App.vue:项目中默认的视图文件,是被index.html显示的

关于.vue视图文件

是Vue CLI中用于设计页面的源文件,可以此文件中设计页面的元素、CSS样式、JavaScript。

此文件可以有3个根节点(元素):

  • <template>:在其内部设计页面元素,且此节点(元素)必须有且仅有1个直接子节点(元素),通常,会在<template>下添加<div>,然后,在<div>内部再设计页面
  • <style>:在其内部配置CSS样式
  • <script>:在其内部编写JavaScript程序

提示:根据页面设计,某些.vue文件可能没有<style>,或可能没有<script>。

关于路由配置

在src/router/index.js中,使用了routes数组常量配置路由,主要是配置了各路径与视图组件的对应关系,所以,在数组中的各个元素值就是一个个的路由对象,每个路由对象至少要配置path和component这2个属性。

提示:在路由对象中,name属性不是必须的。

关于component属性,有2种配置方式,第1种是默认导入的,通常会在当前文件的顶部使用import语句导入并命名,然后,此component属性的值就是导入时取的名字,第2种是使用箭头函数import导入的,通常,在各项目中,只会有1个是默认导入的。

关于router-view

在.vue文件中,可以添加<router-view/>,此标签本身是没有显示效果的,它表示“此处将由另一个视图组件来完成显示,且,到底由哪个视图组件来显示,取决于路由配置与当前访问的URL”。

嵌套路由

在开发实践中,必然存在某些页面是完全没有相同之处的,所以,通常,在App.vue的设计中,只保留一个<router-view/>,所以,具体的显示都由各个.vue文件来决定,默认并没有共同(复用)的部分!但是,也一定存在多个页面之间存在共同的部分,所以,在某个.vue中可能还需要再加一个<router-view/>,像这种本身显示在App.vue中的<router-view/>位置、自身内部也包含<router-view/>的,称之为“嵌套路由”。

在src/router/index.js中,如果某个视图有<router-view/>,在配置时,应该通过children属性配置子级路由(被嵌套的那层路由),此children属性的写法与根级的routes完全相同,例如:

const routes = [{path: '/home',component: () => import('../views/HomeView.vue'),children: [{path: '/brand-list',component: () => import('../views/BrandListView.vue')},{path: '/brand-add-new',component: () =>import('../views/BrandAddNewView.vue')}]},// 省略其它代码
}

一旦使用了嵌套路由,必须有某个View是不完整的(其内部有某个区域使用了<router-view/>,是由其它View来负责显示的),这样的View不应该能够被直接访问,所以,通常会配置上redirect属性,表示“重定向”的意思,一旦访问这个View对应的路径,就会自动跳转到重定向配置的路径上,例如:

const routes = [{path: '/sys-admin',component: () => import('../views/HomeView.vue'),redirect: '/sys-admin/index',  // 重定向// 其它代码

另外,在使用了嵌套路由时,通常,设计的子级路由中的URL会有共同的前缀,例如:

const routes = [{path: '/sys-admin',component: () => import('../views/HomeView.vue'),redirect: '/sys-admin/index',children: [{// 以下路径使用了 /sys-admin 作为前缀path: '/sys-admin/temp/brand/list',component: () => import('../views/sys-admin/temp/BrandListView.vue')},{// 以下路径使用了 /sys-admin 作为前缀path: '/sys-admin/temp/brand/add-new',component: () => import('../views/sys-admin/temp/BrandAddNewView.vue')},

则,在配置子级路径的path时,可以不使用/作为第1个字符,然后,配置值只需要写/sys-admin右侧的部分,例如:

const routes = [{path: '/sys-admin',component: () => import('../views/HomeView.vue'),redirect: '/sys-admin/index',children: [{// 实际路径是父级路由的path与当前path的组合:/sys-admin/temp/brand/listpath: 'temp/brand/list',component: () => import('../views/sys-admin/temp/BrandListView.vue')},{// 实际路径是父级路由的path与当前path的组合:/sys-admin/temp/brand/add-newpath: 'temp/brand/add-new',component: () => import('../views/sys-admin/temp/BrandAddNewView.vue')},

在Vue CLI中使用Element UI

在终端中,需要先安装Element UI(本质上是下载Element UI相关的文件到本项目的node_modules中)。

必须保证当前命令提示符是当前项目下(与执行npm run serve等命令的位置相同,必须保证当前位置下有package.json文件)!

安装的命令是:

npm i element-ui -S

注意:以上命令最后的S是大写的!

安装完成后,需要在src/main.js中进行配置:

import ElementUI from 'element-ui';import 'element-ui/lib/theme-chalk/index.css';Vue.use(ElementUI);

在使用Element UI中的<el-menu>菜单时,可以在菜单上添加router属性,然后,各菜单项的index属性可配置为路径值,则点击菜单项时将跳转到此路径,例如:

<el-menurouterdefault-active="2"class="el-menu-vertical-demo"background-color="#9ed3d7"text-color="#fff"active-text-color="#fff"><el-menu-item index="/sys-admin/index"><i class="el-icon-s-home"></i><span slot="title">首页</span></el-menu-item><!-- 剩余其它代码 -->

使用Axios

在Vue CLI中,在使用Axios之前,需要先安装:

npm i axios -S

安装完成后,需要在main.js中添加配置:

import axios from 'axios';

Vue.prototype.axios = axios;

当使用Axios向其它远程服务器提交异步请求时,可能会出现CORS错误,即“跨域”的错误。

Access to XMLHttpRequest at 'http://localhost:8080/login' from origin 'http://localhost:8888' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.

在Spring MVC项目中,当需要允许跨域访问时,需要在Spring MVC项目中自定义配置类,实现WebMvcConfigurer接口,重写其中的addCorsMapping()方法:

@Configurationpublic class WebMvcConfiguration implements WebMvcConfigurer {@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowCredentials(true).allowedHeaders("*").allowedMethods("*").allowedOriginPatterns("*").maxAge(3600);}}

在Vue CLI项目中,当使用Axios时,需要使用this来引用axios,并且,在then()内部,只能使用箭头函数,不写使用匿名function函数,例如:

this.axios.post(url, this.ruleForm).then((response) => {console.log('服务器端响应的结果:' + response);console.log(response);if (response.data == 1) {console.log('登录成功');this.$message({message: '登录成功!',type: 'success'});} else if (response.data == 2) {console.log('登录失败,用户名错误!');this.$notify.error({title: '登录失败',message: '用户名不存在!'});} else {console.log('登录失败,密码错误!');this.$notify.error({title: '登录失败',message: '密码错误!'});}});

Spring框架

关于Spring框架

Spring框架主要解决了创建对象、管理对象的问题。

通过Spring创建对象

需要Spring创建对象,有2种做法:

  • @Bean方法
  • 组件扫描

关于@Bean方法

  • 在配置类中,自定义某个方法,其返回值类型就是你需要Spring创建对象的类型,在方法体中自行编写创建对象的代码,并且,在此方法上添加@Bean注解即可

关于组件扫描

  1. 通过@ComponentScan注解,可以开启组件扫描,并配置扫描的包

    • 在Spring Boot项目中,启动类上的@SpringBootApplication已经使用@ComponentScan作为元注解
    • 在Spring Boot项目中,默认的组件扫描的包就是启动类所在的包
    • 任何组件扫描,都会扫描指定的包及其子孙包
  2. 在组件类上,需要添加组件注解:基础的组件注解有:
    • @Component:通用组件注解
    • @Controller:建议添加在控制器类上
    • @Service:建议添加在处理业务逻辑的类上
    • @Repository:建议添加在数据访问层的类上
      • 在使用IntelliJ IDEA时,在Mybatis的Mapper接口上添加此注解,只是为了避免IntelliJ IDEA误判而已,并没有实质的作用
    • 以上4种注解,在Spring的解释范围内,是完全等效的,只是语义不同

另外,如果某个组件扫描范围内的类添加了@Configuration,也会被创建对象,添加此注解的类被视为“配置类”,与一般的组件不同,Spring框架会通过CGLib代理模式进行处理。

Spring管理的对象

Spring管理的对象,默认是单例的!并且,默认情况下,单例的对象都是默认加载的。

单例:单一实例,在某个时间点,此类的对象最多只有1个。

默认加载:加载Spring时,就会创建此类的对象,类似于单例模式中的饿汉式单例模式。

注意:Spring并没有使用单例模式,只是Spring管理的对象的表现与单例模式的特点是相同的。

使用@Scope注解,注解参数值配置为prototype(原型),可以使得Spring管理的对象不是单例的:

  • 使用组件扫描创建对象时,在组件类上添加@Scope("prototype")
  • 使用@Bean方法创建对象时,在@Bean方法上添加@Scope("prototype")

使用@Lazy注解,可以使得Spring管理的单例对象是懒加载的(第1次获取此对象时才创建对象):

  • 使用组件扫描创建对象时,在组件类上添加@Lazy
  • 使用@Bean方法创建对象时,在@Bean方法上添加@Lazy

Spring管理的对象的生命周期

被Spring管理的对象的类型,可以自定义2个生命周期方法,这2个方法会分别在“创建后”和“销毁前”被自动调用!

在自定义类中,可以自定义方法:

  • 访问权限:应该使用public
  • 返回值类型:void
  • 方法名称:自定义
  • 参数列表:仅可添加少量特定的类型

另外,在“创建后”的方法上,需要添加@PostConstruct注解,在“销毁前”的方法上,需要添加@PreDestroy注解,例如:

@PostConstructpublic void init() {}@PreDestroypublic void destroy() {}

自动装配

Spring容器:Spring的本质是一个容器,它会将它创建的所有对象都管理在此容器中。

Spring Bean:每个被Spring创建的对象都是一个Spring Bean。

自动装配:当某个添加了自动装配注解的属性,或某个被Spring自动调用的方法的参数需要值时,Spring会自动尝试从容器中查找适合的Spring Bean,用于为此赋值。

通常,其表现就是在类的属性上添加@Autowired注解,则Spring会尝试自动为此属性赋值。

关于@Autowired的装配机制:

  • 首先,在Spring容器中查找匹配类型的Spring Bean的数量

    • 0个:取决于@Autowired注解的required属性

      • required = true:加载Spring时出现NoSuchBeanDefinitionException
      • required = false:放弃自动装配,则属性值为null
    • 1个:直接装配,且成功
    • 多个:将尝试根据名称来自动装配,要求被自动装配的属性名与Spring Bean的名称是匹配的,如果存在匹配的,则成功装配,否则,加载Spring时出现NoUniqueBeanDefinitionException
      • 关于名称匹配,可以是属性名改为某个Spring Bean名称,或在属性上添加@Qualifier注解来指定某个Spring Bean的名称

另外,在不使用@Autowired(含匹配的@Qualifier)的情况下,也可以在属性上添加@Resource注解来实现自动装配!

关于@Resource注解的装配机制:

  • 先尝试根据名称查找匹配的Spring Bean,且类型也匹配,则自动装配,如果没有匹配名称的Spring Bean,将尝试按照类型来装配,简单来说,是先根据名称,再根据类型的装配机制。

关于DI与IoC

IoCInversion OControl,控制反转,表示将对象的控制权(创建、管理)交给框架

DIDependency Injection,依赖注入,表现为给对象的依赖属性赋值

Spring AOP

AOP:Aspect Oriented Programming,面向切面编程

注意:AOP并不是Spring框架独有的技术或特点,即使没有使用Spring框架,也可以实现AOP,但是,Spring框架很好的支持了AOP,所以,通常会使用Spring来实现AOP。

在开发实践中,数据的处理流程大致是:

注册:客户端 <---(请求)---> Controller <------> Service <------> Mapper登录:客户端 <---(请求)---> Controller <------> Service <------> Mapper下单:客户端 <---(请求)---> Controller <------> Service <------> Mapper

假设,现在添加一个需求:统计每个业务(Service中的方法)的执行耗时。

在没有AOP的情况下,只能编辑每个Service方法,添加几乎相同代码来实现以上需求,并且,当需求发生变化时,每个Service方法可能需要再次调整。

使用AOP实现以上需求,大致需要:

创建切面类,并交给Spring框架管理

配置切面类中的方法在特定的点执行

在项目中添加spring-boot-starter-aop依赖。

在项目的根包下创建aop.TimerAspect类,在类上添加@Component和@Aspect注解

@Component
@Aspect
public class TimerAspect {// 【切面中的方法】// 访问权限:public// 返回值类型:当使用@Around时,使用Object类型// 方法名称:自定义// 参数列表:当使用@Around时,添加ProceedingJoinPoint类型的参数// 异常:当使用@Around时,抛出Throwable// @Before:在……之前// @After:在……之后// @AfterReturning:在返回之后// @AfterThrowing:在抛出异常之后// @Around:环绕// 关于以上注解,大致是:// @Before// try {//   pjp.proceed();//   @AfterReturning// } catch (Throwable e) {//   @AfterThrowing//   throw e;// } finally {//   @After// }// 注解中的表达式用于匹配某些方法,在表达式中,应该表示出“返回值类型 包.类.方法(参数列表)”// 以表达式中,可以使用星号(*)和2个连续的小数点(..)作为通配符// 其中,星号可以用于:返回值类型、包名、类名、方法名、参数// 而2个连续的小数点可以用于:包、参数// 星号只表示匹配1次,而2个连续的小数点表示匹配0~n次// 连接点:JoinPoint,表现为切面需要处理的某个方法,或其它的某种行为// 切入点:Point Cut,写在@Around等注解中的表达式// 通知:Advice,即@Around等注解及对应的代码// 切面:Aspect,囊括了切入点和通知的模块@Around("execution(* cn.tedu.csmall.product.service.impl.*.*(..))")public Object timer(ProceedingJoinPoint pjp) throws Throwable {// 【需求】统计每个Service方法的耗时log.debug("在某个Service的某个方法之前执行了……");long start = System.currentTimeMillis();// 执行(处理)连接点,即执行业务方法// 注意:必须获取调用proceed()方法的返回值// 注意:如果连接点是Service中的方法,调用proceed()时的异常必须声明抛出,不可以try...catchObject result = pjp.proceed();long end = System.currentTimeMillis();log.debug("当前切面匹配到的组件类:{}", pjp.getTarget());log.debug("当前切面匹配到的方法:{}", pjp.getSignature());log.debug("当前切面匹配到的方法的参数列表:{}", pjp.getArgs());log.debug("执行耗时:{}毫秒", end - start);// 注意:必须返回调用proceed()方法的结果return result;}}

小结

关于Spring框架,你应该:

  • 理解Spring框架的作用
  • 掌握使得Spring框架创建对象的2种方式
    • @Bean方法
    • 组件扫描
  • 理解Spring框架管理的对象的作用域
    • 默认具有单例的特点
    • 在单例的状态下,默认是预加载的
  • 了解Spring框架管理的对象的生命周期方法
  • 理解Spring框架的自动装配的特点,理解@Autowired的装配机制
  • Spring AOP

另外,尚未涉及的部分:

  • 读取.properties配置文件,管理项目中的环境变量Environment

Spring MVC框架

关于Spring MVC框架

Spring MVC是建立在Spring框架基础之上的。

Spring MVC主要解决了接收请求、响应结果的问题。

基础配置

在Spring Boot项目中,添加spring-boot-starter-web依赖项,即可添加Spring MVC框架所需的依赖!

提示:如果使用的不是Spring Boot项目,当需要使用Spring MVC框架时,需要添加的依赖项是spring-webmvc。

提示:只需要将原有的spring-boot-starter改为spring-boot-starter-web即可。

提示:在创建工程时,如果勾选了Web,本质上也是添加spring-boot-starter-web依赖项,另外,还会在src/main/resources下自动创建static和templates文件夹。

一旦添加了spring-boot-starter-web,当启动Spring Boot项目时,会自动启动Tomcat,并将此项目部署到Tomcat上。

Spring Boot启动Tomcat时,默认占用8080端口,可以在application.properties中通过server.port属性来修改端口号,例如:

# 服务端口

server.port=9080

关于接收请求

需要自定义类,在类上添加@Controller / @RestController注解,则此类就是控制器类。

通常,建议在类上也添加@RequestMapping配置请求路径中的前缀部分。

在类中自定义处理请求的方法:

  • 注解:添加@RequestMapping系列注解来配置请求路径和某些参数
  • 访问权限:应该使用public
  • 返回值类型:在前后端分离的开发模式下,应该使用自定义的数据类型,例如JsonResult
  • 方法名称:自定义
  • 参数列表:当请求参数数量只有1个时,或少量参数且没有相关性,直接写,当请求参数数量超过1个且具有相关性时,应该封装为自定义的数据类型,并使用自定义的数据类型作为参数

关于接收的请求参数

如果要求客户端提交的请求参数是JSON格式的,则处理请求的方法的参数列表中,封装的数据类型必须添加@RequestBody注解,如果要求提交的请求参数是FormData格式的,则不可以添加@RequestBody注解。

如果某个请求参数是URL的一部分,在使用@RequestMapping系列注解配置请求路径时,需要使用{}格式的占位符,例如/albums/{id}/delete,并且,在处理请求的方法的相关参数上,添加@PathVariable注解。

另外,还可以在请求参数上添加@RequestParam,此注解可以指定请求参数名称、限制必须提交、配置默认值,但此注解并不是必须的。

关于RESTful

RESTful是一种服务器端软件的设计风格。

RESTful的典型表现是:在URL中会存在某些具有唯一性的参数值,例如id、用户名等。

RESTful还建议根据对数据操作的方式不同,使用不同的请求方式,例如删除数据时应该使用DELETE这种请求方式,但,通常仍只使用GET和POST。

RESTful只是一种风格,并不是设计规范。

处理响应

在前后端分离的开发模式下,响应方式都是“响应正文”的,可以:

  • 在方法上添加@ResponseBody
  • 在类上添加@ResponseBody
  • 在类上添加@RestController

以上3种方式均可。

通常,为了保证服务器端响应结果的格式是统一的,会自定义数据类型,封装需要响应的数据,至少包括:

  • 业务状态码
  • 错误时的提示文本
  • 成功时的数据

当处理请求的方法的返回值类型是以上封装的类型(即项目中的JsonResult)时,且当项目中已经添加了jackson-databind依赖时,此依赖项中的Converter会自动将方法返回的对象转换为JSON格式并响应到客户端去。

统一处理异常

自定义统一处理异常的类,在类上添加@ControllerAdvice / @RestControllerAdvice注解。

在类中自定义处理异常的方法:

  • 注解:@ExceptionHandler
  • 访问权限:应该使用public
  • 返回值类型:参考处理请求的方法
  • 方法名称:自定义
  • 参数列表:至少有1个被处理的异常类型参数,可按需添加特定类型的参数,例如HttpServletRequest、HttpServletResponse等,但不可以像处理请求的方法那么自由

在同一个项目中,可以有多个统一处理异常的类,每个类中都可以有多个处理异常的方法,只要这些方法处理的异常不完全相同(各方法处理的异常允许存在继承关系)即可。

关于MVC

MVC = Model + View + Controller

MVC为设计软件提供了基本的思想,它认为每个软件都应该至少包含这3大部分,且各部分分工明确,只负责整个数据处理流程中的一部分功能。

例如V通常表现为“软件的界面”,用于呈现数据、提供用户操作的控件。

C表示控制器,用于接收请求、响应结果,并不会处理实质业务。

M表示数据模型,通常由业务逻辑和数据访问这2部分组成,在开发实践中,数据访问通常指的就是数据库编程,而业务逻辑是由专门的类来实现的,这样的类通常使用Service作为类名的关键字。

在整个数据处理过程中,将会是:Controller调用Service,而Service调用Mapper。

业务逻辑的主要职责是:设计业务流程,处理业务逻辑,以保证数据的完整性和安全性。

开发Service

Service的开发规范是先写接口,再写实现类。

通常,会在项目的根包下创建service子包,Service接口将存放在这个包中,并且,还会在service包下创建impl子包,Service实现类都将放在这个包中,实现类都会使用ServiceImpl作为类名的后缀。

例如:在项目的根包下创建service.IAlbumService接口,然后,再创建service.impl.AlbumServiceImpl类,且此类将实现IAlbumService接口。

为了保证项目启动时可以正确的创建此实现类,需要类上添加@Service注解。

Spring MVC的核心处理流程

Spring MVC的核心组件:

  • DispathcerServlet:用于统一接收请求,并分发
  • HandlerMapping:记录了请求路径与处理请求的控制器组件的对应关系
  • Controller:实际请求的组件
  • ModelAndView:封装了数据与视图名称的结果
  • ViewResolver:根据视图名称确定实际应用的视图组件

Spring MVC拦截器

关于拦截器

  • 拦截器:Interceptor
  • Spring MVC拦截器是Spring MVC框架中的一种组件,它可以执行在若干个请求之前、之后,通常用于解决处理若干个请求都需要执行的任务,例如验证用户是否已经登录等。

使用拦截器

  • 使用Spring MVC拦截器,首先,需要自定义类,作为拦截器类,这个类必须实现HandlerInterceptor接口,例如:

    @Slf4j
    @Component
    public class DemoInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {log.debug("DemoInterceptor.preHandle()");return false;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {log.debug("DemoInterceptor.postHandle()");}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {log.debug("DemoInterceptor.afterCompletion()");}}
  • 每个拦截器都必须注册后才可以生效,在Spring MVC的配置类(实现了WebMvcConfigurer接口的配置类)中重写addInterceptors()方法即可实现注册,例如:
    @Autowired
    private DemoInterceptor demoInterceptor;@Override
    public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(demoInterceptor).addPathPatterns("/brands", "/categories/list-by-parent");
    }
  • 通过测试运行,可以发现,拦截器的3个方法:
  • preHandle():在控制器(Controller)之前执行,此方法的返回值是boolean类型的,当返回true时表示“放行”,当返回false时表示“阻止”,当阻止时,程序不会向后继续运行,例如控制器将不会执行
  • postHandle():在控制器(Controller)之后执行
  • afterCompletion():在处理完整个浏览,即将向客户端进行响应之前执行

配置拦截路径

  • 在配置拦截路径时,可以使用星号(*)作为通配符,但是,只能匹配1层路径,例如:使用/brands/*可以匹配/brands/add-new、/brands/list,却不可以匹配到/brands/1/delete。
  • 如果要匹配若干层路径,需要使用2个连续的星号(**),例如:使用/brands/**,可以匹配到/brands/add-new、/brands/list、/brands/1/delete、/brands/1/status/disable……
  • 一旦使用通配符,可能导致匹配的范围过大,例如:配置为/admins/**,将匹配到/admins/change-password、/admins/upload-avatar等,还会匹配到/admins/login等,如果此拦截器是用于验证“是否登录”的,将/admins/login也拦截是不合适的!
  • 在配置拦截路径时,还可以调用excluedePathPatterns()方法,在已有的拦截范围中添加“排除在外”的请求路径,例如:
    @Override
    public void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(demoInterceptor).addPathPatterns("/admins/**").excludePathPatterns("/admins/login");
    }
  • 提示:以上addPathPatterns()和excludePathPatterns()这2个方法的参数都可以是可变参数,或List集合。

拦截器与过滤器的区别

  • 以上讨论的拦截器(Interceptor)是Spring MVC框架中的组件,而过滤器(Filter)是Java EE中的组件。
  • 过滤器是最早接收到客户端请求的组件,是执行在所有组件之前的,而拦截器是执行在Spring MVC的控制器(Controller)之前和之后的。
  • 基于以上特点,某些问题只能通过过滤器来解决,例如:
  • 设置字符编码
  • Spring Security的相关过滤器

过滤器只能配置“黑名单”,不可以配置“白名单”,所以,使用时并不是那么方便,而拦截器的配置更加灵活

小结

关于Spring MVC框架,你应该:

  • 理解Spring MVC框架的作用
  • 掌握使用控制器接收请求,并响应结果
  • 理解JsonResult这种封装响应数据的类型的作用、设计思路
  • 掌握处理异常
  • 理解RESTful

Mybatis框架

关于Mybatis框架

Mybatis框架主要实现了简化持久层编程的问题。

持久层:实现数据持久化的一系列组件。

数据持久化:通常,在开发领域中,讨论的数据大多是在内存中的,而内存默认特指内存条(RAM:Random Access Memory),RAM的特性包含“一旦断电,数据将全部丢失”,且“正在执行的程序和数据都是在内存中的”,由程序处理的数据最终应该永久的保存下来,则不能将这些数据一直只存储在内存中,通常,会将数据存储到可以永久保存数据的存储介质中,典型的永久存储数据的存储介质有:硬盘、U盘、光盘等,所以,数据持久化就是将内存中的数据存储到硬盘等介质中,而硬盘中的数据是以文件的形式存在的,所以,通常可以将数据存储到文本文件中、XML文件、数据库中,这些存储方案中,只有数据库是便于实现增、删、改、查这4种操作的,所以,一般“数据持久化”默认指的就是将数据存储到数据库中。

在Java语言中,实现数据库编程需要先建立与数据库的连接,然后准备SQL语句,然后执行,然后获取执行结果并处理结果,最后,关闭或归还数据库连接,这是一套非常固定的流程,无论对哪个数据表执行哪种操作,其流程大致是固定的,所以,就产生了一些框架,用于简化这部分的编程。

依赖项

通常,使用Mybatis框架时,需要添加依赖:

  • mybatis:Mybatis框架
  • mybatis-spring:Mybatis整合Spring
  • mysql-connector-java:数据库依赖项
  • spring-jdbc:Spring整合JDBC

除此以外,通常还会添加:

  • spring-test:执行测试
  • commons-dbcp / druid等:数据库连接池

在Spring Boot项目中,只需要添加mybatis-spring-boot-starter和mysql-connector-java即可,测试时,另外添加spring-boot-starter-test。

基本配置

在Spring Boot项目中,在application.properties中配置:

  • spring.datasource.url
  • spring.datasource.username
  • spring.datasource.password

然后,需要在配置类上使用@MapperScan指定Mapper接口所在的包,在启动项目时,Mybatis会扫描此包,并找到相关的接口,自动生成这些接口的代理对象。

另外,还需要在application.properties中配置mybatis.mapper-locations属性,用于指定XML文件的位置。

使用Mybatis操作数据

Mybatis要求抽象方法必须存在于接口中(因为其实现原理是基于接口的代理模式),所以,在项目的根包下创建mapper.AlbumMapper接口。

提示:可以在接口上添加@Repository注解,避免在自动装配时IntelliJ IDEA误判而提示错误。

关于接口中的抽象方法:

  • 返回值类型:如果需要执行的SQL是增、删、改类型的,使用int作为返回值类型,表示“受影响的行数”,不建议使用void
  • 方法名称 :自定义,不要使用重载
    • 获取单个对象的方法用get做前缀
    • 获取多个对象的方法用list做前缀
    • 获取统计值的方法用count做前缀
    • 插入的方法用save/insert做前缀
    • 删除的方法用remove/delete做前缀
    • 修改的方法用update做前缀
  • 参数列表:如果需要执行的SQL语句有多个参数,应该将这些参数封装到自定义的数据类型中,并使用自定义的数据类型作为抽象方法的参数

首次使用时,需要让Mybatis知道哪些接口是Mapper接口,可以(二选一):

  • 在每个接口上添加@Mapper注解
  • 配置类上添加@MapperScan并指定Mapper接口所在的包
    • 在根包下的任何类,添加了@Configuration注解,即是配置类
    • 可以在根包下创建config.MybatisConfiguration类,同时添加@Configuration和@MapperScan("cn.tedu.csmall.product.mapper")即可

另外,在使用Mybatis时,还需要为每个抽象方法配置其映射的SQL语句,可以使用@Insert等注解来配置SQL语句,但不推荐,因为:

  • 不便于配置较长的SQL语句
  • 不便于做一些复杂的配置,特别是查询时
  • 不便于实现与DBA(Database Administrator)分工合作

建议的做法是使用XML文件来配置SQL语句,可以从 http://doc.canglaoshi.org/config/Mapper.xml.zip 下载得到所需的文件,然后,在项目的src/main/resources下创建mapper文件夹,将得下载、解压得到的XML文件复制到此文件夹中。

关于配置SQL的XML文件:

  • 根节点必须是<mapper>
  • 在<mapper>上必须配置namespace属性,取值为对应的接口的全限定名
  • 在<mapper>的子级,根据需要执行的SQL语句,选择使用<insert>、<delete>、<update>、<select>中的某个节点,准备配置SQL语句,这些节点必须配置id属性,取值为抽象方法的名称(不包括括号和参数列表),并在这些节点内部配置SQL语句
  • 首次使用时,需要在application.properties中配置以上XML文件的位置:
    # Mybatis的配置SQL的XML文件的位置
    mybatis.mapper-locations=classpath:mapper/*.xml

插入数据时获取自动编号的id

在配置<insert>节点时,配置useGeneratedKeys和keyProperty这2个属性,就可以得到自动编号的id,例如:

<!-- int insert(Album album); --><insert id="insert" useGeneratedKeys="true" keyProperty="id"><!-- 省略后续代码 -->

其中,useGeneratedKeys="true"表示“需要获取自动编号的键的值”,keyProperty="id"表示将得到的自动编号的id值放回到参数对象的id属性中去!

开发规范上,对于自动编号的表进行插入数据时,都应该配置这2个属性!

MyBatis的动态SQL--foreach

动态SQL:根据参数值的不同,将生成不同的SQL语句。

假设存在需求:根据若干个id删除相册数据,即批量删除。

需要执行的SQL语句大致是:

delete from pms_album where id=? or id=? or id=? ...

或者:

delete from pms_album where id in (?, ?, ?, ... ?);

当实现以上功能时,关于抽象方法,可以设计为:

int deleteByIds(Long[] ids);

或者:

int deleteByIds(Long... ids);

或者:

int deleteByIds(List<Long> ids);

在配置SQL时,需要使用到<foreach>节点对参数进行遍历:

<!-- int deleteByIds(Long[] ids); --><delete id="deleteByIds">DELETE FROM pms_albumWHERE id IN (<foreach collection="array" item="id" separator=",">#{id}</foreach>)</delete>

关于<foreach>节点的配置:

  • collection属性:当抽象方法的参数只有1个且没有添加@Param注解时,当参数是数组类型时(包括类型为可变参数时),此属性取值为array,当参数是List集合类型时,此属性取值为list
  • item属性:遍历过程中的每个元素的变量名,是自定义的名称
  • separator属性:遍历过程中各元素之间的分隔符号

使用Mybaits修改数据

通常,修改数据时,也会使用到动态SQL的机制,当传入某个字段对应的值时,SQL中才会包含修改此字段的部分,反之,如果没有传入某个字段对应的值,则SQL语句不会包含修改此字段的部分!

这样的功能可以通过动态SQL的<if>标签来实现!

假设需要实现修改相册数据,传入的参数中包含哪些数据,就修改哪些数据,不包含的部分将不会被修改。

在AlbumMapper接口中添加抽象方法:

int update(Album album);

在AlbumMapper.xml中配置SQL语句:

<!-- int update(Album album); --><update id="update">UPDATE pms_album<set><if test="name != null">name=#{name},</if><if test="description != null">description=#{description},</if><if test="sort != null">sort=#{sort},</if></set>WHERE id=#{id}</update>

使用Mybatis查询--统计

假设需要实现:统计相册表中的数据的数量

需要执行的SQL语句大致是:

select count(*) from pms_album

关于抽象方法:在查询时,方法的返回值类型只要求能够存入查询结果即可。

则在AlbumMapper中添加抽象方法:

int count();

在AlbumMapper.xml中配置SQL语句,将使用<select>节点,此节点必须配置resultType或resultMap这2个属性中的某1个。

当使用resultType时,此属性的值取决于抽象方法的返回值类型,如果是基本数据类型(例如int等),则resultType属性的值就是类型名,如果是引用数据类型(例如String、Album等),则resultType属性的值就是类型的全限定名(在java.lang包下的可以省略包名)。

Mybatis在封装查询结果时,会自动的将列名(Column)属性名(Property)匹配的结果进行封装,例如查询结果中的name值将封装到返回值对象的name属性中去,对于名称不匹配的,将放弃。可以在配置SQL时,为查询的字段自定义列名,使得“查询结果中的列名”与“封装结果的类型中的属性名”是一致的。除了以上做法以外,还可以在application.properties中添加配置,使得Mybatis能自动处理“全小写且使用下划线分隔的字段名对应的列名”与“驼峰命名法的属性名”之间的对应关系(例如此做法时,不必在查询时自定义别名):

mybatis.configuration.map-underscore-to-camel-case=true。或者,还可以选择自定义ResultMap,用于指导Mybatis如何封装查询结果

关于抽象方法的返回值类型,原则上,只需要能够“放得下”就行,所以,可以使用Album作为此次查询的返回值类型,但是,并不建议这样处理!通常,建议另创建类型,用于封装查询结果!另外创建的类型,通常并不会称之为实体类,并且,这种类型会添加一些后缀,关于后缀的使用,阿里的文档的参考

  • 数据对象:xxxDO,xxx 即为数据表名
  • 数据传输对象:xxxDTO,xxx 为业务领域相关的名称
  • 展示对象:xxxVO,xxx 一般为网页名称
  • POJO 是 DO/DTO/BO/VO 的统称,禁止命名成 xxxPOJO

关于以上后缀:

  • DO:Data Object
  • DTO:Data Transfer Object
  • VO:View Object / Value Object
<!-- int count(); --><select id="count" resultType="int">SELECT count(*) FROM pms_album</select><select id="xxx" resultMap="自定义的ResultMap名称"></select><resultMap id="自定义的ResultMap名称" type="封装查询结果的类型的全限定名"><result column="product_count" property="productCount" /><result column="comment_count" property="commentCount" /><result column="positive_comment_count" property="positiveCommentCount" /></resultMap>

另外,在开发实践中,建议将查询的字段列表使用<sql>节点进行封装,然后,在配置的SQL语句中,使用<include>节点进行调用即可:

<!-- BrandStandardVO getStandardById(Long id); --><select id="getStandardById" resultMap="StandardResultMap">SELECT<include refid="StandardQueryFields"/>FROM pms_brandWHERE id=#{id}</select><sql id="StandardQueryFields">id, name, pinyin, logo, description,keywords, sort, sales, product_count, comment_count,positive_comment_count, enable</sql><resultMap id="StandardResultMap" type="cn.tedu.csmall.product.pojo.vo.BrandStandardVO"><result column="product_count" property="productCount" /><result column="comment_count" property="commentCount" /><result column="positive_comment_count" property="positiveCommentCount" /></resultMap>

查询列表与查询某1个数据的开发过程相差不大,主要区别在于:

  • 查询列表时,需要查询的字段通常更少
  • Mybatis会自动使用List集合来封装查询到的多个数据,所以,抽象方法的返回值类型必须是List类型的

注意:

即使是查询列表,无论使用resultType,还是配置<resultMap>,关于数据类型,都只需要指定为List中的元素类型即可!

如果执行的查询的结果可能超过1条(即2条或以上),必须显式的指定order by进行排序!

使用Mybatis实现数据库编程

使用Mybatis实现数据库编程主要:

  • 设计Mapper接口中的抽象方法
  • 配置抽象方法映射的SQL语句

关于抽象方法:

  • 返回值类型:增删改使用int,查询只需要保证返回值类型足够装得下查询结果即可
  • 方法名称:自定义,建议参考阿里的规范
  • 参数列表:取决于需要执行的SQL语句中的参数,如果SQL语句中只有1个参数,直接声明为方法的参数即可,如果有多个参数,且这些参数具有相关性,则应该封装,并使用封装的类型作为方法的参数,如果多个参数没有相关性,则一一声明为方法的参数,并且,为每个参数添加@Param注解
    • 在某些集成环境中,多个参数也可以不添加@Param

关于配置SQL语句:

  • 可以使用@Insert等节点配置SQL语句,但是不推荐,推荐使用XML文件来配置SQL语句
  • 必须在<mapper>上配置namespace属性,用于指定对应的接口
  • 使用<insert>等节点配置SQL语句,每个节点必须配置id属性,用于指定对应的抽象方法
  • 在配置<insert>时,如果表的id是自动编号的,则应该配置useGeneratedKeys和keyProperty属性,以获取自动编号的id
  • 在配置<select>时,必须配置resultMap或resultType这2个属性中的某1个
  • 可以使用<sql>节点封装SQL语句片段,并使用<include>节点进行引用,通常,使用<sql>封装字段列表
  • 使用<resultMap>节点用于指导Mybatis封装查询结果
  • 使用动态SQL的<foreach>可以实现对数组或List集合类型的参数的遍历
  • 使用动态SQL的<if>可以实现根据参数决定SQL语句中是否包含某个片段,用于处理更新数据的操作时,通常结合<set>节点一起使用
    • 注意:<if>并没有匹配的类似else的节点,如果要实现if...else效果,可以使用2个条件完全相反的<if>,但是,这种做法效率偏低,另外,可以使用<choose>系列节点来实现:

      <choose>
      <when test="条件">满足条件时的SQL语句片段</when><otherwise>不满足条件时的SQL语句片段</otherwise>
      </choose>

关于utf8mb4

utf8mb4是MySQL / MariaDB中的一种字符集。

在当前主流版本的MySQL / MariaDB中,使用utf8作为字符集时,默认表示的是utf8mb3。

关于utf8mb3和utf8mb4,其主要区别在于:most bytes 3和most bytes 4,即最多使用3 / 4个字节来表示1个字符!所以,当使用utf8mb4时,可以表示更多字符,例如生僻汉字、冷门符号、emoji表情符号等。

UTF指的是:Unicode Transfer Format,即Unicode传输编码。

在使用MySQL / MariaDB时,所有SQL语句中涉及的字符集都明确的使用utf8mb4,而不要使用utf8

Mybatis中的#{}占位符

在Mybatis中配置SQL时,可以使用#{}格式的占位符来表示SQL语句中的参数,在占位符的大括号中,当抽象方法只有1个基本值(基本数据类型对应的值,和String)参数时,占位符名称是完全无所谓的。如果抽象方法的参数只有1个,但不是基本值时,在#{}的大括号里,必须写参数的数据类型的属性名。

如果抽象方法的参数有多个,在非Spring Boot的集成环境下,抽象方法的每个参数必须使用@Param注解来配置参数名称

int updatePasswordByUserId(@Param("userId") Long userId,@Param("password") String password);

之所以需要使用@Param注解来配置名称,是因为编译期会丢失局部的变量的名称(这是Java语言的特点),

主流的Spring Boot的集成环境下,即使抽象方法有多个参数,也可以不使用@Param注解来指定参数的名称,是因为在这样的集成环境下,Spring框架会对编译过程进行干预,从而保留抽象方法的参数名称,以至于在.class文件中是存在参数的名称的,所以,可以不使用@Param。(事实上,在Spring MVC的控制器中,Spring MVC框架也是做了这样的处理的)

在开发实践中,无论使用的是Spring Boot集成环境,还是没有Spring Boot的环境,都应该在多参数时使用@Param注解以配置参数的名称!

其它问题

Mybatis中#{}格式的占位符与${}格式的占位符的区别

当使用#{}占位符时,SQL语句会进行预编译处理,所以,不存在SQL注入的问题,#{}格式的占位符只能表示某个值,不能表示SQL语句中的某个片段,不需要关注参数值的数据类型

预编译:不代入值的情况下,执行编译,后续执行时再将值代入。

当使用${}占位符时,会先将值拼接到SQL语句中,再执行编译相关流程,所以,存在SQL注入的风险,${}格式的占位符可以表示SQL语句中的任何片段,不仅仅只是某个值而已,只需要保证将值代入后拼接得到的SQL语句是合法的即可,但是,对于非数值类型(字符串、时间等)的值,需要使用一对单引号框住参数值

Mybatis的缓存机制

缓存(Cache):将原本需要查询的数据暂时存储到其它更易于读取的位置,并且,在后续查询数据时,从新的位置获取数据。

例如:通常查询数据是从数据库(例如MySQL等)位置进行查询,但是,MySQL的查询数据的效率其实很低!使用缓存的做法,可以是将前序的查询结果保存下来(不销毁),当下次再次查询同样的数据时,直接将此前保存下来的结果返回出去即可!

提示:关于将前序的查询结果保存下来,可以保存到应用服务器上,也可以保存在其它能够高效获取数据的位置。

Mybatis框架内置了缓存机制,分别是一级缓存(L1 Cache)和二级缓存(L2 Cache)。

**关于一级缓存:**通常也称之为Session缓存,或会话缓存,它是基于Mybatis的Session机制的,是默认开启的,人为不可控。

一级缓存的特点:必须是同一个会话(SqlSession)、同一个Mapper、执行同样的查询、查询的参数相同,则后续的查询会直接使用前序的查询结果,并不会反复执行查询!

一级缓存还有一些特点:如果更换SqlSession,则会重新查询,如果SqlSession关闭或调用了clearCache()方法,则缓存数据会清空,或者,此表的数据发生了任何写(增删改)操作,缓存数据也会清空!

**关于二级缓存:**通常也称之为namespace缓存,在Spring Boot整合Mybatis的项目中,默认是全局开启,但各namespace默认未开启的!

二级缓存的特点:无论是否同一会话,只要是同一个namespace中的多次查询,均可应用二级缓存,Mybatis在查询数据时,会先检查二级缓存,如果命中,将直接返回结果,如果未命中,则检查一级缓存,如果命中则返回结果,如果仍未命中,则连接数据库执行查询。

二级缓存的使用:需要在配置SQL的XML文件中添加<cache/>节点,表示开启当前namespace的缓存。

如果同一个namespace执行了任何写操作,都会导致二级缓存数据被清空!

注意:使用二级缓存时,用于封装查询结果的类型必须实现Serializable接口,否则查询时将出现异常!

另外,一旦使用了<cache/>,则当前namespace中所有的查询都是开启了二级缓存的,如果部分查询功能并不需要开启二级缓存,还可以在<select>节点上配置useCache="false"。

结论:无论是一级缓存,还是二级缓存,都会因为发生了写操作而自动清空,这种机制通常并不满足生产环境的需求,所以,一般不会使用Mybatis的缓存机制!

小结

1、需要掌握以上所有内容

2、补充:Mybatis的xml中的比较运算符

lt(less than):   小于

gt(great than);   大于

le(less equals)  小于等于

ge(great equals)  大于等于

eq  等于

ne(not equals)  不等于

Spring Boot框架

关于Spring Boot框架

Spring Boot是一个基于Spring框架在的Maven项目,每个自行创建的Spring Boot项目都使用了官方的Spring Boot项目作为父级项目!

Spring Boot是一个基于“约定大于配置”思想的、自动完成了许多配置的框架。

Spring Boot框架的基础依赖项是spring-boot-starter,而其它以spring-boot-starter为Artifact前缀的依赖项都包含了它。

关于基础依赖项

在spring-boot-starter中,包含的典型依赖项有:

  • Spring框架的基础依赖项:spring-context
  • 日志

所以,任何一个Spring Boot项目,都可以使用Spring框架的特性,并且可以使用日志。

并且,在Spring Boot项目中,默认在src/main/resource下就有application.properties文件,是项目中默认自动读取的配置文件。

关于启动类

每个创建好的Spring Boot项目的src/main/java下都有一个默认的包,且包下有一个带main()方法的类,此类就是整个项目的启动类,执行此类的main()方法将启动整个项目。

关于Profile配置

在Spring Boot项目中,在src/main/resources下,默认就存在application.properties文件,此文件是Spring Boot会自动读取的配置文件

关于application.properties配置,在不同的环境下,某些配置的值应该是不同的,例如连接数据库的URL、用户名、密码等,所以,应该针对不同的环境,使用不同的配置,即Profile配置。

通常,关于Profile配置,至少分为3类:

  • 开发环境下的配置
  • 测试环境下的配置
  • 生产环境下的配置

甚至,同样是开发环境下,可能因为团队协作开发,各开发人员也使用了不同的配置。

使用Profile配置的方式是:

  • 自行创建application-xxx.properties文件,文件名中的xxx是自定义的名称,通常是dev、test、prod等,把各个环境下不同的配置编写在此文件中
  • 在application.properties中使用spring.profiles.active属性激活某个Profile配置,此属性的值就是application-xxx.properties文件中的xxx部分

当使用Profile配置后,这些配置文件默认并不会直接读取并应用,需要被激活才会被读取并应用!

在application.properties中的配置是始终被读取并应用的!

关于YAML配置

YAML配置是以.yml作为扩展名的配置文件。

Spring框架本身并不支持读取这类文件,需要额外添加依赖项,在Spring Boot项目中,默认已经集成必要的依赖项,可以直接读取这类文件。

在Spring Boot项目中,可以将.properties的配置完全转移到.yml文件中,并且,同样支持Profile配置,即可以同时存在application.yml、application-dev.yml等。

关于YAML配置,其语法特征是:

  • 原有的例如spring.datasource.url这类属性,将根据小数点拆分为2行,每一行使用冒号表示结束,从下一行开始,缩进2个空格(不可以是TAB,但是,IntelliJ IDEA在编辑YAML时,按下的TAB会自动转换成2个空格),当属性名写完后,在冒号右侧添加1个空格,再填写属性值

例如:

spring:

datasource:

url: jdbc:mysql://localhost:3306/mall_pms

username: root

password: root

Spring Security

关于Spring Security

Spring Security是主要解决认证(Authenticate)和授权(Authorization)的框架。

添加依赖

在Spring Boot项目中,添加spring-boot-starter-security依赖项。

注意:以上依赖项是带有自动配置的,一旦添加此依赖,整个项目中所有的访问,默认都是必须先登录才可以访问的,在浏览器输入任何此服务的URL,都会自动跳转到默认的登录页面。

默认的用户名是user,默认的密码是启动项目时自动生成的随机密码,在服务器端的控制台可以看到此密码。

当登录后,会自动跳转到此前尝试访问的页面。

Spring Security默认使用Session机制保存用户的登录状态,所以,重启服务后,登录状态会消失。在不重启的情况下,可以通过 /logout 访问“退出登录”页面,确定后也可以清除登录状态。

关于BCrypt

在Spring Security中,内置了BCrypt算法的工具类,此工具类可以实现使用BCrypt算法对密码进行加密、验证密码的功能。

BCrypt算法使用了随机盐,所以,多次使用相同的原文进行加密,得到的密文都将是不同的,并且,使用的盐值会作为密文的一部分,也就不会影响验证密码了。

@Slf4j
public class BCryptTests {BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();@Testvoid testEncode() {for (int i = 0; i < 10; i++) {String rawPassword = "123456";String encodedPassword = passwordEncoder.encode(rawPassword);log.debug("原文={}, 密文={}", rawPassword, encodedPassword);}}@Testvoid testMatches() {String rawPassword = "123456";String encodedPassword = "$2a$10$urORu6xPREon46gyqLztGO4AiQlFoul.W3wkFX3FjnLMQRqWnQ7sa";boolean result = passwordEncoder.matches(rawPassword, encodedPassword);log.debug("原文={}, 密文={}, 验证结果={}", rawPassword, encodedPassword, result);}}

在Spring Security框架中,定义了PasswordEncoder接口,表示“密码编码器”,并且使用BCryptPasswordEncoder实现了此接口。

通常,应该自定义配置类,在配置类中使用@Bean方法,使得Spring框架能创建并管理PasswordEncoder类型的对象,在后续使用过程中,可以自动装配此对象。

在根包下创建config.SecurityConfiguration类:

@Configurationpublic class SecurityConfiguration {@Beanpublic PasswordEncoder passwordEncoder() {return new BCryptPasswordEncoder();}}

然后,在需要使用此对象的类中,自动装配即可,例如,在AdminServiceImpl类中添加:

@Autowiredprivate PasswordEncoder passwordEncoder;

在此类中,就可以使用到以上属性,例如:

String rawPassword = admin.getPassword();String encodedPassword = passwordEncoder.encode(rawPassword);admin.setPassword(encodedPassword);

注意:一旦在Spring容器中已经存在PasswordEncoder对象,Spring Security会自动使用它,所以,会导致默认的随机密码不可用(你提交的随机密码会被加密后再进行对比,而Spring Security默认的密码并不是密文,所以对比会失败)。

对请求放行

在默认情况下,Spring Security要求所有的请求都是必须先登录才允许访问的,可以通过Spring Security的配置类对请求放行,即不需要登录即可直接访问。

具体的做法:

  • 使得当前SecurityConfiguration继承自WebSecurityConfigurerAdapter
  • 重写void configure(HttpSecurity http)方法,对特定的请求路径进行访问
package cn.tedu.csmall.passport.config;import lombok.extern.slf4j.Slf4j;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.WebSecurityConfiguration;import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;@Slf4j@Configurationpublic class SecurityConfiguration extends WebSecurityConfigurerAdapter {@Beanpublic PasswordEncoder passwordEncoder() {log.debug("创建密码编码器:BCryptPasswordEncoder");return new BCryptPasswordEncoder();}@Overrideprotected void configure(HttpSecurity http) throws Exception {http.authorizeRequests() // 要求请求必须被授权.antMatchers("/**")  // 匹配一些路径.permitAll() // 允许访问.anyRequest() // 除以上配置以外的请求.authenticated(); // 经过认证的}}

完成后,重启项目,各页面均可直接访问,不再要求登录!

注意:此时,任何跨域的异步请求不允许提交,否则将出现403错误。

接下来,还需要在以上配置方法中添加:

http.csrf().disable(); // 禁用防止伪造跨域攻击

如果没有以上配置,则所有的异步跨域访问(无论是否是伪造的攻击)都会被禁止,也就出现了403错误。

使用数据库中的用户名和密码

使用Spring Security时,应该自定义类,实现UserDetailsService接口,在此接口中,有UserDetails loadUserByUsername(String username)方法,Spring Security会自动使用登录时输入的用户名来调用此方法,此方法返回的结果中应该包含与用户名匹配的相关信息,例如密码等,接下来,Spring Security会自动使用自动装配的密码编码器对密码进行验证。

所以,应该先将“允许访问的路径”进行调整,然后,自定义类实现以上接口,并重写接口中的方法。

关于“允许访问的路径”,可以将“Knife4j的API文档”的相关路径全部设置为允许直接访问(不需要登录),并且,开启表单验证(使得未授权请求会自动重定向到登录表单),则配置为:

@Overrideprotected void configure(HttpSecurity http) throws Exception {// 请求路径白名单String[] urls = {"/favicon.ico","/doc.html","/**/*.js","/**/*.css","/swagger-resources/**","/v2/api-docs"};http.csrf().disable(); // 禁用防止伪造跨域攻击http.authorizeRequests() // 要求请求必须被授权.antMatchers(urls) // 匹配一些路径.permitAll() // 允许访问.anyRequest() // 除以上配置以外的请求.authenticated(); // 经过认证的http.formLogin(); // 启用登录表单,未授权的请求均会重定向到登录表单}

关于自定义的UserDetailsService接口的实现类:

package cn.tedu.csmall.passport.security;import org.springframework.security.core.userdetails.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.Service;@Servicepublic class UserDetailsServiceImpl implements UserDetailsService {@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {// 假设root是可用的用户名,其它用户名均不可用if ("root".equals(s)) {// 返回模拟的root用户信息UserDetails userDetails = User.builder().username("root").password("$2a$10$oxvr08D3W0oiesfGPZ8miuPy6kWGst6lz3.qZ29upo8yTjROWh4eC").accountExpired(false) // 账号是否已经过期.accountLocked(false) // 账号是否已经锁定.credentialsExpired(false) // 认证是否已经过期.disabled(false) // 是否已经禁用.authorities("这是临时使用的且无意义的权限值") // 权限,注意,此方法的参数值不可以为null.build();return userDetails;}throw new UsernameNotFoundException("登录失败,用户名不存在!");}}

完成后,重启项目,在启动日志将不会再出现随机的默认密码,并且,可以根据以上方法实现时的用户名+密码实现登录,如果使用错误的用户名或密码,将会提示对应的错误!

接下来,只需要保证以上方法中返回UserDetails是基于数据库查询来返回结果即可。

开发自定义的登录流程

目前,在passport项目中,登录是由Security框架提供的页面的表单来输入用户名、密码,且由Security框架自动处理登录流程,不适合前后端分离的开发模式!所以,需要自行开发登录流程!

关于自定义的登录流程,主要需要:

  • 在业务逻辑实现类中,调用Security的验证机制来执行登录认证
  • 在控制器类中,自定义处理请求,用于接收登录请求及请求参数,并调用业务逻辑实现类来实现认证

关于在Service中调用Security的认证机制:

当需要调用Security框架的认证机制时,需要使用AuthenticationManager对象,可以在Security配置类中重写authenticationManager()方法,在此方法上添加@Bean注解,由于当前类本身是配置类,所以Spring框架会自动调用此方法,并将返回的结果保存到Spring容器中:

@Bean@Overrideprotected AuthenticationManager authenticationManager() throws Exception {return super.authenticationManager();}

在IAdminService中添加处理登录的抽象方法:

void login(AdminLoginDTO adminLoginDTO);

在AdminServiceImpl中,可以自动装配AuthenticationManager对象:

@Autowired

private AuthenticationManager authenticationManager;

并实现接口中的方法:

@Overridepublic void login(AdminLoginDTO adminLoginDTO) {// 日志log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);// 调用AuthenticationManager执行认证Authentication authentication = new UsernamePasswordAuthenticationToken(adminLoginDTO.getUsername(), adminLoginDTO.getPassword());authenticationManager.authenticate(authentication);log.debug("认证通过!");}

在控制器中接收登录请求,并调用Service:

在根包下创建pojo.dto.AdminLoginDTO类:

@Datapublic class AdminLoginDTO implements Serializable {private String username;private String password;}在AdminController中添加处理请求的方法:@ApiOperation("管理员登录")@ApiOperationSupport(order = 50)@PostMapping("/login")public JsonResult<Void> login(AdminLoginDTO adminLoginDTO) {log.debug("准备处理【管理员登录】的请求:{}", adminLoginDTO);adminService.login(adminLoginDTO);return JsonResult.ok();}

为了保证能对以上路径直接发起请求,需要将此路径(/admins/login)添加到Security配置类的“白名单”中。

完成后,启动项目,可以通过Knife4j的调试来测试登录,当登录成功时将响应正确,当用户名或密码错误时,将响应错误(需要统一处理异常)。

注意:即使登录成功,也不可以实现其它请求的访问!

关于Session

HTTP协议本身是无状态协议,所以,无法识别用户的身份!

为了解决此问题,经编程时,引入了Session机制,用于保存用户的某些信息,可识别用户的身份!

Session的本身是在服务器端的内存中一个类似Map结构的数据,每个客户端在提交请求时,都会携带一个由服务器端首次响应时分配的Session ID,作为Map的Key,由于此Session ID具有极强的唯一性,所以,每个客户端的Session ID理论上都是不相同的,从而服务器可以识别客户端!

由于Session是保存在服务器端的内存中的,在一般使用时,并不适用于集群!

Token

Token:令牌,票据。

目前,推荐使用Token来保存用户的身份标识,使之可以用于集群!

相比Session ID是没有信息含义的,Token则是有信息含义的数据,当客户端向服务器端提交登录请求后,服务器商认证通过就会将此用户的信息保存在Token中,并将此Token响应到客户端,后续,客户端在每次请求时携带Token,服务器端即可识别用户的身份!

JWT

JWT = JSON Web Token

JWT是使用JSON格式表示一系列的数据的Token。

当需要使用JWT时,应该在项目中添加依赖:

<!-- JJWT(Java JWT) --><dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt</artifactId><version>0.9.1</version></dependency>

然后,通过测试,实现生成JWT和解析JWT。

@Slf4jpublic class JwtTests {// 密钥(盐)String secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";// 测试生成JWT@Testpublic void testGenerateJwt() {// 准备ClaimsMap<String, Object> claims = new HashMap<>();claims.put("id", 9527);claims.put("name", "liulaoshi");// JWT的组成部分:Header(头),Payload(载荷),Signature(签名)String jwt = Jwts.builder()// Header:用于声明算法与此数据的类型,以下配置的属性名是固定的.setHeaderParam("alg", "HS256").setHeaderParam("typ", "jwt")// Payload:用于添加自定义数据,并声明有效期.setClaims(claims).setExpiration(new Date(System.currentTimeMillis() + 3 * 60 * 1000))// Signature:用于指定算法与密钥(盐).signWith(SignatureAlgorithm.HS256, secretKey).compact();}@Testpublic void testParseJwt() {String jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJuYW1lIjoibGl1bGFvc2hpIiwiaWQiOjk1MjcsImV4cCI6MTY1OTkzOTUzMH0.lwD_PzrqGXEgQs3KmMjsYzTmhsKbGhKnd1WkDkFpj5M";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();Object id = claims.get("id");Object name = claims.get("name");log.debug("id={}", id);log.debug("name={}", name);}}

如果JWT数据已经过期,将出现错误:

io.jsonwebtoken.ExpiredJwtException: JWT expired at 2022-08-08T12:05:21Z. Current time: 2022-08-08T14:11:34Z, a difference of 7573854 milliseconds.  Allowed clock skew: 0 milliseconds.

如果JWT签名有误(JWT数据的最后一段出错,或生成与解析时使用的secretKey不同),将出现错误:

io.jsonwebtoken.SignatureException: JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.

如果JWT数据格式有误,将出现错误:

io.jsonwebtoken.MalformedJwtException: Unable to read JSON value: {"alg|b:"HS256","typ":"jwt"}

关于JWT在项目中的应用

生成JWT

应该在用户登录时,视为”认证成功“后,生成JWT,并将此数据响应到客户端。

在业务层,调用AuthenticationManager的authenticate()方法后,得到的返回结果例如:

UsernamePasswordAuthenticationToken [

Principal=org.springframework.security.core.userdetails.User [

Username=root,

Password=[PROTECTED],

Enabled=true,

AccountNonExpired=true,

credentialsNonExpired=true,

AccountNonLocked=true,

Granted Authorities=[权限列表]

],

Credentials=[PROTECTED],

Authenticated=true,

Details=null,

Granted Authorities=[权限列表]

]

可以看到,认证返回的数据中将包含成功认证的用户信息,也是当初用于执行认证的信息(UserDetailsServiceImpl中返回的结果),可以从此认证结果中获取用户相关数据,并写入到JWT中,则需要:

  • 将业务接口中的登录方法返回值类型改为String,表示认证成功后返回的JWT
  • 将业务实现类中的登录方法返回值一并修改
  • 在业务实现类中,当认证成功后,获取需要写入到JWT中的数据(例如:用户名等),并生成JWT,返回JWT

关于业务实现类的登录方法:

@Overridepublic String login(AdminLoginDTO adminLoginDTO) {// 日志log.debug("开始处理【管理员登录】的业务,参数:{}", adminLoginDTO);// 调用AuthenticationManager执行认证Authentication authentication = new UsernamePasswordAuthenticationToken(adminLoginDTO.getUsername(), adminLoginDTO.getPassword());Authentication authenticateResult = authenticationManager.authenticate(authentication);log.debug("认证通过,返回的结果:{}", authenticateResult);log.debug("认证结果中的Principal的类型:{}",authenticateResult.getPrincipal().getClass().getName());// 处理认证结果User loginUser = (User) authenticateResult.getPrincipal();log.debug("认证结果中的用户名:{}", loginUser.getUsername());// 生成JWTString secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";// 准备ClaimsMap<String, Object> claims = new HashMap<>();claims.put("username", loginUser.getUsername());// JWT的组成部分:Header(头),Payload(载荷),Signature(签名)String jwt = Jwts.builder()// Header:用于声明算法与此数据的类型,以下配置的属性名是固定的.setHeaderParam("alg", "HS256").setHeaderParam("typ", "jwt")// Payload:用于添加自定义数据,并声明有效期.setClaims(claims).setExpiration(new Date(System.currentTimeMillis() + 14 * 24 * 60 * 60 * 1000))// Signature:用于指定算法与密钥(盐).signWith(SignatureAlgorithm.HS256, secretKey).compact();log.debug("生成的JWT:{}", jwt);return jwt;}

在控制器中,将处理登录请求的方法的返回值类型改为JsonResult<String>,并在调用业务方法时获取返回值,封装到返回的对象中:

@ApiOperation("管理员登录")@ApiOperationSupport(order = 50)@PostMapping("/login")public JsonResult<String> login(AdminLoginDTO adminLoginDTO) {log.debug("准备处理【管理员登录】的请求:{}", adminLoginDTO);String jwt = adminService.login(adminLoginDTO);return JsonResult.ok(jwt);}

完成后,重启项目,在Knife4j的调试功能中,使用正常的用户名和密码发起登录请求,将响应JWT结果,例如:

{

"state": 20000,

"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJleHAiOjE2NjExNTIzOTUsInVzZXJuYW1lIjoic3VwZXJfYWRtaW4ifQ.rFACBsBY8w8oNpR80n2YiplsEUIqw5bnCIsC5UAqsww"

}

在服务器端检查并解析JWT

经过以上登录认证并响应JWT后,客户端在后续发起请求时,应该自主携带JWT数据,而服务器端应该尝试检查并解析JWT。

由于客户端在发起多种不同请求时都应该携带JWT,且服务器端都应该检查并尝试解析,所以,服务器端检查并解析的过程,应该发生在比较”通用“的组件中,即无论客户端提交的是哪个路径的请求,这个组件都应该执行!通常,会使用”过滤器“组件进行处理。

在项目的根包下创建filter.JwtAuthrozationFilter类,继承自OncePerRequestFilter,并在此类上添加@Component注解:

@Slf4j@Componentpublic class JwtAuthorizationFilter extends OncePerRequestFilter {public JwtAuthorizationFilter() {log.debug("创建过滤器:JwtAuthorizationFilter");}@Overrideprotected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {log.debug("执行JwtAuthorizationFilter");// 过滤器链继续执行,相当于:放行filterChain.doFilter(request, response);}}

关于客户端提交请求时携带JWT数据,业内通用的做法是在请求头中添加Authorization属性,其值就是JWT数据,所以,服务器端获取JWT的做法应该是:从请求头中的Authorization属性中获取JWT数据!

package cn.tedu.csmall.passport.filter;import io.jsonwebtoken.Claims;import io.jsonwebtoken.Jwts;import lombok.extern.slf4j.Slf4j;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.Authentication;import org.springframework.security.core.authority.SimpleGrantedAuthority;import org.springframework.security.core.context.SecurityContext;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.stereotype.Component;import org.springframework.util.StringUtils;import org.springframework.web.filter.OncePerRequestFilter;import javax.servlet.FilterChain;import javax.servlet.ServletException;import javax.servlet.http.HttpServletRequest;import javax.servlet.http.HttpServletResponse;import java.io.IOException;import java.util.ArrayList;import java.util.List;@Slf4j@Componentpublic class JwtAuthorizationFilter extends OncePerRequestFilter {public JwtAuthorizationFilter() {log.debug("创建过滤器:JwtAuthorizationFilter");}@Overrideprotected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain filterChain) throws ServletException, IOException {log.debug("执行JwtAuthorizationFilter");// 从请求头中获取JWTString jwt = request.getHeader("Authorization");log.debug("从请求头中获取JWT:{}", jwt);// 判断JWT数据是否不存在if (!StringUtils.hasText(jwt) || jwt.length() < 80) {log.debug("获取到的JWT是无效的,直接放行,交由后续的组件继续处理!");// 过滤器链继续执行,相当于:放行filterChain.doFilter(request, response);// 返回,终止当前方法本次执行return;}// 尝试解析JWTString secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();Object username = claims.get("username");log.debug("从JWT中解析得到username:{}", username);// 准备Authentication对象,后续会将此对象封装到Security的上下文中List<SimpleGrantedAuthority> authorities = new ArrayList<>();authorities.add(new SimpleGrantedAuthority("临时使用的权限"));Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);// 将用户信息封装到Security的上下文中SecurityContext securityContext = SecurityContextHolder.getContext();securityContext.setAuthentication(authentication);log.debug("已经向Security的上下文中写入:{}", authentication);// 过滤器链继续执行,相当于:放行filterChain.doFilter(request, response);}}

完成后,还需要将此过滤器添加在Security框架的UsernamePasswordAuthenticationFilter过滤器之前,需要在Security配置类中,先自动装配自定义的过滤器对象:

@Autowiredprivate JwtAuthorizationFilter jwtAuthorizationFilter;然后,在configurer(HttpSecurity http)方法中添加:// 将“JWT过滤器”添加在“认证过滤器”之前http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);

最后,在JWT过滤器执行之初,先清除Security上下文中的数据,以避免”一旦提交JWT将认证对象存入到Security上下文中,后续不携带JWT也能访问“的问题:

// 清除Security上下文中的数据SecurityContextHolder.clearContext();

完成后,启动项目,在Knife4j的调试功能中,携带JWT可以发起任何需要登录才能访问的请求,反之,这些请求不携带JWT将不允许访问。

处理登录成功的管理的权限列表

目前,存入到Security上下文中的认证信息(Authentication对象)并不包含有效的权限信息(目前是个假信息),为了后续能够判断用户的权限,需要:

  • 当认证(登录)成功后,取出管理员的权限,并将其存入到JWT数据中
  • 后续的请求中的JWT应该已经包含权限,则可以从JWT中解析出权限信息,并存入到认证信息(Authentication对象)中
  • 在操作过程中,应该先将权限列表转换成JSON再存入到JWT中,在解析JWT时,得到的权限信息也是一个JSON数据,需要将其转换成对象才能继续使用

关于JSON格式的转换,有许多工具都可以实现,例如:fastjson

<!-- fastjson:实现对象与JSON的相互转换 --><dependency><groupId>com.alibaba</groupId><artifactId>fastjson</artifactId><version>1.2.75</version></dependency>

在AdminServiceImpl处理登录时,当认证成功时,需要从认证结果中取出权限列表,转换成JSON字符串,并存入到JWT中:

// 原有其它代码Collection<GrantedAuthority> authorities = loginUser.getAuthorities();log.debug("认证结果中的权限列表:{}", authorities);String authorityListString = JSON.toJSONString(authorities); // 【重要】将权限列表转换成JSON格式,用于存储到JWT中// 生成JWT时的Claims相关代码claims.put("authorities", authorityListString);log.debug("生成JWT,向JWT中存入authorities:{}", authorityListString);// 原有其它代码

然后,在JWT过滤器中,当成功的解析JWT时,应该获取权限列表的JSON字符串,并将其转换为认证对象要求的格式(Collection<? extends GrantedAuthority):

// 原有其它代码Object authorityListString = claims.get("authorities");log.debug("从JWT中解析得到authorities:{}", authorityListString);// 准备Authentication对象,后续会将此对象封装到Security的上下文中List<SimpleGrantedAuthority> authorities = JSON.parseArray(authorityListString.toString(), SimpleGrantedAuthority.class);Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);// 原有其它代码

完成后,启动项目,正常登录,在服务器端的控制台可以看到相关日志,将显示存入到Security上下文的认证信息中包含权限列表。

使用Security框架检查权限

首先,需要在Security的配置类上开启全局的在方法上检查权限:

// 其它原有注解@EnableGlobalMethodSecurity(prePostEnabled = true) // 新增public class SecurityConfiguration ... ...

然后,在控制器类中处理请求的方法上使用@PreAuthorize注解检查权限:

// 其它原有注解@PreAuthorize("hasAuthority('/ams/admin/update')") // 新增public JsonResult ...

以上注解表示:必须具有/ams/admin/update权限才允许向此路径提交请求。

除此外还可以

 // 当@PreAuthorize注解后括号中判断参数为hasRole时// 相当于在做针对角色(role)的判断,这个写法的效果是对判断内容前(左侧)自动添加"ROLE_"// 既@PreAuthorize("hasRole('user')") 写法的最终效果就等价于// @PreAuthorize("hasAuthority('ROLE_user')")@PreAuthorize("hasRole('user')")

提示:Security会根据上下文中的权限列表进行对比,来检查当前登录的用户是否具有此权限。

自定义UserDetails

Security使用UserDetails接口类型的对象表示需要认证的用户、认证结果中的Principal,但是,Security框架中UserDetails接口的实现类User中并不包含id及其它个性化属性,则可以自定义类进行扩展:

package cn.tedu.csmall.passport.security;import lombok.EqualsAndHashCode;import lombok.Getter;import lombok.Setter;import lombok.ToString;import org.springframework.security.core.GrantedAuthority;import org.springframework.security.core.userdetails.User;import java.util.Collection;@Setter@Getter@EqualsAndHashCode@ToString(callSuper = true)public class AdminDetails extends User {/*** 管理员id*/private Long id;public AdminDetails(String username, String password, boolean enabled,Collection<? extends GrantedAuthority> authorities) {super(username, password, enabled,true, true, true,authorities);}}

接下来,在UserDetailsServiceImpl的UserDetails loadUserByUsername(String username)方法的实现中,使用自定义的AdminDetails作为此方法的返回结果类型:

package cn.tedu.csmall.passport.security;import cn.tedu.csmall.passport.mapper.AdminMapper;import cn.tedu.csmall.passport.pojo.vo.AdminLoginInfoVO;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.security.core.authority.SimpleGrantedAuthority;import org.springframework.security.core.userdetails.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.Service;import java.util.ArrayList;import java.util.List;@Slf4j@Servicepublic class UserDetailsServiceImpl implements UserDetailsService {@Autowiredprivate AdminMapper adminMapper;@Overridepublic UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {log.debug("根据用户名【{}】从数据库查询用户信息……", s);// 调用AdminMapper对象,根据用户名(参数值)查询管理员信息AdminLoginInfoVO loginInfo = adminMapper.getLoginInfoByUsername(s);// 判断是否查询到有效结果if (loginInfo == null) {// 根据用户名没有找到任何管理员信息String message = "登录失败,用户名不存在!";log.warn(message);throw new UsernameNotFoundException(message);}log.debug("根据用户名【{}】从数据库查询到有效的用户信息:{}", s, loginInfo);// 从查询结果中找出权限信息,转换成Collection<? extends GrantedAuthority>List<String> permissions = loginInfo.getPermissions(); // /ams/admin/deleteList<SimpleGrantedAuthority> authorities = new ArrayList<>();for (String permission : permissions) {authorities.add(new SimpleGrantedAuthority(permission));}// 返回AdminDetails类型的对象AdminDetails adminDetails = new AdminDetails(loginInfo.getUsername(), loginInfo.getPassword(),loginInfo.getEnable() == 1, authorities);adminDetails.setId(loginInfo.getId());log.debug("即将向Spring Security返回UserDetails:{}", adminDetails);return adminDetails;}}

后续,在AdminServiceImpl处理登录时,当认证通过,在认证结果中的Principal就是AdminDetails类型的。

所以,当认证通过后,可以将认证结果中的Principal取出,强制转换为AdminDetails类型,并取出id值,用于生成JWT数据:

// 原有其它代码// 处理认证结果AdminDetails loginUser = (AdminDetails) authenticateResult.getPrincipal();log.debug("认证结果中的管理员id:{}", loginUser.getId());log.debug("认证结果中的用户名:{}", loginUser.getUsername());Collection<GrantedAuthority> authorities = loginUser.getAuthorities();log.debug("认证结果中的权限列表:{}", authorities);// 【重要】将权限列表转换成JSON格式,用于存储到JWT中String authorityListString = JSON.toJSONString(authorities);// 生成JWTString secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";// 准备ClaimsMap<String, Object> claims = new HashMap<>();claims.put("id", loginUser.getId());claims.put("username", loginUser.getUsername());claims.put("authorities", authorityListString);log.debug("生成JWT,向JWT中存入id:{}", loginUser.getId());log.debug("生成JWT,向JWT中存入username:{}", loginUser.getUsername());log.debug("生成JWT,向JWT中存入authorities:{}", authorityListString);// 原有其它代码

至此,当登录成功后,生成的JWT中将包含id。

接下来,在JWT过滤器中,解析JWT时,就可以解析得到id的值:

// 尝试解析JWTString secretKey = "nmlfdasfdsaurefuifdknjfdskjhajhef";Claims claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();Long id = claims.get("id", Long.class);String username = claims.get("username", String.class);String authorityListString = claims.get("authorities", String.class);log.debug("从JWT中解析得到id:{}", id);log.debug("从JWT中解析得到username:{}", username);log.debug("从JWT中解析得到authorities:{}", authorityListString);

解析得到的id和username都应该封装到认证对象中,进而将认证对象存入到Security上下文中,由于UsernamePasswordAuthenticationToken中的Principal是Object类型的,表示“当事人”,即“当前成功登录的用户”,所以,可以自定义数据类型,封装id和username,并将封装后的对象存入到UsernamePasswordAuthenticationToken中:

package cn.tedu.csmall.passport.security;import lombok.Data;import java.io.Serializable;/*** 用于保存到Security上下文中的、当前登录的管理员信息(不包含权限信息)*/@Datapublic class LoginPrincipal implements Serializable {/*** 当事人id*/private Long id;/*** 当事人用户名*/private String username;}
// 准备Authentication对象,后续会将此对象封装到Security的上下文中LoginPrincipal loginPrincipal = new LoginPrincipal();loginPrincipal.setId(id);loginPrincipal.setUsername(username);List<SimpleGrantedAuthority> authorities = JSON.parseArray(authorityListString, SimpleGrantedAuthority.class);Authentication authentication = new UsernamePasswordAuthenticationToken(loginPrincipal, null, authorities);

至此,每次客户端携带有效的JWT提交请求时,都可以从中解析得到id、username,这些数据也会保存到Security上下文中,则在任何控制器处理请求的方法上,可以添加@AuthenticationPrincipal LoginPrincipal loginPrincipal,即可注入Security上下文中的LoginPrincipal对象,则可以获取到当事人(当前成功登录的用户)的id、username,例如:

@ApiOperation("查询角色列表")@ApiOperationSupport(order = 401)@GetMapping("")public JsonResult<List<RoleListItemVO>> list(@ApiIgnore @AuthenticationPrincipal LoginPrincipal loginPrincipal) {log.debug("准备处理【查询角色列表】的请求");log.debug("当前登录的用户(当事人)的id:{}", loginPrincipal.getId());log.debug("当前登录的用户(当事人)的用户名:{}", loginPrincipal.getUsername());List<RoleListItemVO> list = roleService.list();return JsonResult.ok(list);}

提示:以上请求参数还添加了@ApiIgnore注解,其作用是在Knife4j的API文档中忽略此参数,否则,还会在Knife4j文档中显示LoginPrincipal对应的参数。

通过前端界面实现登录

当前,如果向服务器端提交登录请求,登录成功时,服务器端将响应:

{"state": 20000,"data": "eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJpZCI6MSwiZXhwIjoxNjYxMjM5MzMxLCJhdXRob3JpdGllcyI6Ilt7XCJhdXRob3JpdHlcIjpcIi9hbXMvYWRtaW4vZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi9yZWFkXCJ9LHtcImF1dGhvcml0eVwiOlwiL2Ftcy9hZG1pbi91cGRhdGVcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvZGVsZXRlXCJ9LHtcImF1dGhvcml0eVwiOlwiL3Btcy9wcm9kdWN0L3JlYWRcIn0se1wiYXV0aG9yaXR5XCI6XCIvcG1zL3Byb2R1Y3QvdXBkYXRlXCJ9XSIsInVzZXJuYW1lIjoicm9vdCJ9.bLrqPBNVVC9nQejqhGeUhr7QETbVSxoZZaZ-YSK6O6o"}

登录失败时(用户名或密码错误),服务器端将响应:

{"state": 50000,"message": "程序运行过程中出现意外错误,请联系系统管理员!"}

提示:目前服务器端还有许多种类的异常暂未处理,将稍后进行处理。

则客户端的登录请求的相关代码需要调整为:

submitForm(formName) {this.$refs[formName].validate((valid) => {if (valid) {let url = 'http://localhost:9081/admins/login';console.log('尝试登录……');console.log('请求路径为:' + url);console.log('请求参数为:' + this.ruleForm);console.log(this.ruleForm);let formData = this.qs.stringify(this.ruleForm); // 注意console.log('将ruleForm对象转换为FormData:'); // 注意console.log(formData); // 注意// 注意:下一行的post()的第2个参数this.axios.post(url, formData).then((response) => {console.log('服务器端响应的结果:' + response);console.log(response);if (response.data.state == 20000) {  // 注意:此行的判断条件console.log('登录成功');this.$message({message: '登录成功!',type: 'success'});// 获取服务器端响应的JWT,并保存下来let jwt = response.data.data; // 重要console.log('服务器端响应的JWT:');console.log(jwt);localStorage.setItem('jwt', jwt); // 重要console.log('已经将JWT数据保存到LocalStorage中');// 以下仅用于测试从LocalStorage中读取数据,没有实质的功能方面的意义let localJwt = localStorage.getItem('jwt');console.log('从LocalStorage中取出的JWT:');console.log(localJwt);} else {console.log('登录失败,用户名或密码错误!');this.$notify.error({title: '登录失败',message: '用户名或密码错误!'});}});} else {console.log('error submit!!');return false;}});}

关于CORS与PreFlight

当客户端与服务器端并不是运行在同一个服务器上时,默认是不允许跨域访问的,在基于Spring MVC框架的项目中(包括添加了spring-boot-starter-web的Spring Boot项目),添加配置类即可解决此问题:

@Slf4j@Configurationpublic class WebMvcConfiguration implements WebMvcConfigurer {public WebMvcConfiguration() {log.debug("加载配置类:WebMvcConfiguration");}@Overridepublic void addCorsMappings(CorsRegistry registry) {registry.addMapping("/**").allowedOriginPatterns("*").allowedMethods("*").allowedHeaders("*").allowCredentials(true).maxAge(3600);}}

当客户端提交异步请求,且自定义了特定的请求头时(例如Authorization),会被视为“复杂请求”,对于复杂请求,在处理过程中,会先执行PreFlight(预检),其具体表现是会先向服务器端提交OPTIONS类型的请求,如果此请求不被允许,将出现以下错误:

(例如:携带JWT向服务器端发起“添加管理员”的请求,由于请求路径并不在Security的白名单中,则不允许访问,进而导致预检不通过,所以出现此错误)

Access to XMLHttpRequest at 'http://localhost:9081/admins/add-new' from origin 'http://localhost:8888' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

在使用了Security框架的项目中,可以在Security的配置类中,对所有OPTIONS类型的请求(即:所有预检)直接放行即可:

http.antMatchers(HttpMethod.OPTIONS, "/**").permitAll()

或者,调用HttpSecurity对象的cors()方法,此方法会为Security框架注册一个解决此问题的过滤器(CorsFilter),也可解决此问题:

http.cors();

另外,对于每个浏览器对同一个服务器的复杂请求而言,预检只会在第1次请求时发生,一旦通过预检,后续的请求中将不再需要预检!这是许多浏览器的机制(当浏览器没有禁用缓存时)!

处理异常

在登录时,可能出现:

  • 用户名错误:BadCredentialsException
  • 密码错误:BadCredentialsException
  • 账号被禁用:DisabledException

在访问时,可能出现:

  • 无此权限:AccessDeniedException

以上异常都可以由统一处理异常的机制进行处理,则先在ServiceCode中添加对应的业务状态码:

/*** 未授权的访问*/Integer ERR_UNAUTHORIZED = 40100;/*** 未授权的访问:账号禁用*/Integer ERR_UNAUTHORIZED_DISABLED = 40101;/*** 禁止访问,通常是已登录,但无权限*/Integer ERR_FORBIDDEN = 40300;

然后,在统一处理异常的类中,添加对相关异常的处理:

@ExceptionHandlerpublic JsonResult<Void> handleBadCredentialsException(BadCredentialsException e) {String message = "登录失败,用户名或密码错误!";log.debug("处理BadCredentialsException:{}", message);return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED, message);}@ExceptionHandlerpublic JsonResult<Void> handleDisabledException(DisabledException e) {String message = "登录失败,此账号已禁用!";log.debug("处理DisabledException:{}", message);return JsonResult.fail(ServiceCode.ERR_UNAUTHORIZED_DISABLED, message);}@ExceptionHandlerpublic JsonResult<Void> handleAccessDeniedException(AccessDeniedException e) {String message = "访问失败,当前登录的账号无此权限!";log.debug("处理AccessDeniedException:{}", message);return JsonResult.fail(ServiceCode.ERR_FORBIDDEN, message);}

另外,在解析JWT的过程中,也可能出现异常,由于解析JWT是在过滤器中进行的,如果出现异常,不会被统一处理异常的机制获取得到(因为过滤器执行的时间点太早),所以,只能在过滤器中自行处理异常,例如:

// 尝试解析JWTClaims claims = null;try {claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwt).getBody();} catch (MalformedJwtException e) {log.warn("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());JsonResult<Void> jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_PARSE, "无法获取到有效的登录信息,请重新登录!");String jsonResultString = JSON.toJSONString(jsonResult);PrintWriter writer = response.getWriter();writer.println(jsonResultString);writer.close();return;} catch (SignatureException e) {log.warn("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());JsonResult<Void> jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_PARSE, "无法获取到有效的登录信息,请重新登录!");String jsonResultString = JSON.toJSONString(jsonResult);PrintWriter writer = response.getWriter();writer.println(jsonResultString);writer.close();return;} catch (ExpiredJwtException e) {log.warn("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());JsonResult<Void> jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_EXPIRED, "登录信息已过期,请重新登录!");String jsonResultString = JSON.toJSONString(jsonResult);PrintWriter writer = response.getWriter();writer.println(jsonResultString);writer.close();return;} catch (Throwable e) {log.warn("解析JWT失败:{}:{}", e.getClass().getName(), e.getMessage());JsonResult<Void> jsonResult = JsonResult.fail(ServiceCode.ERR_JWT_PARSE, "无法获取到有效的登录信息,请重新登录!");String jsonResultString = JSON.toJSONString(jsonResult);PrintWriter writer = response.getWriter();writer.println(jsonResultString);writer.close();return;}

基于Spring JDBC的事务管理

事务:Transaction,是数据库中可以保障多次写(增删改)操作要么全部成功,要么全部失败的机制。

在基于Spring JDBC的数据库编程(包括使用Mybatis框架实现数据库编程)中,在处理业务的方法上添加@Transactional注解,即可保证此业务方法是事务性的。

关于事务的相关概念:

  • 开启事务:Begin
  • 提交事务:Commit
  • 回滚事务:Rollback

在Spring JDBC的事务管理中,其实现大概是:

开启事务

try {

执行业务方法

提交事务

} catch (RuntimeException e) {

回滚事务

}

可以看到,当执行业务方法时,如果出现了RuntimeException(含其子孙类异常),都会回滚事务,这是Spring JDBC事务管理的默认处理方式!

在使用@Transactional注解时,可以通过配置rollbackFor及相关属性,来指定回滚的异常类型,还可以通过配置noRollbackFor及相关属性,来指定不执行回滚的异常类型。

关于@Transactional注解,可以添加在:

  • 接口上
  • 接口的实现类上
  • 接口的抽象方法上
  • 实现类的实现方法上

如果将此注解添加在“接口”或“接口的实现类”上,则表示对应的所有方法都是事务性的!

如果“接口”或“接口的实现类”上添加了此注解,并配置了注解的某属性,同时,“接口的抽象方法”或“实现类的实现方法上”也添加了此注解,也配置了注解的同样的属性,却是不同的值,则以方法上的配置值为准!

通常,推荐将注解添加在接口上,或接口中的抽象方法上!

本质上,Spring JDBC是通过接口代理模式来实现的事务管理,如果将@Transactional注解添加在业务实现类中的自定义方法上(未在接口中声明的方法),会是错误的!

通常,如果某个业务涉及2次或以上次数的写(增删改)操作,就必须使其是事务性的!另外,如果某个业务中涉及多次查询,使用@Transactional可以使得这些查询共用同一个数据库连接对象,可提高查询效率。

另外:建议学习“事务的ACID特性”、“事务的传播”、“事务的隔离”。

、其他知识点

目前流行的是使用Spring Boot作为项目的基础框架,使用到主流的SSM(Spring / Spring MVC / Mybatis)、Spring Security、Spring Validation等框架。

早期流行的是SSH:Spring / Struts 2 / Hibernate

项目的开发流程

大概的指导思想,开发项目的核心流程:需求分析、可行性分析、总体设计、详细设计等。

关于实体类的开发

  • 实体类的名称应该与数据表名的关键字相对应,例如表名为pms_album,则实体类名为Album,表名为pms_attribute_template,则实体类名为AttributeTemplate
  • 实体类中的属性的类型应该与表设计保持一致,通常对应关系为:

  • 实体类中所有属性都应该是私有的
  • 实体类中所有属性都应该有对应的Setter & Getter方法【自动生成】
  • 实体类必须存在无参数构造方法
  • 实体类必须重写hashCode()和equals(),且必须保证:hashCode()返回值相同时,equals()对比结果必须为true,hashCode()返回值不同时,equals()对比结果必须为false【自动生成】
    • 提示:不同的开发工具、生成时使用的模板不同时,生成的代码可能不同,但不重要
  • 实体类都应该重写toString()方法,以输出所有字段的值,便于后续观察对象
  • 实体类都必须实现Serializable接口
    • 可以不定义序列化版本ID

建议将实体类放在项目根包下的entity包中(某些编程习惯中可能使用其它的包名,例如domain等)。

LOMBOK

LOMBOK是一款可以在编译期在类中自动生成某些代码的工具,通常用于自动生成:

  • Setters & Getters
  • hashCode() and equals()
  • toString()
  • 无参数构造方法
  • 全参数构造方法

在使用时,需要添加依赖项:

<!-- Lombok --><!-- https://mvnrepository.com/artifact/org.projectlombok/lombok --><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><version>1.18.20</version><scope>provided</scope></dependency>

在POJO类的声明上,添加@Data注解,此注解可以帮助生成所有属性对应的Setters & Getters、规范的hashCode()和equals()、toString(),并且,要求此类的父类中存在无参数构造方法。

package cn.tedu.csmall.product.pojo.entity;import lombok.Data;import java.io.Serializable;import java.time.LocalDateTime;@Datapublic class Album implements Serializable {private Long id;private String name;private String description;private Integer sort;private LocalDateTime gmtCreate;private LocalDateTime gmtModified;}

注意:Lombok是在**编译期(将Java源代码文件.java编译成目标文件.class)**添加各方法,所以,在IntelliJ IDEA或其它开发工具中,默认情况下,直接调用以上各属性对应的Setter或Getter方法,在开发工具将无法提示这些方法,并且,已经写出来的调用这些方法的代码会报错,为了解决此问题,需要在开发工具中安装Lombok插件。

另外,Lombok还提供了以下注解:

  • @Data
  • @Setter
  • @Getter
  • @EqualsAndHashCode
  • @ToString
  • @NoArgsConstructor
  • @AllArgsConstructor
  • @Accessors
    • 配置为@Accessors(chain = true)时,将支持链式调用方法

使用SLF4j日志

日志可以用于在程序执行过程中,向控制台或文件或数据库等位置输出一些自定义的信息。

注意:在开发实践中,不要在src/main/java下的任何类中使用System.out.println()的方式进行输出,除非你确定这些信息是一定要被任何人都可以看到的!

在spring-boot-starter(此依赖项是几乎所有带有spring-boot-starter依赖项的子级依赖)的依赖项中,默认已经集成了SLF4j日志框架。

日志是有显示级别的,根据日志信息的重要程度,从低到高分别是:

  • trace:跟踪信息
  • debug:调试信息
  • info:一般信息
  • warn:警告信息
  • error:错误信息

默认的显示级别是info,则只会显示此级别及更重要的日志信息。

在Spring Boot项目的application.properties中,可以添加配置,以指定日志的显示级别:

logging.level.包名=日志的显示级别

例如,可以配置为:

logging.level.cn.tedu.csmall.product=info

在添加了Lombok框架的Spring Boot项目中,可以在任何类上添加@Slf4j注解,则在当前类中就可以使用名为log的变量来调用方法,实现日志的输出,例如:

@Slf4j@SpringBootTestpublic class Slf4jTests {@Testvoid testSlf4j() {log.trace("这是一条【trace】级别的日志");log.debug("这是一条【debug】级别的日志");log.info("这是一条【info】级别的日志");log.warn("这是一条【warn】级别的日志");log.error("这是一条【error】级别的日志");}}

可以看到,log变量可以调用trace()、debug()、info()、warn()、error()方法,将输出对应级别的日志。

各级别的方法均重载了多次,通常使用的方法是:

  • trace(String message)
  • trace(String message, Object... args)

提示:其它各级别的方法也有以上方式的重载。

使用以上第2个方法时,可以在第1个参数的字符串中使用{}作为占位符,表示某变量,然后,从第2个参数开始,依次表示各{}占位符对应的值即可,例如:

@Testvoid testSlf4j() {int a = 1;int b = 2;log.debug("a=" + a + ", b=" + b + ", a+b=" + (a + b));log.debug("a={}, b={}, a+b={}", a, b, a + b);}

另外,使用trace(String message, Object... args)这类方法来输出日志时,日志框架会对第1个参数String message进行缓存,执行效率远高于使用System.out.println()且拼接字符串的效果。

关于注解的源代码

注解的源代码中,都使用了@Target作为其元注解,此注解的作用是声明当前注解可以添加在哪些位置,以@RequestMapping为例,上面就配置了:

@Target({ElementType.TYPE, ElementType.METHOD})

在注解的源代码内部,声明了此注解可以配置哪些属性,及属性的值,在@RequestMapping为例,其代码中包括:

@AliasFor("path")

String[] value() default {};

以上代码中:

  • value():表示此注解可以配置名为value的属性
  • String[]:表示此value属性的值类型是String[]
  • default {}:表示此value属性的默认值是{}(空数组)
  • @AliasFor("path"):表示此value属性与当前注解中的path属性是等效的

基于以上声明,可以:

@RequestMapping(value = {"value1", "value2", "value3"})

在所有注解中,value属性都是默认的属性,在配置此属性时,如果注解只配置这1个属性,则可以不必显式指定属性名,即:

@RequestMapping(value = {"value1", "value2", "value3"})

@RequestMapping({"value1", "value2", "value3"})

以上2种配置方式是完全等效的!

并且,在所有注解中,如果要配置的属性的值类型是数组,但是,只需要配置1个值时(数组中只有1个元素),可以不必使用{}将值框住,例如:

@RequestMapping(value = {"value1"})

@RequestMapping(value = "value1")

所以,综合看来,

@RequestMapping(value = {"value1"})

@RequestMapping(value = "value1")

@RequestMapping({"value1"})

@RequestMapping("value1")

以上4种配置是完全等效的!

在@RequestMapping中的value和path是等效的,所以,配置方式也完全相同!另外,之所以有这2个完全等效的属性,因为value使用简便,但是,每个注解都可以有value属性,所以,这个属性名称并不一定具有良好的可读性,所以,Spring MVC框架就另设计了path属性,当开发者追求简便时,使用value即可,追求代码的可读性时,则可以使用path属性。

需要注意:如果注解中需要配置多个属性,则每个属性值都必须显式的指定属性名,例如:

@RequestMapping(name = "xxx", "/list")

以上做法是错误的!!!

正确的配置方式如下:

@RequestMapping(name = "xxx", value = "/list")

以上配置中,即使是value属性,也必须显式的指定属性名!

在@RequestMapping还定义了method属性:

RequestMethod[] method() default {};

此属性的作用是限制为某种请求方式,例如配置为:

@RequestMapping(value = "/add-new", method = RequestMethod.POST)

以上代码表示/add-new路径只能使用POST方式来提交请求,如果使用其它请求方式,将响应405错误。

如果没有配置method属性,则所有请求方式都是支持的!

为了简化约束请求方式,Spring MVC还提供了以下注解:

  • @GetMapping
  • @PostMapping
  • 其它

RESTful风格

RESTful是一种设计URL的风格。

注意:RESTful既不是规定,也不是规范!

RESTful的典型表现是:将某些具有“唯一性”的参数值作为URL的一部分。

例如:

https://blog.csdn.net/wl_ss013/article/details/810691

https://blog.csdn.net/weixfd3811/article/details/11565346

以上URL,如果不采用RESTful风格,可能是:

https://blog.csdn.net/article/details?username=wl_ss013&id=810691

https://blog.csdn.net/article/details?username=weixfd3811&id=11565346

所以,如果需要设计“根据id删除相册”的URL,可以设计为:

http://localhost:8080/album/9527/delete

Spring MVC框架对RESTful提供了很好的支持,要实现以上效果,可以在方法上配置为:

@RequestMapping("/{id}/delete")

在处理请求的方法的参数列表中,可以声明与占位符的名称匹配的参数,并添加@PathVariable注解,即可接收到URL中的参数值:

// http://localhost:9080/album/9527/delete@RequestMapping("/{id}/delete")public String delete(@PathVariable Long id) {log.debug("开始处理删除id={}请求", id);return "处理了/" + id + "/delete的请求";}

提示:如果URL中占位符的名称与方法参数的名称不匹配,可以在@PathVariable注解中配置参数,值为URL中占位符的名称即可,则方法参数的名称就不重要了!例如:

@RequestMapping("/{albumId}/delete")public String delete(@PathVariable("albumId") Long id) {log.debug("开始处理删除id={}请求", id);return "处理了/" + id + "/delete的请求";}

在设计URL时,使用{}的占位符时,可以在名称右侧添加:,并在其右侧配置正则表达式,以对URL中的参数的基本格式进行约束,例如:

// http://localhost:9080/album/9527/delete@RequestMapping("/{id:[0-9]+}/delete")public String delete(@PathVariable Long id) {log.debug("开始处理删除id={}请求", id);return "处理了/" + id + "/delete的请求";}

通过使用以上正则表达式,纯数字的id可以匹配以上路径,可以正常访问,如果不是纯数字的id,则根本匹配不到以上路径,以上方法也不会执行,服务器端将直接响应404错误。

提示:404错误相比400错误,能更早的回绝客户端的错误请求。

在使用{}占位符且使用了正则表达式时,不冲突的匹配(每个URL只会匹配到其中某1个正则表达式,不会同时匹配到多个正则表达式)是可以共存的,例如:

// http://localhost:9080/album/9527/delete@RequestMapping("/{id:[0-9]+}/delete")public String delete(@PathVariable Long id) {log.debug("开始处理删除id={}请求", id);return "处理了/" + id + "/delete的请求";}// http://localhost:9080/album/huawei/delete@RequestMapping("/{name:[a-zA-Z]+}/delete")public String delete(@PathVariable String name) {log.debug("开始处理删除name={}请求", name);return "处理了/" + name + "/delete的请求";}

甚至,不使用正则表达式的,也可以与之共存,例如,在以上基础上,还可以添加:

// http://localhost:9080/album/test/delete@RequestMapping("/test/delete")public String delete() {log.debug("开始处理测试删除请求");return "处理了测试删除的请求";}

Spring MVC在处理时,会优先匹配没有使用正则表达式的,所以,当提交 /album/test/delete 时,会成功匹配到以上delete()方法,不会匹配到delete(String name)方法。

在RESTful的建议中,对于不同的数据操作,应该使用不同的请求类型,例如:

  • GET >>> /albums/9:对id值为9的相册数据执行查询(执行数据的select操作)
  • PUT >>> /albums/9:对id值为9的相册数据执行编辑(执行数据的update操作)
  • DELETE >>> /albums/9:对id值为9的相册数据执行删除(执行数据的delete操作)
  • POST >>> /albums:新增相册数据(执行数据的insert操作)

通常,绝大部分应用中,在处理业务时(并不是直接操作某数据),并不会采纳以上建议!

最后,在开发实践中,更多的还是只使用GET和POST这2种请求方式,关于RESTful 风格的URL设计参考:

  • 查询列表:/数据类型的复数

    • 例如:/albums
  • 查询指定id的数据:/数据类型的复数/id值
    • 例如:/albums/{id}
  • 对指定id的数据进行某操作:/数据类型的复数/id值/操作
    • 例如:/albums/{id}/delete

Spring Validation检查请求参数

Spring Validation框架的主要作用:实现了简化检查请求参数的基本格式

在Spring Boot中,需要添加spring-boot-starter-validation依赖项。

当需要检查请求参数时,需要在处理请求的方法的参数列表中,对需要检查的参数添加@Validated注解,表示此参数是需要通过Spring Validation进行检查的:

@RequestMapping("/add-new")public String addNew(@Validated AlbumAddNewDTO albumAddNewDTO) {// 省略方法体的代码}

然后,在类的属性上,添加相关检查注解,并在检查注解中配置message属性以指定错误时的提示文本:

@Datapublic class AlbumAddNewDTO implements Serializable {@NotNull(message = "必须提交相册名称!")private String name;private String description;private Integer sort;}

当Spring Validation检查不通过时,将抛出BindException,所以,可以在统一处理异常的类中对此类异常进行处理:

@ExceptionHandlerpublic String handleBindException(BindException e) {log.debug("处理BindException:{}", e.getMessage());StringBuilder stringBuilder = new StringBuilder();List<FieldError> fieldErrors = e.getFieldErrors();for (FieldError fieldError : fieldErrors) {String message = fieldError.getDefaultMessage();stringBuilder.append(message);}return stringBuilder.toString();}

除了@NotNull以外,框架还提供了许多检查注解,

@ApiImplicitParams({@ApiImplicitParam(name = "type", value = "弹幕所属视频类型", dataType = "String", required = true,allowableValues = "tv,film,video"),@ApiImplicitParam(name = "userId", value = "用户Id", dataType = "long", required = true),@ApiImplicitParam(name = "videoId", value = "弹幕对应视频Id", dataType = "long", required = true)})
@Data
@ApiModel("新增视频")
public class VideoAddNewDTO implements Serializable {@ApiModelProperty(value = "视频url",required = true)@NotNull(message = "视频url不能为空!")private String url;@ApiModelProperty(value = "视频标题",required = true)@NotNull(message = "视频标题不能为空!")@Size(min=3,max=30,message ="视频标题长度必须大于等于3,小于等于30!")private String title;@ApiModelProperty(value = "视频标签",required = true)@NotNull(message = "视频标签不能为空!")private String[] labels;@ApiModelProperty(value = "视频海报",required = true)@NotNull(message = "视频海报不能为空!")private String poster;@ApiModelProperty(value = "视频简介",required = true)@NotNull(message = "视频简介不能为空!")@Size(min=3,max=400,message ="视频简介长度必须大于等于3,小于等于400!")private String brief;
}
  • @Pattern:通过此注解的regexp属性配置正则表达式,并使用message配置验证失败时的提示文本

    • 注意:此注解只能添加在字符串类型的属性上
    • 注意:此注解不能检查“为null”的情况,如果不允许为null,则必须同时配置@NotNull和@Pattern
  • @Range:通过此注解的min和max属性可以指定整型数据的最小值和最大值
    • 提示:此注解可以和@NotNull一起使用

Knife4j框架

Knife4j框架是一款基于Swagger 2框架的、能够基于项目中的控制器的代码来生成在线API文档的框架,另外,此框架还有调试功能,可以向服务器端发送请求,并获取响应结果。

关于此框架,要使之能够使用,需要:

  • 添加依赖
  • 添加配置类
  • 在application.properties中添加1条配置

关于依赖的代码:

<!-- Knife4j Spring Boot:在线API --><dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-spring-boot-starter</artifactId><version>2.0.9</version></dependency>

关于配置类:

import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver;import lombok.extern.slf4j.Slf4j;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import springfox.documentation.builders.ApiInfoBuilder;import springfox.documentation.builders.PathSelectors;import springfox.documentation.builders.RequestHandlerSelectors;import springfox.documentation.service.ApiInfo;import springfox.documentation.service.Contact;import springfox.documentation.spi.DocumentationType;import springfox.documentation.spring.web.plugins.Docket;import springfox.documentation.swagger2.annotations.EnableSwagger2WebMvc;/*** Knife4j配置类** @author java@tedu.cn* @version 0.0.1*/@Slf4j@Configuration@EnableSwagger2WebMvcpublic class Knife4jConfiguration {/*** 【重要】指定Controller包路径*/private String basePackage = "cn.tedu.csmall.product.controller";/*** 分组名称*/private String groupName = "product";/*** 主机名*/private String host = "http://java.tedu.cn";/*** 标题*/private String title = "酷鲨商城在线API文档--商品管理";/*** 简介*/private String description = "酷鲨商城在线API文档--商品管理";/*** 服务条款URL*/private String termsOfServiceUrl = "http://www.apache.org/licenses/LICENSE-2.0";/*** 联系人*/private String contactName = "Java教学研发部";/*** 联系网址*/private String contactUrl = "http://java.tedu.cn";/*** 联系邮箱*/private String contactEmail = "java@tedu.cn";/*** 版本号*/private String version = "1.0.0";@Autowiredprivate OpenApiExtensionResolver openApiExtensionResolver;public Knife4jConfiguration() {log.debug("加载配置类:Knife4jConfiguration");}@Beanpublic Docket docket() {String groupName = "1.0.0";Docket docket = new Docket(DocumentationType.SWAGGER_2).host(host).apiInfo(apiInfo()).groupName(groupName).select().apis(RequestHandlerSelectors.basePackage(basePackage)).paths(PathSelectors.any()).build().extensions(openApiExtensionResolver.buildExtensions(groupName));return docket;}private ApiInfo apiInfo() {return new ApiInfoBuilder().title(title).description(description).termsOfServiceUrl(termsOfServiceUrl).contact(new Contact(contactName, contactUrl, contactEmail)).version(version).build();}}

关于application.properties中的配置:

# 开启Knife4j框架的增强模式

knife4j.enable=true

注意:

  • 当前项目的Spring Boot版本必须是2.6以下的版本(2.6不可用)

    • 如果要使用更高版本的Spring Boot,必须使用更高版本的Knife4j
  • 在配置类中的basePackage必须是控制器类所在的包,记得需要修改

完成后,启动项目,通过 /doc.html 即可访问在线API文档。

在开发实践中,还应该对在线API文档进行细化,需要在控制器及相关类中进行一些配置:

  • 在控制器类上添加@Api注解,配置tags属性,此属性是String类型的
  • 在控制器类中处理请求的方法上添加@ApiOperation注解,配置value属性,此属性是String类型的
  • 在控制器类中处理请求的方法上添加@ApiOperationSupport注解,配置order属性,此属性是int类型的
    • 此属性用于排序,数据越小越靠前,不建议使用1位的数字
  • 在控制器类中处理请求的方法上,不要再使用没有限制请求方式的@RequestMapping,建议使用@GetMapping或@PostMapping
  • 如果处理请求的方法中,如果参数是封装的数据类型,应该在此类型的各属性上添加@ApiModelProperty注解,以配置对参数的说明
  • 如果处理请求的方法中,如果参数并没有封装,则需要使用@ApiImplicitParams和@ApiImplicitParam这2个注解组合来配置
    • 注意:一旦配置了@ApiImplicitParam,原本的提示的值会被覆盖,应该完整的配置各属性

前端框架:qs

关于Spring MVC中的@RequestBody

当服务器端接收来自客户的请求参数时,客户端的请求参数可以是以下2种格式:

  • JSON格式,例如:

    • {
    • "description": "小米80的相册的简介",
    • "name": "小米80的相册",
    • "sort": 88
    • }
  • FormData格式,例如:
    • name=小米80的相册&description=小米80的相册的简介&sort=88

如果使用了@RequestBody,则客户端提交的请求参数必须是JSON格式的。

如果没有使用@RequestBody,则客户端提交的请求参数必须是FormData格式的。

提示:如果使用了@RequestBody,在Knife4j的调试界面,将没有各请求参数的输入框,而是需要自行填写JSON格式的请求参数。

qs是一个可以将JavaScript中的对象(与JSON格式相同)转换为FormData格式的框架!

在前端项目中,先安装qs:

npm i qs -S

然后,需要在main.js中添加配置,以导入qs并使用:

import qs from 'qs';

Vue.prototype.qs = qs;

接下来,在项目的任何视图组件中,都可以通过this.qs来使用此对象。

在提交请求之前,可以使用qs将JavaScript对象转换为FormData字符串,例如:

let formData = this.qs.stringify(this.ruleForm);

密码加密

存储到数据库中的密码,必须经过加密处理!

用户提交的原始密码通常称之为原文、明文,加密后的数据通常称之为密文。

对于需要存储到数据库中的密码,不可以使用加密算法!

提示:加密算法是用于保障传输过程安全的,并不是用于保障存储的数据的安全的!

通常,会使用消息摘要算法对密码进行加密处理!

消息摘要算法典型的特征是:

  • 不可逆向运算
  • 消息相同时,得到摘要是相同的
  • 使用同样的算法,无论消息的长度是多少,摘要的长度是固定的
  • 消息不同时,得到的摘要几乎不会相同

典型的消息摘要算法是:

  • MD(Message Digest)系列

    • MD1 / MD2 /MD5
  • SHA(Secure Hash Algorithm)家族
    • SHA-1 / SHA-256 / SHA-384 / SHA-512

MD系列算法都是128位算法,即其运算结果是128个二进制位。

SHA-1是160位算法(已被破解),SHA-256是256算法,SHA-384是384位算法,SHA-512是512位算法。

关于处理密码加密,应该:

  • 要求用户使用安全强度更高的密码
  • 加盐
  • 多重加密(循环)
  • 使用位数更长的算法
  • 综合以上应用方式
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.util.DigestUtils;
import java.util.UUID;
@Slf4j
public class Md5Tests {@Testpublic void testMd5() {for (int i = 0; i < 10; i++) {String rawPassword = "123456";String salt = UUID.randomUUID().toString().replaceAll("-","");String encodedPassword = DigestUtils.md5DigestAsHex((salt + rawPassword + salt + rawPassword + salt).getBytes());log.debug("原文={}, 密文={}", rawPassword, encodedPassword+salt);}}@Testpublic void testMatches() {String rawPassword = "123456";String dbPassword = "f2f3f98eddb42a394a157ad3aab32d0371fe16745233442ca69e0dc5a314a85e";String salt = dbPassword.substring(32);String encodedPassword = DigestUtils.md5DigestAsHex((salt + rawPassword + salt + rawPassword + salt).getBytes());System.out.println(dbPassword.equals(encodedPassword + salt));}
}

Redis

关于Redis

Redis是一款基于内存使用了类似K-V结构来实现缓存数据的NoSQL非关系型数据库。

提示:Redis本身也会做数据持久化处理。

Redis的简单操作

当已经安装Redis,并确保环境变量可用后,可以在命令提示符窗口(CMD)或终端(IDEA的Terminal,或MacOS/Linux的命令窗口)中执行相关命令。

在终端下,可以通过redis-cli登录Redis客户端:

redis-cli

在Redis客户端中,可以通过ping检测Redis是否正常工作,将得到PONG的反馈:

ping

在Redis客户端中,可以通过set命令向Redis中存入或修改简单类型的数据:

set name jack

在Redis客户端中,可以通过get命令从Redis中取出简单类型的数据:

get name

如果使用的Key并不存在,使用get命令时,得到的结果将是(nil),等效于Java中的null

在Redis客户端中,可以通过keys命令检索Key:

keys *

keys a*

注意:默认情况下,Redis是单线程的,keys命令会执行整个Redis的检索,所以,执行时间可能较长,可能导致阻塞!

在Spring Boot项目中读写Redis

首先,需要添加spring-boot-starter-data-redis依赖项:

<!-- Spring Data Redis:读写Redis --><dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

以上依赖项默认会连接localhost:6379,并且无用户名、无密码,所以,当你的Redis符合此配置,则不需要在application.properties / application.yml中添加任何配置就可以直接编程。如果需要显式的配置,各配置项的属性名分别为:

  • spring.redis.host
  • spring.redis.port
  • spring.redis.username
  • spring.redis.password

在使用以上依赖项实现Redis编程时,需要使用到的工具类型为RedisTemplate,调用此类的对象的方法,即可实现读写Redis中的数据。

import lombok.extern.slf4j.Slf4j;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.data.redis.connection.RedisConnectionFactory;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.data.redis.serializer.RedisSerializer;import java.io.Serializable;@Slf4j@Configurationpublic class RedisConfiguration {    @Bean    public RedisTemplate<String, Serializable> redisTemplate(RedisConnectionFactory redisConnectionFactory){      RedisTemplate<String, Serializable> redisTemplate= new RedisTemplate<>();        redisTemplate.setConnectionFactory(redisConnectionFactory);        redisTemplate.setKeySerializer(RedisSerializer.string());        redisTemplate.setValueSerializer(RedisSerializer.json());        return redisTemplate;    }}

在使用之前,应该先在配置类中使用@Bean方法创建RedisTemplate,并实现对RedisTemplate的基础配置,则在项目的根包下创建config.RedisConfiguration类:

计划任务:

配置类:

@Configuration
@EnableScheduling
public class ScheduleConfiguration {
}
@Slf4j
@Component
public class CacheSchedule {@Autowiredprivate IBrandService brandService;public CacheSchedule() {log.debug("创建计划任务对象:CacheSchedule");}// 关于@Scheduled常用属性// >> fixedRate:每间隔多少毫秒执行一次// >> fixedDelay:每延迟多少毫秒执行一次// >> cron:使用1个字符串,其中写6-7个值,各值之间使用空格进行分隔// >> >> 这6-7个值分别表示:秒 分 时 日 月 周 [年]// >> >> 例如:cron = "56 34 12 20 1 ? 2230",表示“2230年1月20日12:34:56执行,无论当天是星期几”// >> >> 以上各个位置,均可以使用星号,表示任意值,在“日”和“周”上,还可以使用问号,表示不关心具体值// >> >> 以上各个位置,还可以使用"x/x"格式的值,例如,在"分钟"位置使用 1/5,表示分钟值为1时执行,且每5分钟执行1次@Scheduled(fixedRate = 3 * 60 * 1000)public void updateCache() {log.debug("执行了CacheSchedule的计划任务……");log.debug("加载品牌数据到缓存……");brandService.loadBrandToCache();}}

缓存预热:

@Slf4j
@Component
public class CacheLoader implements ApplicationRunner {@Autowiredprivate IBrandService brandService;public CacheLoader() {log.debug("创建ApplicationRunner:CacheLoader");}@Overridepublic void run(ApplicationArguments args) throws Exception {log.debug("CacheLoader.run()");log.debug("加载品牌数据到缓存……");brandService.loadBrandToCache();}}

注解大全

面试题

Spring框架中,如何通过组件扫描来创建类的对象

在某个配置类上添加@ComponentScan注解,必要的话,配置这个注解的value / basePackages属性,可以指定组件扫描的根包。

将需要被Spring创建对象的类型,声明在这个根包之下,并且,在类上添加组件注解即可。

常用的基本组件注解有:@Component、@Controller、@Service、@Repository。

使用Spring框架时,如何选取@Bean方法和组件扫描,使得Spring创建对象

自定义的类应该使用组件扫描,其它类(不是自行创建的类型)必须使用@Bean方法。

请描述@Autowired的自动装配机制

首先,在Spring容器中查找匹配类型的Spring Bean的数量

  • 0个:取决于@Autowired注解的required属性

    • required = true:加载Spring时出现NoSuchBeanDefinitionException
    • required = false:放弃自动装配,则属性值为null
  • 1个:直接装配,且成功
  • 多个:将尝试根据名称来自动装配,要求被自动装配的属性名与Spring Bean的名称是匹配的,如果存在匹配的,则成功装配,否则,加载Spring时出现NoUniqueBeanDefinitionException
    • 关于名称匹配,可以是属性名改为某个Spring Bean名称,或在属性上添加@Qualifier注解来指定某个Spring Bean的名称

请描述@Autowired@Resource的装配机制的区别

【先回答以上@Autowired的装配机制】

@Resource的装配机制是:先尝试根据名称查找匹配的Spring Bean,且类型也匹配,则自动装配,如果没有匹配名称的Spring Bean,将尝试按照类型来装配,简单来说,是先根据名称,再根据类型的装配机制。

请描述@Autowired@Resource的区别

【先回答以上题目的答案】

@Resource是javax.annotation包中的注解,而@Autowired是Spring框架定义的注解。

@Resource注解可以添加在类上、属性上、方法上,但是,只有添加在属性上,才被解释为自动装配。

@Autowired注解可以添加在属性上、Setter方法上、构造方法上,所以,当尝试自动装配时,可以:

public class BrandController {@Autowiredprivate IBrandService brandService;}// 本示例代码的效果,使用@Resource无法实现public class BrandController {private IBrandService brandService;@Autowiredpublic void setBrandService(IBrandService brandService) {this.brandService = brandService;}}// 本示例代码的效果,使用@Resource无法实现public class BrandController {private IBrandService brandService;@Autowiredpublic BrandController(IBrandService brandService) {this.brandService = brandService;}}

其实,关于Spring框架自动调用构造方法:

  • 如果类中仅有无参构造方法,则直接调用(不需要使用@Autowired注解)
  • 如果类中仅有1个非无参构造方法,则直接调用(不需要使用@Autowired注解)
  • 如果类中有多个构造方法,则尝试调用无参数构造方法(不需要使用@Autowired注解)
  • 如果类中有多个构造方法,且都是有参数的,则尝试调用带@Autowired的那1个

由于@Autowired可以添加在方法上,如果方法的参数需要被自动装配,但名称不匹配,还可以在方法的参数前添加@Qualifier来指定Spring Bean的名称。

在Spring框架中,IoC与DI的区别

IoCInversion OControl,控制反转,表示将对象的控制权(创建、管理)交给框架

DIDependency Injection,依赖注入,表现为给对象的依赖属性赋值

Spring框架通过DI实现了(完善了)IoC。

Spring MVC的核心处理流程

Spring MVC的核心组件:

  • DispathcerServlet:用于统一接收请求,并分发
  • HandlerMapping:记录了请求路径与处理请求的控制器组件的对应关系
  • Controller:实际请求的组件
  • ModelAndView:封装了数据与视图名称的结果
  • ViewResolver:根据视图名称确定实际应用的视图组件

使用Mybatis的查询时,如何选取resultType和resultMap

在配置<select>节点时,必须配置resultType或resultMap,其中,resultType是直接指定返回值的类型,此属性的取值为返回值类型的全限定名,而resultMap的取值为自行配置的<resultMap>节点的id属性的值,而自定义的<resultMap>是用于指导Mybatis如何封装查询结果的。

当抽象方法的返回值类型是基本数据类型(例如统计查询)或其它基本值类型(通常包括String)时,只能使用resultType。

当抽象方法的返回值类型是封装的类型时,强烈推荐使用resultMap,因为<resultMap>可能具有一定的复用性,并且,复杂的关联查询只能使用<resultMap>的配置。

Java主流框架技术及少量前端框架使用与总结相关推荐

  1. java版 SpringCloud 之目前得前端框架都有哪些?

    1.AngularJS Angular JS 是一个有Google维护的开源前端web应用程序框架.它最初由Brat Tech LLC的Misko Hevery于2009年开发出来.Angular J ...

  2. 框架对比_2020 年前端框架性能对比和评测

    我们又来做这个对比了.这次是 2020 年的版本,还有之前的版本: 2019 年. 2018 年. 2017 年. 先来明确一点--这篇文章绝对不是为了告诉你该选择哪个前端框架而写的.它只是一个小型而 ...

  3. 浅谈 SAP UI5 框架对一些其他前端框架比如 Vue 的支持

    我们都知道 Fiori 代表 SAP 新一代 UI 的界面风格,而 UI5 是 Fiori UX(User Experience,用户体验)的具体实现技术.从下图这则新闻 能够看出,SAP 决定将 F ...

  4. 谈谈新的前端框架 Svelte 和现代前端框架的特点

    官方网址 https://svelte.dev/ 这个框架还是非常不错的,轻量级,代码量少,没有Virtual DOM高性能,涵盖了非常多的优点. 先来说说轻量级,通过CSS缩小后有约17kb的大小, ...

  5. php前端响应式框架,响应式css前端框架有哪些

    响应式css前端框架有:1.Semantic UI Framework:2.Less Framework:3.Foundation Framework:4.UIkit Framework:5.YUI ...

  6. jquery属于html框架吗,jquery是前端框架吗?

    jquery是前端框架吗? jquery不是前端框架,它是一个JavaScript库. 框架与库之间最本质区别在于控制权:you call libs, frameworks call you(控制反转 ...

  7. layuit 框架_Layui|经典模块化前端框架

    "Layui"是一款采用自身模块规范编写的情怀型前端UI框架,遵循原生HTML/CSS/JS的书写与组织形式,门槛极低,拿来即用.其外在极简,却又不失饱满的内在,体积轻盈,组件丰盈 ...

  8. BUI框架使用步骤(前端框架)

    一.介绍 BUI 是用来快速构建界面交互的UI框架, 专注webapp开发. 二.使用 官方文档: https://imouou.github.io/BUI-Guide/#/ https://www. ...

  9. JAVAEE框架技术之8-myBatis ORM框架技术参数和动态SQL语句

    parameterType 传入多个参数 确切点是方法的输入参数,一般都是采用直接使用pojo类.此时在mapper.xml文件中的SQL语句不用再写parameterType属性,而是用arg0,a ...

  10. 前后端分离技术——前端框架

    本文主要介绍前后端分离技术--前端框架. 一.前端框架 前端框架均为近年新兴技术,包括:业务相关.环境相关等方面.从组件化.可视化.信息化.扁平化.数据驱动等多角度设计架构.以用户体验为原则,综合业务 ...

最新文章

  1. 微信程序跳转到页面底部 scroll-view
  2. OpenCV 霍夫线检测
  3. 【数据分析】干货!一文教会你 Scrapy 爬虫框架的基本使用
  4. virtualbox - 2台虚拟机之间通过ssh互访
  5. Google 发布网页统计报告
  6. stm32 usb 虚拟串口 相同_为什么说你要学习USB?(一)
  7. █年薪20万招聘软件工程师!!!
  8. linux 内存使用原理,linux中内存使用原理
  9. linux io测试陈旭,130242014076+陈旭+第2次实验(示例代码)
  10. Spring基础——在 Spring Config 文件中基于 XML 的 Bean 的自动装配
  11. C++之强制转换const_cast、static_cast、dynamic_cast、reinterpret_cast 、dynamic_cast
  12. 任正非称华为 6G 领先世界;支付宝小程序将与微博打通;Linux Kernel 5.3 发布 | 极客头条...
  13. Regional Proposal的输出到底是什么
  14. 今天是有纪念意义的一天--中国13亿人口日
  15. vrchat模型房_vrchat人物模型 1.0 官方版
  16. Android 扫码盒子全局接收付款码
  17. MATLAB批量读取航摄相片EXIF信息和GNSS信息以及MATLAB批量经纬度坐标转换空间直角坐标
  18. Power Query|M函数:数据类型及数据结构
  19. IC模拟版图工程师高薪进阶之路,三年实现年薪30w+
  20. html中自动旋转90度,如何将HTML旋转90度?

热门文章

  1. android实现语音聊天功能,为实现Android语音聊天室开发,语音聊天室软件源码该如何搭建...
  2. matlab实现振动弹簧的实时动画,仿真动画软件设计作品--理想弹簧振子简谐振动...
  3. android 悬浮窗口透明,基于popupWindow实现悬浮半透明效果
  4. 公司专利技术交底书撰写及申请完全流程
  5. 如何理解T检验和P值
  6. html提示更新浏览器的代码,IE9及以下浏览器升级提示
  7. The StL Format(StL 格式)
  8. matlab qpsk 星座图,QPSK误码率和星座图MATLAB仿真
  9. 微信公众号内推送模板消息
  10. 51单片机TMOD及定时器配置