软件开发整体介绍

该文章已生成可运行项目,

# 软件开发整体介绍

一 软件开发

1.1 开发流程

  1. 需求分析:需求规格说明书、产品原型

  2. 设计:UI设计、数据库设计、接口设计

  3. 编码:项目代码、单元测试

  4. 测试:测试用例、测试报告

  5. 上线运维:软件环境安装、配置

1.2 角色分工

  • ‌软件工程师/开发人员‌:负责编写代码,根据需求和设计规格开发软件的各个模块和功能。他们使用编程语言和开发工具实现软件功能,确保代码的质量和性能‌。

  • ‌软件测试工程师‌:负责对软件进行测试,发现潜在的错误和问题。他们编写测试用例、执行测试,并与开发人员合作解决问题‌。

  • ‌软件架构师‌:负责设计软件的整体架构和结构。他们决定软件的组织方式、模块划分和交互方式,确保软件具有良好的可扩展性和可维护性‌。

  • ‌产品经理/业务分析师‌:与客户或用户沟通,了解需求和业务目标,将需求转化为明确的需求规格和用户故事,作为开发的指导依据‌。

  • ‌UI/UX设计师‌:负责设计软件的用户界面和用户体验。他们确保软件界面友好、易用,提升用户的满意度和体验‌。

  • ‌数据库管理员‌:负责设计、配置和管理软件所需的数据库系统。他们确保数据的安全性和完整性,以及数据库的高性能和可靠性‌。

  • ‌运维工程师‌:负责部署和维护软件系统,确保软件在目标环境中稳定运行,并及时响应和解决系统故障‌。

  • ‌项目经理‌:负责整个软件开发项目的计划、协调和管理。他们确保项目按时交付、控制项目成本,同时处理团队间的沟通和冲突‌。

1.3 软件环境

  • 开发环境(Development Environment)是软件开发人员用于编写、调试和测试代码的环境。它是软件生命周期中最先接触的环境,通常由开发人员自行搭建和管理。

  • 测试环境(Testing Environment)是用于执行各种测试活动的环境,包括单元测试、集成测试、系统测试和用户验收测试等。测试环境的目标是验证软件的功能、性能和稳定性,确保软件在发布前达到预期的质量标准。

  • 生产环境(Production Environment)是软件正式运行和对外提供服务的环境。它是软件生命周期中最终交付的环境,直接面向用户,因此对稳定性、性能和安全性有极高的要求。

1.4 技术选型

展示项目中使用到的技术框架和中间件等。例如:

用户层:node.js、VUE.js、ElementUi、微信小程序、apache echarts等 网关层:Nginx等 应用层:Spring Boot、Spring MVC、Spring Task、httpclient、Spring Cache、JWT、阿里云OSS、Swagger、POI、WebSocket等 数据层:MySQL、Redis、mybatis、pagehelper、spring data redis等

二 项目结构与工具

1.1 单体架构与微服务

1.2 项目结构

1.2.1 前端项目结构

  • node_modules:当前项目依赖的js包

  • assets:静态文件

  • components:公共组件

  • views:存放页面级组件

  • router:路由配置

  • App.vue:项目的主组件,页面的入口文件

  • main.js:整个项目的入口文件

  • .gitignore:配置git文件

  • babel.config.js:兼容文件

  • package.json:项目的配置信息、依赖包管理文件

  • README.md:项目介绍文件

  • vue.config.js:vue-cli配置文件,如端口号等

VUE

VUE是一款用于构建用户界面的渐进式的JavaScript框架。它基于标准 HTML、CSS 和 JavaScript 构建,并提供了一套声明式的、组件化的编程模型,帮助你高效地开发用户界面。无论是简单还是复杂的界面,Vue 都可以胜任。

HTML、CSS、JavaScript、axios、vue基本语法、ElementUI、router、vuex、typescript

  • node.js:前端项目的运行环境,node -v

  • npm:JavaScript的包管理高级, npm -v

  • Vue CLI:快速项目脚手架,npm i @vue/cli -g,vue --version,安装 | Vue CLI

  • Ant Design:快速开发UI界面库,Ant Design of React - Ant Design

  • Element-plus:开始开发UI界面库,https://element-plus.org/zh-CN/guide/installation.html

  • Axios:向后端请求库

  • Pinia:维护前端全局/共享数据

  • 驱动工程化:ESLint(确保代码的规范) + Prettier(自动格式化代码) + TypeScript(增强js),保证前端项目开发规范

Vue CLI工具使用
npm init vue@latest
# 或者
vue create 项目名称
# 或者
vue ui

npm run serve
npm run serve -- --port 9000 # 指定端口
Ctrl + C # 停止

npm install axios
npm install -save acios vue-axios # 安装vue-axios
npm install sass -D
npm install antd --save
npm install element-plus --save

npm run build # 打包
npm install # 安装依赖
npm run dev # 工程启动
//vue.config.js文件
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
  transpileDependencies: true,
  devServer: {
    port: 7000,
    proxy: { // 解决跨域问题
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
        pathRewrite: {
          '^/api': ''
        }
      }
    }
  }
})
vue基础
  • vue组件:由template、scrip、style三部分组成

  • 文字插值:{{插值表达式}},绑定script中提供的数据

  • 属性绑定:属性名,为标签属性绑定script中提供的数据

  • 事件绑定:@事件,为页面元素绑定事件

  • 双向绑定:v-model,表单输入项和script中提供的数据进行双向绑定

  • 条件渲染:v-if、v-else,根据表达式的值动态展示页面元素

  • axios:发送各种方式的http请求

API风格
<script setup>
// 推荐组合式 API (Composition API)
import { ref, onMounted } from 'vue'
// 响应式状态
const count = ref(0)
// 用来修改状态、触发更新的函数
function increment() {
  count.value++
}
// 生命周期钩子
onMounted(() => {
  console.log(`The initial count is ${count.value}.`)
})
</script>
<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>

<script>
// 选项式 API (Options API)
export default {
  // data() 返回的属性将会成为响应式的状态
  // 并且暴露在 `this` 上
  data() {
    return {
      count: 0
    }
  },
  // methods 是一些用来更改状态与触发更新的函数
  // 它们可以在模板中作为事件处理器绑定
  methods: {
    increment() {
      this.count++
    }
  },

  // 生命周期钩子会在组件生命周期的各个不同阶段被调用
  // 例如这个函数就会在组件挂载完成后被调用
  mounted() {
    console.log(`The initial count is ${this.count}.`)
  }
}
</script>
<template>
  <button @click="increment">Count is: {{ count }}</button>
</template>
Vue-Router
  1. 安装router,npm install vue-router@next

  2. 创建路由配置,router/index.js或者.ts

  3. 在主文件中引入路由,main.js或者ts

  4. 设置视图组件,<router-view>

// index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home
  },
  {
    path: '/about',
    name: 'About',
    component: About
    //children可以嵌套路由
  }
]

const router = createRouter({
  history: createWebHistory(process.env.BASE_URL),
  routes
})

export default router

// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')
<template>
  <div id="app">
    <router-link to="/">Home</router-link> |
    <router-link to="/about">About</router-link>
    <router-view></router-view>
  </div>
</template>
vuex状态管理

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。Vuex 可以帮助我们管理共享状态,并附带了更多的概念和框架。

npm install vuex@next --save

  • state:状态对象,集中定义各个组件共享的数据

  • mutations:类似于一个事件,用于修改共享数据,要求必须是同步函数

  • actions:类似于mutation,可以包含异步操作,通过调用mutation来改变共享数据

vuex嗯嗯...过时了,白雪


Pinia状态管理库

现在登场的是Pinia!!!,Vue 官方团队推荐的新一代状态管理22库。Pinia 被设计为 Vuex 的现代替代品,它不仅支持 Vue 2 和 Vue 3,而且在设计理念、API 设计以及·· TypeScript 支持上都有显著的改进。

npm install pinia

  • 简便直观:像定义组件一样定义 store,使得状态管理变得更加直观和易于理解。

  • 类型安全:对 TypeScript 提供了非常好的支持,包括自动完成功能在内的类型推断,可以减少错误并提高开发效率。

  • Composition API 风格:提供 Composition API 风格的 API,与 Vue 3 的特性紧密结合。

  • 模块化设计:通过创建多个 store 文件来实现多模块化,有利于代码组织和维护。

  • 轻量级:体积非常小,只有大约 1kb 的大小。

  • 服务器端渲染支持:确保可以在服务端渲染的应用中使用。

  • Vue Devtools 支持:提供良好的调试体验。

  • 灵活的状态修改:不像 Vuex 那样严格区分 mutations 和 actions,Pinia 中可以直接在 actions 中修改 state,简化了操作。

Pinia是Vue的专属状态管理库,它允许你跨组件或页面共享状态,例如:管理token,使用 Pinia 这样的状态管理库可以大大简化 token 管理的复杂性,提供更好的开发体验。它不仅帮助保持代码的整洁性和可维护性,还确保了应用状态的一致性和安全性。对于需要跨多个组件共享和管理的状态,像 Pinia 这样的工具是不可或缺的。

  1. 安装导入pinia

  2. 在src/stores/token.js中定义store

  3. 在组件中使用store

// main.js
import { createPinia } from 'pinia'
const pinia = createPinia()
app.use(pinia)

// token.js
import { defineStore } from "pinia";
import { ref } from 'vue';
/*
	此模块用于管理应用程序中的身份验证令牌(token)
*/
export const useTokenStore = defineStore('token',()=>{
    const token = ref('')
    const setToken = (newToken)=>{
        token.value = newToken
    }
    const removeToken = ()=>{
        token.value=''
    }
    return {
        token,setToken,removeToken
    }
})

// Login.vue
imprort { useTokenStore } from '@/stores/token.js'
const tokenStore = useTokenStore();
//tokenStore.token、tokenStore.setToken()、tokenStore.removeToken()

饿饿饿...下午茶

Axios前后端数据交互技术

Axios 是一个基于 Promise 的 HTTP 客户端,可以用于浏览器和 Node.js 环境中。它允许你发起 HTTP 请求并与后端服务进行数据交互。注意是:JSON格式,code是状态码,一般数据放在data下面,调用用 . 例如:data.id,官网:axios中文文档|axios中文网 | axios

{
 "code": 0,
 "data": {
     "id": 1,
     "name": "千整高",
     "photoUrls": [
         "http://dummyimage.com/200x200"
     ],
     "category": {
         "id": 5992662872598646,
         "name": "Cat"
     },
     "tags": [
         {
             "id": 6851322438797908,
             "name": "cat"
         }
     ],
     "status": "available"
 }
}

npm install axios

嗯嗯...怎么写了 我教你啊

// Ajax不支持这种params,原生URL的方式axios.get(url).then(res=>...).catch(error=>...).catch(error=>...)
// params的方式axios.get(url,{参数...}).then(res=>...).catch(error=>...)
// get、post、put、patch、delete官网文档是最好的教程
axios.get("https://apifoxmock.com/m1/5593675-5271906-default/pet/1")
 .then((response) => {
 // 如果服务器返回的状态码是0,表示请求成功,
 if (response.data.code === 0) {
     // 保存数据到pet对象中
     this.pet = response.data.data;
 } else {
     this.error = "请求成功,但服务器返回了错误的状态码";
 }
 this.loading = false;
})
 .catch((error) => {
 console.error("请求失败,错误信息如下:", error);
 this.error = "无法获取宠物信息,请稍后再试。";
 this.loading = false;
});
typescript

TypeScript是微软开发的一个开源的编程语言,通过在JavaScript的基础上添加静态类型定义构建而成。TypeScript通过TypeScript编译器或Babel转译为JavaScript代码,可运行在任何浏览器,任何操作系统。

npm install -g typescript

1.2.2 后端项目结构

由于单体结构可以拆成多个小微服务,这里只是微服务也是一样的,建议使用java11以上。

Maven父工程,统一管理依赖版本,聚合其他子模块 common子模块,存放公共类,例如:工具类、常量类、异常类等(constant、context、enumeration、exception、json、properties、result、utils) pojo子模块,存放实体类(Entity)、VO、DTO、POJO等 server子模块,后端服务,存放配置文件、配置类(config)、拦截器(interceptor)、Controller、Service、Mapper、handler等

SpringBoot部署
<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <version>3.1.3</version>
</plugin>

配置优先级:

  • 项目中的application.yml

  • jar包目录下的application.yml

  • 环境变量

  • 命令行

mvn package
java -jar jar包位置 --server.port=端口号
# 必须要jre环境
多环境开发

单文件:

  • --- 分隔环境

  • spring.config.activate.on-profile 配置环境

  • spring.profiles.active 激活环境

分组:

  • application-分类名.yml

  • spring.profiles.group:“dev”: 组名

  • spring.profiles.active

#application.yml通用环境
spring:
  profiles:
    active: test
server:
  servlet:
    context-path: /api
#application-dev.yml开发环境
server:
  port: 8081
#application-test.yml测试环境
server:
  port: 8082
#application-dev.pro生产环境
server:
  port: 8083
宝塔面板

宝塔面板是一款服务器管理软件,可以通过Web端轻松管理服务器,提升运维效率。例如:创建管理网站、FTP、数据库,拥有可视化文件管理器,可视化软件管理器,可视化CPU、内存、流量监控图表,计划任务等功能。

面板支持windows和linux系统,不过,有些功能在Windows上是不支持的。强烈建议在Linux系统使用。

另外,宝塔的大部分功能都是免费使用。满足日常的服务器运维管理需求。

在线安装:宝塔面板下载,免费全能的服务器运维软件

# Centos/OpenCloud/Alibaba稳定版9.0.0
url=https://download.bt.cn/install/install_lts.sh;if [ -f /usr/bin/curl ];then curl -sSO $url;else wget -O install_lts.sh $url;fi;bash install_lts.sh ed8484bec
# Ubuntu/Deepin 安装脚本稳定版9.0.0
wget -O install.sh https://download.bt.cn/install/install_lts.sh && sudo bash install.sh ed8484bec

1.3 Git与Nginx

1.3.1 GIT版本控制

开源不仅是一种软件开发模式,更是一种独特的文化和精神。它代表了无数开发者的共同努力,致力于让知识在更广泛的范围内传播,让技术在无尽的想象中发展。然而,开源的世界并非仅仅由美好的愿景构成,它也面临着种种现实的挑战。对于每一位参与者而言,开源不仅仅是代码的贡献,更是自我价值的体现与责任的承担,开源不等于免费。

Git是一个开源的分布式版本控制系统,用以有效、高速的处理从很小到非常大的项目版本管理。 Git的特点:分支更快、更容易。 支持离线工作;

  • 创建Git本地仓库

  • 创建Git远程仓库(推荐Gitee、GitHub)

  • 将本地文件推送到Git远程仓库

1.3.2 Nginx反向代理配置

Nginx (engine x) 是一个 高性能HTTP反向代理 web服务器,同时也提供了IMAP/POP3/SMTP服务

Nginx是一款轻量级的Web 服务器 / 反向代理服务器 及 电子邮件(IMAP/POP3)代理服务器,在BSD-like 协议下发行。其特点是占有内存少,并发能力强,事实上 nginx 的并发能力在同类型的网页服务器中表现较好,中国大陆使用nginx网站用户有:百度、京东、新浪、网易、腾讯、淘宝等。

Nginx 是高性能的HTTP和反向代理的web服务器,处理高并发能力是十分强大的,能经受高负载的考验,有报告表明能支持高达50,000个并发连接数。

Nginx支持 热部署,启动简单,可以做到7*24不间断运行。几个月都不需要重新启动。

    总而言之,Nginx是一个高性能、灵活和可扩展的Web服务器和代理服务器,适用于各种场景,包括静态文件服务、反向代理、负载均衡和动态内容处理等。

#http://localhost/api/employee/login ==> NGINX ==> http://localhost:8080/admin/employee/login
server{
    listen 80;
    server_name localhost;
    
    location /api/ {
        proxy_pass http://localhost:8080/admin/; #反向代理
    }
}
#nginx负载均衡
#策略有:轮询(默认)、weight(权重)、ip_hash(IP分配)、leasr_conn(最小连接)、url_hash(url分配)、fair(响应时间)
upstream webservers{
    server IP地址1:端口1 策略;
    server IP地址2:端口2;
    ...
}
server{
    listen 80;
    server_name localhost;
    
    location /api/ {
        proxy_pass http://webservers/admin/; #负载均衡
    }
}

1.4 常见的工具与方法

工具

  • Postman 是一个 Chrome 扩展,提供功能强大的 Web API & HTTP 请求调试。它能够发送任何类型的 HTTP 请求 (GET, HEAD, POST, PUT..),附带任何数量的参数 + headers。

  • YApi是设计阶段使用的工具,管理和维护接口。

方法

//MD5加密
password = DigestUtils.md5DigestAsHex(password.getBytes());
Lombok

通过注解自动生成常见的代码,如getter和setter方法、构造函数、toString方法、equals和hashCode方法等,从而简化Java开发过程,减少样板代码的编写,提高代码的可读性和可维护性‌。‌

官方地址:http://projectlombok.org

提醒:java17以后记录类型可以快速得到一个自带构造方法、Getter以及重写ToString等方法的类:

public record Accout(int id,String name,int age,String gender,String password,String description){
}
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
@Getter
@Setter
@AllArgsConstructor
public class Student{
    private Integer sid;
    private String name;
    private String sex;
}

基本注解
  • @Getter@Setter:用于自动生成getter和setter方法。

  • @ToString:自动生成toString方法,方便调试输出。

  • @EqualsAndHashCode:自动生成equals和hashCode方法。

  • @Data:在类上使用,提供所有属性的getter、setter、toString、equals和hashCode方法。

  • @Value:与@Data类似,但会将所有成员变量默认定义为private final,并且不会生成setter方法。

构造器注解
  • @NoArgsConstructor:生成无参构造函数。

  • @AllArgsConstructor:生成包含所有属性的构造函数。

  • @RequiredArgsConstructor:生成包含final字段和非空字段的构造函数。

其他常用注解
  • @Builder:用于生成流式API的构建器模式。

  • @Slf4j:自动生成log静态常量,用于日志记录。

  • @NonNull:标记非空约束,帮助检测空值。

  • @Cleanup:自动调用close方法,简化资源管理。

  • @SneakyThrows:忽略检查异常声明,直接抛出异常。

Builder
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BusinessDataVO implements Serializable {
 
    private String name;//姓名
 
    private Integer validOrderCount;//有效订单数
 
    private Double orderCompletionRate;//订单完成率
 
    private Double unitPrice;//平均客单价
 
    private Integer newUsers;//新增用户数
 
}
//用法
BusinessDataVO bd=new BusinessDataVO();
 bd.build()
    .name("张三")
    .unitPrice(5.8)
    .build();
Slf4j

@Slf4j 是 Lombok 提供的一种注解,用于在类中自动生成一个名为 log 的日志对象。通过使用 @Slf4j 注解,可以方便地在代码中使用日志功能,而无需手动创建和初始化日志对象。

import lombok.extern.slf4j.Slf4j;
 
@Slf4j
 
public class MyClass {
    public void myMethod() {
        // 日志记录示例
        log.info("This is an informational message");
        log.debug("Debug message with parameters: {}, {}", param1, param2);
        log.error("Error occurred", exception);
    }
}
Swagger

Swagger 是一个规范和完整的框架,用于生成、描述、调用和可视化 RESTful 风格的 Web 服务

官网:https://swagger.io/

  1. 导入knife4j的maven坐标

  2. 在配置类中加入knife4j相关配置

  3. 设置静态资源映射,否则接口文档页面无法访问

Swagger在开发阶段使用的框架,帮助后端开发人员做后端的接口测试

<!-->Knife4j是为java MVC框架集成Swagger生成Api文档的增强解决方案</-->
<dependency>
    <groupId>com.github.xiaoymin</groupId>
    <artifactId>knife4j-spring-boot-starter</artifactId>
</dependency>
/**
 * 通过knife4j生成接口文档
 * @return
 */
@Bean
public Docket docket() {
    log.info("开始通过knife4j生成接口文档...");
    ApiInfo apiInfo = new ApiInfoBuilder()
            .title("项目接口文档")
            .version("2.0")
            .description("项目接口文档")
            .build();
    Docket docket = new Docket(DocumentationType.SWAGGER_2)
            .groupName("管理端接口")
            .apiInfo(apiInfo)
            .select()
            //指定生成接口文档需要扫描的包路径
            .apis(RequestHandlerSelectors.basePackage("com.sky.controller"))
            .paths(PathSelectors.any())
            .build();
    return docket;
}
/**
 * 设置静态资源映射
 * @param registry
 */
protected void addResourceHandlers(ResourceHandlerRegistry registry) {
    log.info("开始设置静态资源映射...");
    //访问swagger文档路径:http://localhost:8080/doc.html
    registry.addResourceHandler("/doc.html").addResourceLocations("classpath:/META-INF/resources/");
    registry.addResourceHandler("/webjars/**").addResourceLocations("classpath:/META-INF/resources/webjars/");
}
常见注释
  1. @Api:用在类上,例如Controller,表明对类的说明。

  2. @ApiModel:用在类上,例如entity、DTO、VO。

  3. @ApiModelProperty:用在属性上,描述属性信息。

  4. @ApiOperation:用在方法上,例如Controller的方法,说明方法的用途、作用。

@Api(tags = "相关接口")
@ApiModel(description = "员工登录返回的数据格式")
TheradLocal

ThreadLoal 变量,线程局部变量。

  1. 线程隔离:每个线程都拥有自己的变量副本,线程之间的变量副本互不影响,从而避免了多线程操作共享资源造成的数据不一致问题。

  2. 线程安全:由于每个线程只能访问自己的变量副本,因此不需要额外的同步机制来保证线程安全。

  3. 减少参数传递:在复杂的业务逻辑中,使用ThreadLocal可以避免在多个方法之间频繁传递参数,从而简化代码。

常见方法
  • public void ser(T value):设置当前线程的线程局部变量的值

  • public T get():返回当前线程对应的线程局部变量的值

  • public void remove():移除当前线程的线程局部变量

三层架构

表示层Controller

负责处理用户界面和用户交互,通常包括Controller层。

Controller层是表现层的一部分,负责处理HTTP请求和响应。它的主要职责包括:

  • 接收请求:接收客户端的HTTP请求,解析请求参数。

  • 调用业务逻辑:调用Service层的方法处理业务逻辑。

  • 返回响应:将处理结果封装为HTTP响应返回给客户端。

Controller层通常使用Spring MVC框架提供的注解来定义请求映射、参数绑定和响应处理。以下是一个简单的Controller示例:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController //等于@Controller+@ResponseBody
@RequestMapping("/admin")
public class UserController {
    
    @Autowired //依赖注入
    private UserService userService;
    
    @GetMapping("/user")
    public Result getUser(@RequestParam String userId) { //Result(cod,msg,data)
        return Result.success(userService.getUserById(userId));
}
    //在这个示例中,UserController通过@GetMapping注解定义了一个GET请求映射,通过@RequestParam注解解析请求参数,并调用UserService的方法处理业务逻辑,最后一般返回JSON数据处理结果。
请求参数传递
  • 原始传递:形参定义为HttpServletRequest,通过request.getParameter("名称")接收

  • SpingBoot方法:直接定义但名字要相同(实体的属性保持POJO接收),可以用@RequestParam(name="name")保持

  • @Request注解:required属性默认true必须传递,否则报错,集合接收须用本注解绑定

  • @DateTimeFormat注解:日期参数,pattern="yyyy-MM-dd HH:mm:ss"属性可以修改格式,通常跟LocalDateTime类型

  • @RequestBody:JSON参数,形参实体接收,数据键名与对象属性名相同

  • @PathVariable:路径参数,要使用{...}来标识参数,形参直接定义即可接收(多个跟着加/{...}和形参)

POJO类

POJO 是 DTO、VO 等统称,禁止命名成 xxxPOJO

VO:视图对象,主要用于前端界面显示的数据,是与前端进行交互的对象。

DTO:据传输对象是在传递给前端时使用的, 数据传输对象比较特殊,之所以将 DTO 绘制在 视图层业务逻辑层 之间,是因为它有两种存在形式:

  • 前端:它是以 Json 字串的形式存在。

  • 后端:它是以对象的形式存在。

entity:普通实体对象,通常和数据库对应

业务逻辑Servlet

负责处理业务逻辑和规则,通常包括Service层。通常要设置接口,实体impl

Service层是业务逻辑层的一部分,负责处理业务逻辑和规则。它的主要职责包括:

  • 业务逻辑处理:实现具体的业务逻辑和规则。

  • 事务管理:管理数据库事务,确保数据一致性。

  • 调用数据访问层:调用Mapper层的方法进行数据访问。

Service层通常使用Spring框架提供的注解来定义业务逻辑和事务管理。以下是一个简单的Service示例:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    @Transactional
    public User getUserById(String userId) {
        return userMapper.getUserById(userId);
    }
}
	//在这个示例中,UserService通过@Service注解定义了一个Service组件,通过@Transactional注解管理事务,并调用UserMapper的方法进行数据访问。
数据访问层Mapper

负责与数据库或其他数据存储进行交互,通常包括Mapper层。

Mapper层是数据访问层的一部分,负责与数据库或其他数据存储进行交互。它的主要职责包括:

  • 数据访问:执行数据库查询、插入、更新和删除操作。

  • 数据映射:将数据库记录映射为Java对象,或将Java对象映射为数据库记录。

Mapper层通常使用MyBatis或Spring Data JPA等ORM框架来实现数据访问和映射。以下是一个简单的Mapper示例:

import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;

@Mapper
public interface UserMapper {

    @Select("SELECT * FROM users WHERE id = #{userId}")
    User getUserById(String userId);
}
	//在这个示例中,UserMapper通过@Mapper注解定义了一个Mapper接口,通过@Select注解定义了一个SQL查询,并将查询结果映射为User对象。
IOC&DI

IOC容器管理:

  • @Component,不属于Controller、Service、Dao用,工具类

  • @Controller,标注控制类

  • @Service,标注业务类

  • @Repository,标注数据访问类(由于与mybatis整合,用的少)

  • @ComponentSca({路径}),Bean扫描,@SpringBootApplication里面包含

DI依赖注入:

  • @Autowired,默认类型注入,spring提供

  • @Primary,冲突时当前类注入

  • @Qualifier(名称),指定类型注入,配合@Autowired用

  • @Resource(name=名称),指定名称注入,jdk提供

文件上传

前端页面三要素:method="post" enctype="multipart/form-data" type="file"

后端接收文件:MultipartFile

<form action="/upload" method="post" enctype="multipart/form-data">
    姓名:<input type="text" name="username"><br>
    年龄: <input type="text" name="age"><br>
    头像: <input type="file" name="image"><br>
    <input type="submit" value="提交">
</form>
@RestController
public class Uploadcontroller {
    @PostMapping("/upload")
    public Result upload(String username , integer age, MultipartFile image){
        return Result.success();
    }
}
本地存储
@RestController
public class Uploadcontroller {
    @PostMapping("/upload")
    public Result upload(MultipartFile image) throws IOException{
        //获取原始文件名
        String originalFilename = image.getOriginalFilename();
        //构建新的文件名
        String newFileName = UUID.randomUUID().toString() + originalFilename.substring(originalFilename.lastindexOf("."));
        //将文件保存在服务器端 E:/images/目录下
        image.transferTo(new File("E:/images/" + newFileName));
        return Result.success();
    }
}
<template>
  <div class="component-upload-image">
    <el-upload
      multiple
      :action="uploadImgUrl"
      list-type="picture-card"
      :on-success="handleUploadSuccess"
      :before-upload="handleBeforeUpload"
      :limit="limit"
      :on-error="handleUploadError"
      :on-exceed="handleExceed"
      ref="imageUpload"
      :before-remove="handleDelete"
      :show-file-list="true"
      :headers="headers"
      :file-list="fileList"
      :on-preview="handlePictureCardPreview"
      :class="{ hide: fileList.length >= limit }"
    >
      <el-icon class="avatar-uploader-icon"><plus /></el-icon>
    </el-upload>
    <!-- 上传提示 -->
    <div class="el-upload__tip" v-if="showTip">
      请上传
      <template v-if="fileSize">
        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
      </template>
      <template v-if="fileType">
        格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
      </template>
      的文件
    </div>

    <el-dialog
      v-model="dialogVisible"
      title="预览"
      width="800px"
      append-to-body
    >
      <img
        :src="dialogImageUrl"
        style="display: block; max-width: 100%; margin: 0 auto"
      />
    </el-dialog>
  </div>
</template>

<script setup>
import { getToken } from "@/utils/auth";

const props = defineProps({
  modelValue: [String, Object, Array],
  // 图片数量限制
  limit: {
    type: Number,
    default: 5,
  },
  // 大小限制(MB)
  fileSize: {
    type: Number,
    default: 5,
  },
  // 文件类型, 例如['png', 'jpg', 'jpeg']
  fileType: {
    type: Array,
    default: () => ["png", "jpg", "jpeg"],
  },
  // 是否显示提示
  isShowTip: {
    type: Boolean,
    default: true
  },
});

const { proxy } = getCurrentInstance();
const emit = defineEmits();
const number = ref(0);
const uploadList = ref([]);
const dialogImageUrl = ref("");
const dialogVisible = ref(false);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadImgUrl = ref(import.meta.env.VITE_APP_BASE_API + "/common/upload"); // 上传的图片服务器地址
const headers = ref({ Authorization: "Bearer " + getToken() });
const fileList = ref([]);
const showTip = computed(
  () => props.isShowTip && (props.fileType || props.fileSize)
);

watch(() => props.modelValue, val => {
  if (val) {
    // 首先将值转为数组
    const list = Array.isArray(val) ? val : props.modelValue.split(",");
    // 然后将数组转为对象数组
    fileList.value = list.map(item => {
      if (typeof item === "string") {
        if (item.indexOf(baseUrl) === -1 && item.indexOf("http") === -1) {
          item = { name: baseUrl + item, url: baseUrl + item };
        } else {
          item = { name: item, url: item };
        }
      }
      return item;
    });
  } else {
    fileList.value = [];
    return [];
  }
},{ deep: true, immediate: true });

// 上传前loading加载
function handleBeforeUpload(file) {
  let isImg = false;
  if (props.fileType.length) {
    let fileExtension = "";
    if (file.name.lastIndexOf(".") > -1) {
      fileExtension = file.name.slice(file.name.lastIndexOf(".") + 1);
    }
    isImg = props.fileType.some(type => {
      if (file.type.indexOf(type) > -1) return true;
      if (fileExtension && fileExtension.indexOf(type) > -1) return true;
      return false;
    });
  } else {
    isImg = file.type.indexOf("image") > -1;
  }
  if (!isImg) {
    proxy.$modal.msgError(
      `文件格式不正确, 请上传${props.fileType.join("/")}图片格式文件!`
    );
    return false;
  }
  if (props.fileSize) {
    const isLt = file.size / 1024 / 1024 < props.fileSize;
    if (!isLt) {
      proxy.$modal.msgError(`上传头像图片大小不能超过 ${props.fileSize} MB!`);
      return false;
    }
  }
  proxy.$modal.loading("正在上传图片,请稍候...");
  number.value++;
}

// 文件个数超出
function handleExceed() {
  proxy.$modal.msgError(`上传文件数量不能超过 ${props.limit} 个!`);
}

// 上传成功回调
function handleUploadSuccess(res, file) {
  if (res.code === 200) {
    uploadList.value.push({ name: res.fileName, url: res.fileName });
    uploadedSuccessfully();
  } else {
    number.value--;
    proxy.$modal.closeLoading();
    proxy.$modal.msgError(res.msg);
    proxy.$refs.imageUpload.handleRemove(file);
    uploadedSuccessfully();
  }
}

// 删除图片
function handleDelete(file) {
  const findex = fileList.value.map(f => f.name).indexOf(file.name);
  if (findex > -1 && uploadList.value.length === number.value) {
    fileList.value.splice(findex, 1);
    emit("update:modelValue", listToString(fileList.value));
    return false;
  }
}

// 上传结束处理
function uploadedSuccessfully() {
  if (number.value > 0 && uploadList.value.length === number.value) {
    fileList.value = fileList.value.filter(f => f.url !== undefined).concat(uploadList.value);
    uploadList.value = [];
    number.value = 0;
    emit("update:modelValue", listToString(fileList.value));
    proxy.$modal.closeLoading();
  }
}

// 上传失败
function handleUploadError() {
  proxy.$modal.msgError("上传图片失败");
  proxy.$modal.closeLoading();
}

// 预览
function handlePictureCardPreview(file) {
  dialogImageUrl.value = file.url;
  dialogVisible.value = true;
}

// 对象转成指定字符串分隔
function listToString(list, separator) {
  let strs = "";
  separator = separator || ",";
  for (let i in list) {
    if (undefined !== list[i].url && list[i].url.indexOf("blob:") !== 0) {
      strs += list[i].url.replace(baseUrl, "") + separator;
    }
  }
  return strs != "" ? strs.substr(0, strs.length - 1) : "";
}
</script>

<style scoped lang="scss">
// .el-upload--picture-card 控制加号部分
:deep(.hide .el-upload--picture-card) {
    display: none;
}
</style>
@PostMapping("upload")
    public R<SysFile> upload(MultipartFile file,String fileCategory)
    {
        try
        {
            // 上传并返回访问地址
            Map<String,Object> map = sysFileService.uploadFile(file,fileCategory);
            Long id = (Long) map.get("id");
            String url = map.get("url").toString();
            SysFile sysFile = new SysFile();
            sysFile.setId(id);
            sysFile.setName(FileUtils.getName(url));
            sysFile.setUrl(url);
            return R.ok(sysFile,"上传文件成功");
        }
        catch (Exception e)
        {
            log.error("上传文件失败", e);
            return R.fail(e.getMessage());
        }
    }

阿里云OSS

Spring Data Redis

  1. 导入maven坐标

  2. 配置Redis数据源

  3. 编写配置类,创建RedisTemplate对象

  4. 通过RedisTemplate对象操作Redis

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
spring:
  redis:
    host: localhost
    port: 6379
    password: 123456
@Configuration
@Slf4j
public class RedisConfiguration {
    @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
        log.info("初始化RedisTemplate");
        RedisTemplate redisTemplate = new RedisTemplate();
        // 配置连接工厂
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        // 设置key的序列化方式
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

JavaSE多线程与反射

多线程

注意: 本章节会涉及到 操作系统 相关知识。

在了解多线程之前,让我们回顾一下操作系统中提到的进程概念:

b040eadb-8aa1-4b2a-b587-2c0a6b4efa0b

进程是程序执行的实体,每一个进程都是一个应用程序(比如我们运行QQ、浏览器、LOL、网易云音乐等软件),都有自己的内存空间,CPU一个核心同时只能处理一件事情,当出现多个进程需要同时运行时,CPU一般通过时间片轮转调度算法,来实现多个进程的同时运行。

image-20221004132729868

在早期的计算机中,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。但是,如果我希望两个任务同时进行,就必须运行两个进程,由于每个进程都有一个自己的内存空间,进程之间的通信就变得非常麻烦(比如要共享某些数据)而且执行不同进程会产生上下文切换,非常耗时,那么能否实现在一个进程中就能够执行多个任务呢?

image-20221004132700554

后来,线程横空出世,一个进程可以有多个线程,线程是程序执行中一个单一的顺序控制流程,现在线程才是程序执行流的最小单元,各个线程之间共享程序的内存空间(也就是所在进程的内存空间),上下文切换速度也高于进程。

在Java中,我们从开始,一直以来编写的都是单线程应用程序(运行main()方法的内容),也就是说只能同时执行一个任务(无论你是调用方法、还是进行计算,始终都是依次进行的,也就是同步的),而如果我们希望同时执行多个任务(两个方法同时在运行或者是两个计算同时在进行,也就是异步的),就需要用到Java多线程框架。实际上一个Java程序启动后,会创建很多线程,不仅仅只运行一个主线程:

public static void main(String[] args) {
    ThreadMXBean bean = ManagementFactory.getThreadMXBean();
    long[] ids = bean.getAllThreadIds();
    ThreadInfo[] infos = bean.getThreadInfo(ids);
    for (ThreadInfo info : infos) {
        System.out.println(info.getThreadName());
    }
}

关于除了main线程默认以外的线程,涉及到JVM相关底层原理,在这里不做讲解,了解就行。

线程的创建和启动

通过创建Thread对象来创建一个新的线程,Thread构造方法中需要传入一个Runnable接口的实现(其实就是编写要在另一个线程执行的内容逻辑)同时Runnable只有一个未实现方法,因此可以直接使用lambda表达式:

@FunctionalInterface
public interface Runnable {
    /**
     * When an object implementing interface <code>Runnable</code> is used
     * to create a thread, starting the thread causes the object's
     * <code>run</code> method to be called in that separately executing
     * thread.
     * <p>
     * The general contract of the method <code>run</code> is that it may
     * take any action whatsoever.
     *
     * @see     java.lang.Thread#run()
     */
    public abstract void run();
}

创建好后,通过调用start()方法来运行此线程:

public static void main(String[] args) {
    Thread t = new Thread(() -> {    //直接编写逻辑
        System.out.println("我是另一个线程!");
    });
    t.start();   //调用此方法来开始执行此线程
}

可能上面的例子看起来和普通的单线程没两样,那我们先来看看下面这段代码的运行结果:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("我是线程:"+Thread.currentThread().getName());
        System.out.println("我正在计算 0-10000 之间所有数的和...");
        int sum = 0;
        for (int i = 0; i <= 10000; i++) {
            sum += i;
        }
        System.out.println("结果:"+sum);
    });
    t.start();
    System.out.println("我是主线程!");
}

我们发现,这段代码执行输出结果并不是按照从上往下的顺序了,因为他们分别位于两个线程,他们是同时进行的!如果你还是觉得很疑惑,我们接着来看下面的代码运行结果:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50; i++) {
            System.out.println("我是一号线程:"+i);
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50; i++) {
            System.out.println("我是二号线程:"+i);
        }
    });
    t1.start();
    t2.start();
}

我们可以看到打印实际上是在交替进行的,也证明了他们是在同时运行!

注意:我们发现还有一个run方法,也能执行线程里面定义的内容,但是run是直接在当前线程执行,并不是创建一个线程执行!

image-20221004133119997

实际上,线程和进程差不多,也会等待获取CPU资源,一旦获取到,就开始按顺序执行我们给定的程序,当需要等待外部IO操作(比如Scanner获取输入的文本),就会暂时处于休眠状态,等待通知,或是调用sleep()方法来让当前线程休眠一段时间:

public static void main(String[] args) throws InterruptedException {
    System.out.println("l");
    Thread.sleep(1000);    //休眠时间,以毫秒为单位,1000ms = 1s
    System.out.println("b");
    Thread.sleep(1000);
    System.out.println("w");
    Thread.sleep(1000);
    System.out.println("nb!");
}

我们也可以使用stop()方法来强行终止此线程:

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        Thread me = Thread.currentThread();   //获取当前线程对象
        for (int i = 0; i < 50; i++) {
            System.out.println("打印:"+i);
            if(i == 20) me.stop();  //此方法会直接终止此线程
        }
    });
    t.start();
}

虽然stop()方法能够终止此线程,但是并不是所推荐的做法,有关线程中断相关问题,我们会在后面继续了解。

思考:猜猜以下程序输出结果:

private static int value = 0;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) value++;
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) value++;
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

我们发现,value最后的值并不是我们理想的结果,有关为什么会出现这种问题,在我们学习到线程锁的时候,再来探讨。

线程的休眠和中断

我们前面提到,一个线程处于运行状态下,线程的下一个状态会出现以下情况:

  • 当CPU给予的运行时间结束时,会从运行状态回到就绪(可运行)状态,等待下一次获得CPU资源。

  • 当线程进入休眠 / 阻塞(如等待IO请求) / 手动调用wait()方法时,会使得线程处于等待状态,当等待状态结束后会回到就绪状态。

  • 当线程出现异常或错误 / 被stop() 方法强行停止 / 所有代码执行结束时,会使得线程的运行终止。

而这个部分我们着重了解一下线程的休眠和中断,首先我们来了解一下如何使得线程进如休眠状态:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        try {
            System.out.println("l");
            Thread.sleep(1000);   //sleep方法是Thread的静态方法,它只作用于当前线程(它知道当前线程是哪个)
            System.out.println("b");    //调用sleep后,线程会直接进入到等待状态,直到时间结束
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    t.start();
}

通过调用sleep()方法来将当前线程进入休眠,使得线程处于等待状态一段时间。我们发现,此方法显示声明了会抛出一个InterruptedException异常,那么这个异常在什么时候会发生呢?

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        try {
            Thread.sleep(10000);  //休眠10秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    t.start();
    try {
        Thread.sleep(3000);   //休眠3秒,一定比线程t先醒来
        t.interrupt();   //调用t的interrupt方法
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

我们发现,每一个Thread对象中,都有一个interrupt()方法,调用此方法后,会给指定线程添加一个中断标记以告知线程需要立即停止运行或是进行其他操作,由线程来响应此中断并进行相应的处理,我们前面提到的stop()方法是强制终止线程,这样的做法虽然简单粗暴,但是很有可能导致资源不能完全释放,而类似这样的发送通知来告知线程需要中断,让线程自行处理后续,会更加合理一些,也是更加推荐的做法。我们来看看interrupt的用法:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("线程开始运行!");
        while (true){   //无限循环
            if(Thread.currentThread().isInterrupted()){   //判断是否存在中断标志
                break;   //响应中断
            }
        }
        System.out.println("线程被中断了!");
    });
    t.start();
    try {
        Thread.sleep(3000);   //休眠3秒,一定比线程t先醒来
        t.interrupt();   //调用t的interrupt方法
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

通过isInterrupted()可以判断线程是否存在中断标志,如果存在,说明外部希望当前线程立即停止,也有可能是给当前线程发送一个其他的信号,如果我们并不是希望收到中断信号就是结束程序,而是通知程序做其他事情,我们可以在收到中断信号后,复位中断标记,然后继续做我们的事情:

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("线程开始运行!");
        while (true){
            if(Thread.currentThread().isInterrupted()){   //判断是否存在中断标志
                System.out.println("发现中断信号,复位,继续运行...");
                Thread.interrupted();  //复位中断标记(返回值是当前是否有中断标记,这里不用管)
            }
        }
    });
    t.start();
    try {
        Thread.sleep(3000);   //休眠3秒,一定比线程t先醒来
        t.interrupt();   //调用t的interrupt方法
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

复位中断标记后,会立即清除中断标记。那么,如果现在我们想暂停线程呢?我们希望线程暂时停下,比如等待其他线程执行完成后,再继续运行,那这样的操作怎么实现呢?

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("线程开始运行!");
        Thread.currentThread().suspend();   //暂停此线程
        System.out.println("线程继续运行!");
    });
    t.start();
    try {
        Thread.sleep(3000);   //休眠3秒,一定比线程t先醒来
        t.resume();   //恢复此线程
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

虽然这样很方便地控制了线程的暂停状态,但是这两个方法我们发现实际上也是不推荐的做法,它很容易导致死锁!有关为什么被弃用的原因,我们会在线程锁继续探讨。

线程的优先级

实际上,Java程序中的每个线程并不是平均分配CPU时间的,为了使得线程资源分配更加合理,Java采用的是抢占式调度方式,优先级越高的线程,优先使用CPU资源!我们希望CPU花费更多的时间去处理更重要的任务,而不太重要的任务,则可以先让出一部分资源。线程的优先级一般分为以下三种:

  • MIN_PRIORITY 最低优先级

  • MAX_PRIORITY 最高优先级

  • NOM_PRIORITY 常规优先级

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("线程开始运行!");
    });
    t.start();
    t.setPriority(Thread.MIN_PRIORITY);  //通过使用setPriority方法来设定优先级
}

优先级越高的线程,获得CPU资源的概率会越大,并不是说一定优先级越高的线程越先执行!

线程的礼让和加入

我们还可以在当前线程的工作不重要时,将CPU资源让位给其他线程,通过使用yield()方法来将当前资源让位给其他同优先级线程:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println("线程1开始运行!");
        for (int i = 0; i < 50; i++) {
            if(i % 5 == 0) {
                System.out.println("让位!");
                Thread.yield();
            }
            System.out.println("1打印:"+i);
        }
        System.out.println("线程1结束!");
    });
    Thread t2 = new Thread(() -> {
        System.out.println("线程2开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println("2打印:"+i);
        }
    });
    t1.start();
    t2.start();
}

观察结果,我们发现,在让位之后,尽可能多的在执行线程2的内容。

当我们希望一个线程等待另一个线程执行完成后再继续进行,我们可以使用join()方法来实现线程的加入:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println("线程1开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println("1打印:"+i);
        }
        System.out.println("线程1结束!");
    });
    Thread t2 = new Thread(() -> {
        System.out.println("线程2开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println("2打印:"+i);
            if(i == 10){
                try {
                    System.out.println("线程1加入到此线程!");
                    t1.join();    //在i==10时,让线程1加入,先完成线程1的内容,在继续当前内容
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    });
    t1.start();
    t2.start();
}

我们发现,线程1加入后,线程2等待线程1待执行的内容全部执行完成之后,再继续执行的线程2内容。注意,线程的加入只是等待另一个线程的完成,并不是将另一个线程和当前线程合并!我们来看看:

public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println(Thread.currentThread().getName()+"开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println(Thread.currentThread().getName()+"打印:"+i);
        }
        System.out.println("线程1结束!");
    });
    Thread t2 = new Thread(() -> {
        System.out.println("线程2开始运行!");
        for (int i = 0; i < 50; i++) {
            System.out.println("2打印:"+i);
            if(i == 10){
                try {
                    System.out.println("线程1加入到此线程!");
                    t1.join();    //在i==10时,让线程1加入,先完成线程1的内容,在继续当前内容
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    });
    t1.start();
    t2.start();
}

实际上,t2线程只是暂时处于等待状态,当t1执行结束时,t2才开始继续执行,只是在效果上看起来好像是两个线程合并为一个线程在执行而已。

线程锁和线程同步

在开始讲解线程同步之前,我们需要先了解一下多线程情况下Java的内存管理:

image-20221004203914215

线程之间的共享变量(比如之前悬念中的value变量)存储在主内存(main memory)中,每个线程都有一个私有的工作内存(本地内存),工作内存中存储了该线程以读/写共享变量的副本。它类似于我们在计算机组成原理中学习的多核心处理器高速缓存机制:

image-20221004204209038

高速缓存通过保存内存中数据的副本来提供更加快速的数据访问,但是如果多个处理器的运算任务都涉及同一块内存区域,就可能导致各自的高速缓存数据不一致,在写回主内存时就会发生冲突,这就是引入高速缓存引发的新问题,称之为:缓存一致性。

实际上,Java的内存模型也是这样类似设计的,当我们同时去操作一个共享变量时,如果仅仅是读取还好,但是如果同时写入内容,就会出现问题!好比说一个银行,如果我和我的朋友同时在银行取我账户里面的钱,难道取1000还可能吐2000出来吗?我们需要一种更加安全的机制来维持秩序,保证数据的安全性!

比如我们可以来看看下面这个问题:

private static int value = 0;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) value++;
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) value++;
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

实际上,当两个线程同时读取value的时候,可能会同时拿到同样的值,而进行自增操作之后,也是同样的值,再写回主内存后,本来应该进行2次自增操作,实际上只执行了一次!

image-20221004204439553

通过synchronized关键字来创造一个线程锁,首先我们来认识一下synchronized代码块,它需要在括号中填入一个内容,必须是一个对象或是一个类,我们在value自增操作外套上同步代码块:

private static int value = 0;

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (Main.class){  //使用synchronized关键字创建同步代码块
                value++;
            }
        }
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (Main.class){
                value++;
            }
        }
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

我们发现,现在得到的结果就是我们想要的内容了,因为在同步代码块执行过程中,拿到了我们传入对象或类的锁(传入的如果是对象,就是对象锁,不同的对象代表不同的对象锁,如果是类,就是类锁,类锁只有一个,实际上类锁也是对象锁,是Class类实例,但是Class类实例同样的类无论怎么获取都是同一个),但是注意两个线程必须使用同一把锁!

当一个线程进入到同步代码块时,会获取到当前的锁,而这时如果其他使用同样的锁的同步代码块也想执行内容,就必须等待当前同步代码块的内容执行完毕,在执行完毕后会自动释放这把锁,而其他的线程才能拿到这把锁并开始执行同步代码块里面的内容(实际上synchronized是一种悲观锁,随时都认为有其他线程在对数据进行修改,后面在JUC篇视频教程中我们还会讲到乐观锁,如CAS算法)

那么我们来看看,如果使用的是不同对象的锁,那么还能顺利进行吗?

private static int value = 0;

public static void main(String[] args) throws InterruptedException {
    Main main1 = new Main();
    Main main2 = new Main();
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (main1){
                value++;
            }
        }
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) {
            synchronized (main2){
                value++;
            }
        }
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

当对象不同时,获取到的是不同的锁,因此并不能保证自增操作的原子性,最后也得不到我们想要的结果。

synchronized关键字也可以作用于方法上,调用此方法时也会获取锁:

private static int value = 0;

private static synchronized void add(){
    value++;
}

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) add();
        System.out.println("线程1完成");
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 10000; i++) add();
        System.out.println("线程2完成");
    });
    t1.start();
    t2.start();
    Thread.sleep(1000);  //主线程停止1秒,保证两个线程执行完成
    System.out.println(value);
}

我们发现实际上效果是相同的,只不过这个锁不用你去给,如果是静态方法,就是使用的类锁,而如果是普通成员方法,就是使用的对象锁。通过灵活的使用synchronized就能很好地解决我们之前提到的问题了。

死锁

其实死锁的概念在操作系统中也有提及,它是指两个线程相互持有对方需要的锁,但是又迟迟不释放,导致程序卡住:

image-20221004205058223

我们发现,线程A和线程B都需要对方的锁,但是又被对方牢牢把握,由于线程被无限期地阻塞,因此程序不可能正常终止。我们来看看以下这段代码会得到什么结果:

public static void main(String[] args) throws InterruptedException {
    Object o1 = new Object();
    Object o2 = new Object();
    Thread t1 = new Thread(() -> {
        synchronized (o1){
            try {
                Thread.sleep(1000);
                synchronized (o2){
                    System.out.println("线程1");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    Thread t2 = new Thread(() -> {
        synchronized (o2){
            try {
                Thread.sleep(1000);
                synchronized (o1){
                    System.out.println("线程2");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t1.start();
    t2.start();
}

所以,我们在编写程序时,一定要注意,不要出现这种死锁的情况。那么我们如何去检测死锁呢?我们可以利用jstack命令来检测死锁,首先利用jps找到我们的java进程:

nagocoler@NagodeMacBook-Pro ~ % jps
51592 Launcher
51690 Jps
14955 
51693 Main
nagocoler@NagodeMacBook-Pro ~ % jstack 51693
...
Java stack information for the threads listed above:
===================================================
"Thread-1":
	at com.test.Main.lambda$main$1(Main.java:46)
	- waiting to lock <0x000000076ad27fc0> (a java.lang.Object)
	- locked <0x000000076ad27fd0> (a java.lang.Object)
	at com.test.Main$$Lambda$2/1867750575.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:748)
"Thread-0":
	at com.test.Main.lambda$main$0(Main.java:34)
	- waiting to lock <0x000000076ad27fd0> (a java.lang.Object)
	- locked <0x000000076ad27fc0> (a java.lang.Object)
	at com.test.Main$$Lambda$1/396873410.run(Unknown Source)
	at java.lang.Thread.run(Thread.java:748)

Found 1 deadlock.

jstack自动帮助我们找到了一个死锁,并打印出了相关线程的栈追踪信息,同样的,使用jconsole也可以进行监测。

因此,前面说不推荐使用 suspend()去挂起线程的原因,是因为suspend()在使线程暂停的同时,并不会去释放任何锁资源。其他线程都无法访问被它占用的锁。直到对应的线程执行resume()方法后,被挂起的线程才能继续,从而其它被阻塞在这个锁的线程才可以继续执行。但是,如果resume()操作出现在suspend()之前执行,那么线程将一直处于挂起状态,同时一直占用锁,这就产生了死锁。

wait和notify方法

其实我们之前可能就发现了,Object类还有三个方法我们从来没有使用过,分别是wait()notify()以及notifyAll(),他们其实是需要配合synchronized来使用的(实际上锁就是依附于对象存在的,每个对象都应该有针对于锁的一些操作,所以说就这样设计了)当然,只有在同步代码块中才能使用这些方法,正常情况下会报错,我们来看看他们的作用是什么:

public static void main(String[] args) throws InterruptedException {
    Object o1 = new Object();
    Thread t1 = new Thread(() -> {
        synchronized (o1){
            try {
                System.out.println("开始等待");
                o1.wait();     //进入等待状态并释放锁
                System.out.println("等待结束!");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    Thread t2 = new Thread(() -> {
        synchronized (o1){
            System.out.println("开始唤醒!");
            o1.notify();     //唤醒处于等待状态的线程
          	for (int i = 0; i < 50; i++) {
               	System.out.println(i);   
            }
          	//唤醒后依然需要等待这里的锁释放之前等待的线程才能继续
        }
    });
    t1.start();
    Thread.sleep(1000);
    t2.start();
}

我们可以发现,对象的wait()方法会暂时使得此线程进入等待状态,同时会释放当前代码块持有的锁,这时其他线程可以获取到此对象的锁,当其他线程调用对象的notify()方法后,会唤醒刚才变成等待状态的线程(这时并没有立即释放锁)。注意,必须是在持有锁(同步代码块内部)的情况下使用,否则会抛出异常!

notifyAll其实和notify一样,也是用于唤醒,但是前者是唤醒所有调用wait()后处于等待的线程,而后者是看运气随机选择一个。

ThreadLocal的使用

既然每个线程都有一个自己的工作内存,那么能否只在自己的工作内存中创建变量仅供线程自己使用呢?

img

我们可以使用ThreadLocal类,来创建工作内存中的变量,它将我们的变量值存储在内部(只能存储一个变量),不同的线程访问到ThreadLocal对象时,都只能获取到当前线程所属的变量。

public static void main(String[] args) throws InterruptedException {
    ThreadLocal<String> local = new ThreadLocal<>();  //注意这是一个泛型类,存储类型为我们要存放的变量类型
    Thread t1 = new Thread(() -> {
        local.set("lbwnb");   //将变量的值给予ThreadLocal
        System.out.println("变量值已设定!");
        System.out.println(local.get());   //尝试获取ThreadLocal中存放的变量
    });
    Thread t2 = new Thread(() -> {
        System.out.println(local.get());   //尝试获取ThreadLocal中存放的变量
    });
    t1.start();
    Thread.sleep(3000);    //间隔三秒
    t2.start();
}

上面的例子中,我们开启两个线程分别去访问ThreadLocal对象,我们发现,第一个线程存放的内容,第一个线程可以获取,但是第二个线程无法获取,我们再来看看第一个线程存入后,第二个线程也存放,是否会覆盖第一个线程存放的内容:

public static void main(String[] args) throws InterruptedException {
    ThreadLocal<String> local = new ThreadLocal<>();  //注意这是一个泛型类,存储类型为我们要存放的变量类型
    Thread t1 = new Thread(() -> {
        local.set("lbwnb");   //将变量的值给予ThreadLocal
        System.out.println("线程1变量值已设定!");
        try {
            Thread.sleep(2000);    //间隔2秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("线程1读取变量值:");
        System.out.println(local.get());   //尝试获取ThreadLocal中存放的变量
    });
    Thread t2 = new Thread(() -> {
        local.set("yyds");   //将变量的值给予ThreadLocal
        System.out.println("线程2变量值已设定!");
    });
    t1.start();
    Thread.sleep(1000);    //间隔1秒
    t2.start();
}

我们发现,即使线程2重新设定了值,也没有影响到线程1存放的值,所以说,不同线程向ThreadLocal存放数据,只会存放在线程自己的工作空间中,而不会直接存放到主内存中,因此各个线程直接存放的内容互不干扰。

我们发现在线程中创建的子线程,无法获得父线程工作内存中的变量:

public static void main(String[] args) {
    ThreadLocal<String> local = new ThreadLocal<>();
    Thread t = new Thread(() -> {
       local.set("lbwnb");
        new Thread(() -> {
            System.out.println(local.get());
        }).start();
    });
    t.start();
}

我们可以使用InheritableThreadLocal来解决:

public static void main(String[] args) {
    ThreadLocal<String> local = new InheritableThreadLocal<>();
    Thread t = new Thread(() -> {
       local.set("lbwnb");
        new Thread(() -> {
            System.out.println(local.get());
        }).start();
    });
    t.start();
}

在InheritableThreadLocal存放的内容,会自动向子线程传递。

定时器

我们有时候会有这样的需求,我希望定时执行任务,比如3秒后执行,其实我们可以通过使用Thread.sleep()来实现:

public static void main(String[] args) {
    new TimerTask(() -> System.out.println("我是定时任务!"), 3000).start();   //创建并启动此定时任务
}

static class TimerTask{
    Runnable task;
    long time;

    public TimerTask(Runnable runnable, long time){
        this.task = runnable;
        this.time = time;
    }

    public void start(){
        new Thread(() -> {
            try {
                Thread.sleep(time);
                task.run();   //休眠后再运行
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

我们通过自行封装一个TimerTask类,并在启动时,先休眠3秒钟,再执行我们传入的内容。那么现在我们希望,能否循环执行一个任务呢?比如我希望每隔1秒钟执行一次代码,这样该怎么做呢?

public static void main(String[] args) {
    new TimerLoopTask(() -> System.out.println("我是定时任务!"), 3000).start();   //创建并启动此定时任务
}

static class TimerLoopTask{
    Runnable task;
    long loopTime;

    public TimerLoopTask(Runnable runnable, long loopTime){
        this.task = runnable;
        this.loopTime = loopTime;
    }

    public void start(){
        new Thread(() -> {
            try {
                while (true){   //无限循环执行
                    Thread.sleep(loopTime);
                    task.run();   //休眠后再运行
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

现在我们将单次执行放入到一个无限循环中,这样就能一直执行了,并且按照我们的间隔时间进行。

但是终究是我们自己实现,可能很多方面还没考虑到,Java也为我们提供了一套自己的框架用于处理定时任务:

public static void main(String[] args) {
    Timer timer = new Timer();    //创建定时器对象
    timer.schedule(new TimerTask() {   //注意这个是一个抽象类,不是接口,无法使用lambda表达式简化,只能使用匿名内部类
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName());    //打印当前线程名称
        }
    }, 1000);    //执行一个延时任务
}

我们可以通过创建一个Timer类来让它进行定时任务调度,我们可以通过此对象来创建任意类型的定时任务,包延时任务、循环定时任务等。我们发现,虽然任务执行完成了,但是我们的程序并没有停止,这是因为Timer内存维护了一个任务队列和一个工作线程:

public class Timer {
    /**
     * The timer task queue.  This data structure is shared with the timer
     * thread.  The timer produces tasks, via its various schedule calls,
     * and the timer thread consumes, executing timer tasks as appropriate,
     * and removing them from the queue when they're obsolete.
     */
    private final TaskQueue queue = new TaskQueue();

    /**
     * The timer thread.
     */
    private final TimerThread thread = new TimerThread(queue);
  
		...
}

TimerThread继承自Thread,是一个新创建的线程,在构造时自动启动:

public Timer(String name) {
    thread.setName(name);
    thread.start();
}

而它的run方法会循环地读取队列中是否还有任务,如果有任务依次执行,没有的话就暂时处于休眠状态:

public void run() {
    try {
        mainLoop();
    } finally {
        // Someone killed this Thread, behave as if Timer cancelled
        synchronized(queue) {
            newTasksMayBeScheduled = false;
            queue.clear();  // Eliminate obsolete references
        }
    }
}

/**
 * The main timer loop.  (See class comment.)
 */
private void mainLoop() {
  try {
       TimerTask task;
       boolean taskFired;
       synchronized(queue) {
         	// Wait for queue to become non-empty
          while (queue.isEmpty() && newTasksMayBeScheduled)   //当队列为空同时没有被关闭时,会调用wait()方法暂时处于等待状态,当有新的任务时,会被唤醒。
                queue.wait();
          if (queue.isEmpty())
             break;    //当被唤醒后都没有任务时,就会结束循环,也就是结束工作线程
                      ...
}

newTasksMayBeScheduled实际上就是标记当前定时器是否关闭,当它为false时,表示已经不会再有新的任务到来,也就是关闭,我们可以通过调用cancel()方法来关闭它的工作线程:

public void cancel() {
    synchronized(queue) {
        thread.newTasksMayBeScheduled = false;
        queue.clear();
        queue.notify();  //唤醒wait使得工作线程结束
    }
}

因此,我们可以在使用完成后,调用Timer的cancel()方法以正常退出我们的程序:

public static void main(String[] args) {
    Timer timer = new Timer();
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName());
            timer.cancel();  //结束
        }
    }, 1000);
}
守护线程

不要把操作系统重的守护进程和守护线程相提并论!

守护进程在后台运行运行,不需要和用户交互,本质和普通进程类似。而守护线程就不一样了,当其他所有的非守护线程结束之后,守护线程自动结束,也就是说,Java中所有的线程都执行完毕后,守护线程自动结束,因此守护线程不适合进行IO操作,只适合打打杂:

public static void main(String[] args) throws InterruptedException{
    Thread t = new Thread(() -> {
        while (true){
            try {
                System.out.println("程序正常运行中...");
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
    t.setDaemon(true);   //设置为守护线程(必须在开始之前,中途是不允许转换的)
    t.start();
    for (int i = 0; i < 5; i++) {
        Thread.sleep(1000);
    }
}

在守护线程中产生的新线程也是守护的:

public static void main(String[] args) throws InterruptedException{
    Thread t = new Thread(() -> {
        Thread it = new Thread(() -> {
            while (true){
                try {
                    System.out.println("程序正常运行中...");
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        it.start();
    });
    t.setDaemon(true);   //设置为守护线程(必须在开始之前,中途是不允许转换的)
    t.start();
    for (int i = 0; i < 5; i++) {
        Thread.sleep(1000);
    }
}
再谈集合类

集合类中有一个东西是Java8新增的Spliterator接口,翻译过来就是:可拆分迭代器(Splitable Iterator)和Iterator一样,Spliterator也用于遍历数据源中的元素,但它是为了并行执行而设计的。Java 8已经为集合框架中包含的所有数据结构提供了一个默认的Spliterator实现。在集合跟接口Collection中提供了一个spliterator()方法用于获取可拆分迭代器。

其实我们之前在讲解集合类的根接口时,就发现有这样一个方法:

default Stream<E> parallelStream() {
    return StreamSupport.stream(spliterator(), true); //parallelStream就是利用了可拆分迭代器进行多线程操作
}

并行流,其实就是一个多线程执行的流,它通过默认的ForkJoinPool实现(这里不讲解原理),它可以提高你的多线程任务的速度。

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>(Arrays.asList(1, 4, 5, 2, 9, 3, 6, 0));
    list
            .parallelStream()    //获得并行流
            .forEach(i -> System.out.println(Thread.currentThread().getName()+" -> "+i));
}

我们发现,forEach操作的顺序,并不是我们实际List中的顺序,同时每次打印也是不同的线程在执行!我们可以通过调用forEachOrdered()方法来使用单线程维持原本的顺序:

public static void main(String[] args) {
    List<Integer> list = new ArrayList<>(Arrays.asList(1, 4, 5, 2, 9, 3, 6, 0));
    list
            .parallelStream()    //获得并行流
            .forEachOrdered(System.out::println);
}

我们之前还发现,在Arrays数组工具类中,也包含大量的并行方法:

public static void main(String[] args) {
    int[] arr = new int[]{1, 4, 5, 2, 9, 3, 6, 0};
    Arrays.parallelSort(arr);   //使用多线程进行并行排序,效率更高
    System.out.println(Arrays.toString(arr));
}

更多地使用并行方法,可以更加充分地发挥现代计算机多核心的优势,但是同时需要注意多线程产生的异步问题!

public static void main(String[] args) {
    int[] arr = new int[]{1, 4, 5, 2, 9, 3, 6, 0};
    Arrays.parallelSetAll(arr, i -> {
        System.out.println(Thread.currentThread().getName());
        return arr[i];
    });
    System.out.println(Arrays.toString(arr));
}

因为多线程的加入,我们之前认识的集合类都废掉了:

public static void main(String[] args) throws InterruptedException {
    List<Integer> list = new ArrayList<>();
    new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            list.add(i);   //两个线程同时操作集合类进行插入操作
        }
    }).start();
    new Thread(() -> {
        for (int i = 1000; i < 2000; i++) {
            list.add(i);
        }
    }).start();
    Thread.sleep(2000);
    System.out.println(list.size());
}

我们发现,有些时候运气不好,得到的结果并不是2000个元素,而是:

image-20221004212332535

因为之前的集合类,并没有考虑到多线程运行的情况,如果两个线程同时执行,那么有可能两个线程同一时间都执行同一个方法,这种情况下就很容易出问题:

public boolean add(E e) {
    ensureCapacityInternal(size + 1);  // 当数组容量更好还差一个满的时候,这个时候两个线程同时走到了这里,因为都判断为没满,所以说没有进行扩容,但是实际上两个线程都要插入一个元素进来
    elementData[size++] = e;   //当两个线程同时在这里插入元素,直接导致越界访问
    return true;
}

当然,在Java早期的时候,还有一些老的集合类,这些集合类都是线程安全的:

public static void main(String[] args) throws InterruptedException {
    Vector<Integer> list = new Vector<>();   //我们可以使用Vector代替List使用
  	//Hashtable<Integer, String>   也可以使用Hashtable来代替Map
    new Thread(() -> {
        for (int i = 0; i < 1000; i++) {
            list.add(i);
        }
    }).start();
    new Thread(() -> {
        for (int i = 1000; i < 2000; i++) {
            list.add(i);
        }
    }).start();

    Thread.sleep(1000);
    System.out.println(list.size());
}

因为这些集合类中的每一个方法都加了锁,所以说不会出现多线程问题,但是这些老的集合类现在已经不再使用了,我们会在JUC篇视频教程中介绍专用于并发编程的集合类。

通过对Java多线程的了解,我们就具备了利用多线程解决问题的思维!

实战:生产者与消费者

所谓的生产者消费者模型,是通过一个容器来解决生产者和消费者的强耦合问题。通俗的讲,就是生产者在不断的生产,消费者也在不断的消费,可是消费者消费的产品是生产者生产的,这就必然存在一个中间容器,我们可以把这个容器想象成是一个货架,当货架空的时候,生产者要生产产品,此时消费者在等待生产者往货架上生产产品,而当货架有货物的时候,消费者可以从货架上拿走商品,生产者此时等待货架出现空位,进而补货,这样不断的循环。

通过多线程编程,来模拟一个餐厅的2个厨师和3个顾客,假设厨师炒出一个菜的时间为3秒,顾客吃掉菜品的时间为4秒。


反射

注意: 本章节涉及到JVM相关底层原理,难度会有一些大。

反射就是把Java类中的各个成分映射成一个个的Java对象。即在运行状态中,对于任意一个类,都能够知道这个类所有的属性和方法,对于任意一个对象,都能调用它的任意一个方法和属性。这种动态获取信息及动态调用对象方法的功能叫Java的反射机制。

简而言之,我们可以通过反射机制,获取到类的一些属性,包括类里面有哪些字段,有哪些方法,继承自哪个类,甚至还能获取到泛型!它的权限非常高,慎重使用!

Java类加载机制

在学习Java的反射机制之前,我们需要先了解一下类的加载机制,一个类是如何被加载和使用的:

image-20221004213335479

在Java程序启动时,JVM会将一部分类(class文件)先加载(并不是所有的类都会在一开始加载),通过ClassLoader将类加载,在加载过程中,会将类的信息提取出来(存放在元空间中,JDK1.8之前存放在永久代),同时也会生成一个Class对象存放在内存(堆内存),注意此Class对象只会存在一个,与加载的类唯一对应!

为了方便各位小伙伴理解,你们就直接理解为默认情况下(仅使用默认类加载器)每个类都有且只有一个唯一的Class对象存放在JVM中,我们无论通过什么方式访问,都是始终是那一个对象。Class对象中包含我们类的一些信息,包括类里面有哪些方法、哪些变量等等。

Class类详解

通过前面,我们了解了类的加载,同时会提取一个类的信息生成Class对象存放在内存中,而反射机制其实就是利用这些存放的类信息,来获取类的信息和操作类。那么如何获取到每个类对应的Class对象呢,我们可以通过以下方式:

public static void main(String[] args) throws ClassNotFoundException {
    Class<String> clazz = String.class;   //使用class关键字,通过类名获取
    Class<?> clazz2 = Class.forName("java.lang.String");   //使用Class类静态方法forName(),通过包名.类名获取,注意返回值是Class<?>
    Class<?> clazz3 = new String("cpdd").getClass();  //通过实例对象获取
}

注意Class类也是一个泛型类,只有第一种方法,能够直接获取到对应类型的Class对象,而以下两种方法使用了?通配符作为返回值,但是实际上都和第一个返回的是同一个对象:

Class<String> clazz = String.class;   //使用class关键字,通过类名获取
Class<?> clazz2 = Class.forName("java.lang.String");   //使用Class类静态方法forName(),通过包名.类名获取,注意返回值是Class<?>
Class<?> clazz3 = new String("cpdd").getClass();

System.out.println(clazz == clazz2);
System.out.println(clazz == clazz3);

通过比较,验证了我们一开始的结论,在JVM中每个类始终只存在一个Class对象,无论通过什么方法获取,都是一样的。现在我们再来看看这个问题:

public static void main(String[] args) {
    Class<?> clazz = int.class;   //基本数据类型有Class对象吗?
    System.out.println(clazz);
}

迷了,不是每个类才有Class对象吗,基本数据类型又不是类,这也行吗?实际上,基本数据类型也有对应的Class对象(反射操作可能需要用到),而且我们不仅可以通过class关键字获取,其实本质上是定义在对应的包装类中的:

/**
 * The {@code Class} instance representing the primitive type
 * {@code int}.
 *
 * @since   JDK1.1
 */
@SuppressWarnings("unchecked")
public static final Class<Integer>  TYPE = (Class<Integer>) Class.getPrimitiveClass("int");

/*
 * Return the Virtual Machine's Class object for the named
 * primitive type
 */
static native Class<?> getPrimitiveClass(String name);   //C++实现,并非Java定义

每个包装类中(包括Void),都有一个获取原始类型Class方法,注意,getPrimitiveClass获取的是原始类型,并不是包装类型,只是可以使用包装类来表示。

public static void main(String[] args) {
    Class<?> clazz = int.class;
    System.out.println(Integer.TYPE == int.class);
}

通过对比,我们发现实际上包装类型都有一个TYPE,其实也就是基本类型的Class,那么包装类的Class和基本类的Class一样吗?

public static void main(String[] args) {
    System.out.println(Integer.TYPE == Integer.class);
}

我们发现,包装类型的Class对象并不是基本类型Class对象。数组类型也是一种类型,只是编程不可见,因此我们可以直接获取数组的Class对象:

public static void main(String[] args) {
    Class<String[]> clazz = String[].class;
    System.out.println(clazz.getName());  //获取类名称(得到的是包名+类名的完整名称)
    System.out.println(clazz.getSimpleName());
    System.out.println(clazz.getTypeName());
    System.out.println(clazz.getClassLoader());   //获取它的类加载器
    System.out.println(clazz.cast(new Integer("10")));   //强制类型转换
}

下节课,我们将开始对Class对象的使用进行讲解。

Class对象与多态

正常情况下,我们使用instanceof进行类型比较:

public static void main(String[] args) {
    String str = "";
    System.out.println(str instanceof String);
}

它可以判断一个对象是否为此接口或是类的实现或是子类,而现在我们有了更多的方式去判断类型:

public static void main(String[] args) {
    String str = "";
    System.out.println(str.getClass() == String.class);   //直接判断是否为这个类型
}

如果需要判断是否为子类或是接口/抽象类的实现,我们可以使用asSubClass()方法:

public static void main(String[] args) {
    Integer i = 10;
    i.getClass().asSubclass(Number.class);   //当Integer不是Number的子类时,会产生异常
}

通过getSuperclass()方法,我们可以获取到父类的Class对象:

public static void main(String[] args) {
    Integer i = 10;
    System.out.println(i.getClass().getSuperclass());
}

也可以通过getGenericSuperclass()获取父类的原始类型的Type:

public static void main(String[] args) {
    Integer i = 10;
    Type type = i.getClass().getGenericSuperclass();
    System.out.println(type);
    System.out.println(type instanceof Class);
}

我们发现Type实际上是Class类的父接口,但是获取到的Type的实现并不一定是Class。

同理,我们也可以像上面这样获取父接口:

public static void main(String[] args) {
    Integer i = 10;
    for (Class<?> anInterface : i.getClass().getInterfaces()) {
        System.out.println(anInterface.getName());
    }
  
  	for (Type genericInterface : i.getClass().getGenericInterfaces()) {
        System.out.println(genericInterface.getTypeName());
    }
}

是不是感觉反射功能很强大?几乎类的所有信息都可以通过反射获得。

创建类对象

既然我们拿到了类的定义,那么是否可以通过Class对象来创建对象、调用方法、修改变量呢?当然是可以的,那我们首先来探讨一下如何创建一个类的对象:

public static void main(String[] args) throws InstantiationException, IllegalAccessException {
    Class<Student> clazz = Student.class;
    Student student = clazz.newInstance();
    student.test();
}

static class Student{
    public void test(){
        System.out.println("萨日朗");
    }
}

通过使用newInstance()方法来创建对应类型的实例,返回泛型T,注意它会抛出InstantiationException和IllegalAccessException异常,那么什么情况下会出现异常呢?

public static void main(String[] args) throws InstantiationException, IllegalAccessException {
    Class<Student> clazz = Student.class;
    Student student = clazz.newInstance();
    student.test();
}

static class Student{

    public Student(String text){
        
    }

    public void test(){
        System.out.println("萨日朗");
    }
}

当类默认的构造方法被带参构造覆盖时,会出现InstantiationException异常,因为newInstance()只适用于默认无参构造。

public static void main(String[] args) throws InstantiationException, IllegalAccessException {
    Class<Student> clazz = Student.class;
    Student student = clazz.newInstance();
    student.test();
}

static class Student{

    private Student(){}

    public void test(){
        System.out.println("萨日朗");
    }
}

当默认无参构造的权限不是public时,会出现IllegalAccessException异常,表示我们无权去调用默认构造方法。在JDK9之后,不再推荐使用newInstance()方法了,而是使用我们接下来要介绍到的,通过获取构造器,来实例化对象:

public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    Class<Student> clazz = Student.class;
    Student student = clazz.getConstructor(String.class).newInstance("what's up");
    student.test();
}

static class Student{

    public Student(String str){}

    public void test(){
        System.out.println("萨日朗");
    }
}

通过获取类的构造方法(构造器)来创建对象实例,会更加合理,我们可以使用getConstructor()方法来获取类的构造方法,同时我们需要向其中填入参数,也就是构造方法需要的类型,当然我们这里只演示了。那么,当访问权限不是public的时候呢?

public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
    Class<Student> clazz = Student.class;
    Student student = clazz.getConstructor(String.class).newInstance("what's up");
    student.test();
}

static class Student{

    private Student(String str){}

    public void test(){
        System.out.println("萨日朗");
    }
}

我们发现,当访问权限不足时,会无法找到此构造方法,那么如何找到非public的构造方法呢?

Class<Student> clazz = Student.class;
Constructor<Student> constructor = clazz.getDeclaredConstructor(String.class);
constructor.setAccessible(true);   //修改访问权限
Student student = constructor.newInstance("what's up");
student.test();

使用getDeclaredConstructor()方法可以找到类中的非public构造方法,但是在使用之前,我们需要先修改访问权限,在修改访问权限之后,就可以使用非public方法了(这意味着,反射可以无视权限修饰符访问类的内容)

调用类方法

我们可以通过反射来调用类的方法(本质上还是类的实例进行调用)只是利用反射机制实现了方法的调用,我们在包下创建一个新的类:

package com.test;

public class Student {
    public void test(String str){
        System.out.println("萨日朗"+str);
    }
}

这次我们通过forName(String)来找到这个类并创建一个新的对象:

public static void main(String[] args) throws ReflectiveOperationException {
    Class<?> clazz = Class.forName("com.test.Student");
    Object instance = clazz.newInstance();   //创建出学生对象
    Method method = clazz.getMethod("test", String.class);   //通过方法名和形参类型获取类中的方法
    
    method.invoke(instance, "what's up");   //通过Method对象的invoke方法来调用方法
}

通过调用getMethod()方法,我们可以获取到类中所有声明为public的方法,得到一个Method对象,我们可以通过Method对象的invoke()方法(返回值就是方法的返回值,因为这里是void,返回值为null)来调用已经获取到的方法,注意传参。

我们发现,利用反射之后,在一个对象从构造到方法调用,没有任何一处需要引用到对象的实际类型,我们也没有导入Student类,整个过程都是反射在代替进行操作,使得整个过程被模糊了,过多的使用反射,会极大地降低后期维护性。

同构造方法一样,当出现非public方法时,我们可以通过反射来无视权限修饰符,获取非public方法并调用,现在我们将test()方法的权限修饰符改为private:

public static void main(String[] args) throws ReflectiveOperationException {
    Class<?> clazz = Class.forName("com.test.Student");
    Object instance = clazz.newInstance();   //创建出学生对象
    Method method = clazz.getDeclaredMethod("test", String.class);   //通过方法名和形参类型获取类中的方法
    method.setAccessible(true);

    method.invoke(instance, "what's up");   //通过Method对象的invoke方法来调用方法
}

Method和Constructor都和Class一样,他们存储了方法的信息,包括方法的形式参数列表,返回值,方法的名称等内容,我们可以直接通过Method对象来获取这些信息:

public static void main(String[] args) throws ReflectiveOperationException {
    Class<?> clazz = Class.forName("com.test.Student");
    Method method = clazz.getDeclaredMethod("test", String.class);   //通过方法名和形参类型获取类中的方法
    
    System.out.println(method.getName());   //获取方法名称
    System.out.println(method.getReturnType());   //获取返回值类型
}

当方法的参数为可变参数时,我们该如何获取方法呢?实际上,我们在之前就已经提到过,可变参数实际上就是一个数组,因此我们可以直接使用数组的class对象表示:

Method method = clazz.getDeclaredMethod("test", String[].class);

反射非常强大,尤其是我们提到的越权访问,但是请一定谨慎使用,别人将某个方法设置为private一定有他的理由,如果实在是需要使用别人定义为private的方法,就必须确保这样做是安全的,在没有了解别人代码的整个过程就强行越权访问,可能会出现无法预知的错误。

修改类的属性

我们还可以通过反射访问一个类中定义的成员字段也可以修改一个类的对象中的成员字段值,通过getField()方法来获取一个类定义的指定字段:

public static void main(String[] args) throws ReflectiveOperationException {
    Class<?> clazz = Class.forName("com.test.Student");
    Object instance = clazz.newInstance();

    Field field = clazz.getField("i");   //获取类的成员字段i
    field.set(instance, 100);   //将类实例instance的成员字段i设置为100

    Method method = clazz.getMethod("test");
    method.invoke(instance);
}

在得到Field之后,我们就可以直接通过set()方法为某个对象,设定此属性的值,比如上面,我们就为instance对象设定值为100,当访问private字段时,同样可以按照上面的操作进行越权访问:

public static void main(String[] args) throws ReflectiveOperationException {
    Class<?> clazz = Class.forName("com.test.Student");
    Object instance = clazz.newInstance();

    Field field = clazz.getDeclaredField("i");   //获取类的成员字段i
    field.setAccessible(true);
    field.set(instance, 100);   //将类实例instance的成员字段i设置为100

    Method method = clazz.getMethod("test");
    method.invoke(instance);
}

现在我们已经知道,反射几乎可以把一个类的老底都给扒出来,任何属性,任何内容,都可以被反射修改,无论权限修饰符是什么,那么,如果我的字段被标记为final呢?现在在字段i前面添加final关键字,我们再来看看效果:

private final int i = 10;

这时,当字段为final时,就修改失败了!当然,通过反射可以直接将final修饰符直接去除,去除后,就可以随意修改内容了,我们来尝试修改Integer的value值:

public static void main(String[] args) throws ReflectiveOperationException {
    Integer i = 10;

    Field field = Integer.class.getDeclaredField("value");

    Field modifiersField = Field.class.getDeclaredField("modifiers");  //这里要获取Field类的modifiers字段进行修改
    modifiersField.setAccessible(true);
    modifiersField.setInt(field,field.getModifiers()&~Modifier.FINAL);  //去除final标记

    field.setAccessible(true);
    field.set(i, 100);   //强行设置值

    System.out.println(i);
}

我们可以发现,反射非常暴力,就连被定义为final字段的值都能强行修改,几乎能够无视一切阻拦。我们来试试看修改一些其他的类型:

public static void main(String[] args) throws ReflectiveOperationException {
    List<String> i = new ArrayList<>();

    Field field = ArrayList.class.getDeclaredField("size");
    field.setAccessible(true);
    field.set(i, 10);

    i.add("测试");   //只添加一个元素
    System.out.println(i.size());  //大小直接变成11
    i.remove(10);   //瞎移除都不带报错的,淦
}

实际上,整个ArrayList体系由于我们的反射操作,导致被破坏,因此它已经无法正常工作了!

再次强调,在进行反射操作时,必须注意是否安全,虽然拥有了创世主的能力,但是我们不能滥用,我们只能把它当做一个不得已才去使用的工具!

类加载器

我们接着来介绍一下类加载器,实际上类加载器就是用于加载一个类的,但是类加载器并不是只有一个。

思考: 既然说Class对象和加载的类唯一对应,那如果我们手动创建一个与JDK包名一样,同时类名也保持一致,JVM会加载这个类吗?

package java.lang;

public class String {    //JDK提供的String类也是
    public static void main(String[] args) {
        System.out.println("我姓🐴,我叫🐴nb");
    }
}

我们发现,会出现以下报错:

错误: 在类 java.lang.String 中找不到 main 方法, 请将 main 方法定义为:
   public static void main(String[] args)

但是我们明明在自己写的String类中定义了main方法啊,为什么会找不到此方法呢?实际上这是ClassLoader的双亲委派机制在保护Java程序的正常运行:

img

实际上类最开始是由BootstarpClassLoader进行加载,BootstarpClassLoader用于加载JDK提供的类,而我们自己编写的类实际上是AppClassLoader加载的,只有BootstarpClassLoader都没有加载的类,才会让AppClassLoader来加载,因此我们自己编写的同名包同名类不会被加载,而实际要去启动的是真正的String类,也就自然找不到main方法了。

public class Main {
    public static void main(String[] args) {
        System.out.println(Main.class.getClassLoader());   //查看当前类的类加载器
        System.out.println(Main.class.getClassLoader().getParent());  //父加载器
        System.out.println(Main.class.getClassLoader().getParent().getParent());  //爷爷加载器
        System.out.println(String.class.getClassLoader());   //String类的加载器
    }
}

由于BootstarpClassLoader是C++编写的,我们在Java中是获取不到的。

既然通过ClassLoader就可以加载类,那么我们可以自己手动将class文件加载到JVM中吗?先写好我们定义的类:

package com.test;

public class Test {
    public String text;

    public void test(String str){
        System.out.println(text+" > 我是测试方法!"+str);
    }
}

通过javac命令,手动编译一个.class文件:

nagocoler@NagodeMacBook-Pro HelloWorld % javac src/main/java/com/test/Test.java

编译后,得到一个class文件,我们把它放到根目录下,然后编写一个我们自己的ClassLoader,因为普通的ClassLoader无法加载二进制文件,因此我们编写一个自定义的来让它支持:

//定义一个自己的ClassLoader
static class MyClassLoader extends ClassLoader{
    public Class<?> defineClass(String name, byte[] b){
        return defineClass(name, b, 0, b.length);   //调用protected方法,支持载入外部class文件
    }
}

public static void main(String[] args) throws IOException {
    MyClassLoader classLoader = new MyClassLoader();
    FileInputStream stream = new FileInputStream("Test.class");
    byte[] bytes = new byte[stream.available()];
    stream.read(bytes);
    Class<?> clazz = classLoader.defineClass("com.test.Test", bytes);   //类名必须和我们定义的保持一致
    System.out.println(clazz.getName());   //成功加载外部class文件
}

现在,我们就将此class文件读取并解析为Class了,现在我们就可以对此类进行操作了(注意,我们无法在代码中直接使用此类型,因为它是我们直接加载的),我们来试试看创建一个此类的对象并调用其方法:

try {
    Object obj = clazz.newInstance();
    Method method = clazz.getMethod("test", String.class);   //获取我们定义的test(String str)方法
    method.invoke(obj, "哥们这瓜多少钱一斤?");
}catch (Exception e){
    e.printStackTrace();
}

我们来试试看修改成员字段之后,再来调用此方法:

try {
    Object obj = clazz.newInstance();
    Field field = clazz.getField("text");   //获取成员变量 String text;
    field.set(obj, "华强");
    Method method = clazz.getMethod("test", String.class);   //获取我们定义的test(String str)方法
    method.invoke(obj, "哥们这瓜多少钱一斤?");
}catch (Exception e){
    e.printStackTrace();
}

通过这种方式,我们就可以实现外部加载甚至是网络加载一个类,只需要把类文件传递即可,这样就无需再将代码写在本地,而是动态进行传递,不仅可以一定程度上防止源代码被反编译(只是一定程度上,想破解你代码有的是方法),而且在更多情况下,我们还可以对byte[]进行加密,保证在传输过程中的安全性。


注解

注意: 注解跟我们之前讲解的注释完全不是一个概念,不要搞混了。

其实我们在之前就接触到注解了,比如@Override表示重写父类方法(当然不加效果也是一样的,此注解在编译时会被自动丢弃)注解本质上也是一个类,只不过它的用法比较特殊。

注解可以被标注在任意地方,包括方法上、类名上、参数上、成员属性上、注解定义上等,就像注释一样,它相当于我们对某样东西的一个标记。而与注释不同的是,注解可以通过反射在运行时获取,注解也可以选择是否保留到运行时。

预设注解

JDK预设了以下注解,作用于代码:

  • @Override - 检查(仅仅是检查,不保留到运行时)该方法是否是重写方法。如果发现其父类,或者是引用的接口中并没有该方法时,会报编译错误。

  • @Deprecated - 标记过时方法。如果使用该方法,会报编译警告。

  • @SuppressWarnings - 指示编译器去忽略注解中声明的警告(仅仅编译器阶段,不保留到运行时)

  • @FunctionalInterface - Java 8 开始支持,标识一个匿名函数或函数式接口。

  • @SafeVarargs - Java 7 开始支持,忽略任何使用参数为泛型变量的方法或构造函数调用产生的警告。

元注解

元注解是作用于注解上的注解,用于我们编写自定义的注解:

  • @Retention - 标识这个注解怎么保存,是只在代码中,还是编入class文件中,或者是在运行时可以通过反射访问。

  • @Documented - 标记这些注解是否包含在用户文档中。

  • @Target - 标记这个注解应该是哪种 Java 成员。

  • @Inherited - 标记这个注解是继承于哪个注解类(默认 注解并没有继承于任何子类)

  • @Repeatable - Java 8 开始支持,标识某注解可以在同一个声明上使用多次。

看了这么多预设的注解,你们肯定眼花缭乱了,那我们来看看@Override是如何定义的:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

该注解由@Target限定为只能作用于方法上,ElementType是一个枚举类型,用于表示此枚举的作用域,一个注解可以有很多个作用域。@Retention表示此注解的保留策略,包括三种策略,在上述中有写到,而这里定义为只在代码中。一般情况下,自定义的注解需要定义1个@Retention和1-n个@Target

既然了解了元注解的使用和注解的定义方式,我们就来尝试定义一个自己的注解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
}

这里我们定义一个Test注解,并将其保留到运行时,同时此注解可以作用于方法或是类上:

@Test
public class Main {
    @Test
    public static void main(String[] args) {
        
    }
}

这样,一个最简单的注解就被我们创建了。

注解的使用

我们还可以在注解中定义一些属性,注解的属性也叫做成员变量,注解只有成员变量,没有方法。注解的成员变量在注解的定义中以“无形参的方法”形式来声明,其方法名定义了该成员变量的名字,其返回值定义了该成员变量的类型:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
    String value();
}

默认只有一个属性时,我们可以将其名字设定为value,否则,我们需要在使用时手动指定注解的属性名称,使用value则无需填入:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
    String test();
}
public class Main {
    @Test(test = "")
    public static void main(String[] args) {

    }
}

我们也可以使用default关键字来为这些属性指定默认值:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
    String value() default "都看到这里了,给个三连吧!";
}

当属性存在默认值时,使用注解的时候可以不用传入属性值。当属性为数组时呢?

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Test {
    String[] value();
}

当属性为数组,我们在使用注解传参时,如果数组里面只有一个内容,我们可以直接传入一个值,而不是创建一个数组:

@Test("关注点了吗")
public static void main(String[] args) {
	
}
public class Main {
    @Test({"value1", "value2"})   //多个值时就使用花括号括起来
    public static void main(String[] args) {

    }
}
反射获取注解

既然我们的注解可以保留到运行时,那么我们来看看,如何获取我们编写的注解,我们需要用到反射机制:

public static void main(String[] args) {
    Class<Student> clazz = Student.class;
    for (Annotation annotation : clazz.getAnnotations()) {
        System.out.println(annotation.annotationType());   //获取类型
        System.out.println(annotation instanceof Test);   //直接判断是否为Test
        Test test = (Test) annotation;
        System.out.println(test.value());   //获取我们在注解中写入的内容
    }
}

通过反射机制,我们可以快速获取到我们标记的注解,同时还能获取到注解中填入的值,那么我们来看看,方法上的标记是不是也可以通过这种方式获取注解:

public static void main(String[] args) throws NoSuchMethodException {
    Class<Student> clazz = Student.class;
    for (Annotation annotation : clazz.getMethod("test").getAnnotations()) {
        System.out.println(annotation.annotationType());   //获取类型
        System.out.println(annotation instanceof Test);   //直接判断是否为Test
        Test test = (Test) annotation;
        System.out.println(test.value());   //获取我们在注解中写入的内容
    }
}

无论是方法、类、还是字段,都可以使用getAnnotations()方法(还有几个同名的)来快速获取我们标记的注解。

所以说呢,这玩意学来有啥用?丝毫get不到这玩意的用处。其实不是,现阶段作为初学者,还体会不到注解带来的快乐,在接触到Spring和SpringBoot等大型框架后,相信各位就能感受到注解带来的魅力了。


结束语

Java的学习对你来说可能是枯燥的,可能是漫长的,也有可能是有趣的,无论如何,你终于是完成了全部内容的学习,可喜可贺。

实际上很多人一开始跟着你们一起在进行学习,但是他们因为各种原因,最后还是没有走完这条路。坚持不一定会成功,但坚持到别人坚持不下去,那么你至少已经成功了一半了,坚持到最后的人运气往往都不会太差。

希望各位小伙伴能够在之后的学习中砥砺前行!

SSM

恭喜各位顺利进入到SSM(Spring+SpringMVC+Mybatis)阶段的学习,也算是成功出了Java新手村,由于前面我们已经学习过Mybatis了,因此,本期教程的时间安排相比之前会更短一些。从这里开始,很多的概念理解起来就稍微有一点难度了,因为你们没有接触过企业开发场景,很难体会到那种思想带来的好处,甚至到后期接触到的几乎都是基于云计算和大数据理论实现的框架(当下最热门最前沿的技术)逐渐不再是和计算机基础相关联,而是和怎么高效干活相关了。

在JavaWeb阶段,我们已经学习了如何使用Java进行Web应用程序开发,我们现在已经具有搭建Web网站的能力,但是,我们在开发的过程中,发现存在诸多的不便,在最后的图书管理系统编程实战中,我们发现虽然我们思路很清晰,知道如何编写对应的接口,但是这样的开发效率,实在是太慢了,并且对于对象创建的管理,存在诸多的不妥之处,因此,我们要去继续学习更多的框架技术,来简化和规范我们的Java开发。

Spring就是这样的一个框架(文档:Redirecting...),它就是为了简化开发而生,它是轻量级的IoCAOP的容器框架,主要是针对Bean的生命周期进行管理的轻量级容器,并且它的生态已经发展得极为庞大。那么,首先一问,什么是IoCAOP,什么又是Bean呢?不要害怕,这些概念只是听起来满满的高级感,实际上没有多高级(很多东西都是这样,名字听起来很牛,实际上只是一个很容易理解的东西)

Spring

Spring框架最核心的其实它的IoC容器,这是我们开启Spring学习的第一站。

高内聚,低耦合,是现代软件的开发的设计目标,而Spring框架就给我们提供了这样的一个IoC容器进行对象的的管理,一个由Spring IoC容器实例化、组装和管理的对象,我们称其为Bean。

三层架构:Controller层、Service层、Dao层或者Mapper层(是MyBatis等ORM框架中的具体实现方式)

Spring Framework Documentation :: Spring Framework

IoC容器、AOP切片和Bean注入

控制反转: Inversion Of Control,简称IOC。对象的创建控制权由程序自身转移到外部(容器),这种思想称为控制反转。 对象的创建权由程序员主动创建转移到容器(由容器创建、管理对象)。这个容器称为:IOC容器或Spring容器,它是通过实体类和配置文件,经过工厂模式生产getBean获取。

在Spring框架中,IoC容器主要指的是ApplicationContext或更基础的BeanFactory。这些容器负责实例化、配置和管理应用程序中的对象(称为beans),并且处理它们之间的依赖关系。

  • @Component ,就可以实现类交给IOC容器管理

  • @Autowired ,就可以实现程序运行时IOC容器自动注入需要的依赖对象

public static void main(String[] args) {
	A a = new A();
  	a.test(IoC.getBean(Service.class));   //瞎编的一个容器类,但是是那个意思
  	//比如现在在IoC容器中管理的Service的实现是B,那么我们从里面拿到的Service实现就是B
}

class A{
    private List<Service> list;   //一律使用Service,具体实现由IoC容器提供
    public Service test(Service b){
        return null;
    }
}

interface Service{ }   //使用Service做一个顶层抽象
class B implements Service{}  //B依然是具体实现类,并交给IoC容器管理

interface Service{ }
class D implements Service{}   //现在实现类变成了D,但是之前的代码并不会报错

依赖注入: Dependency Injection,简称DI。容器为应用程序提供运行时,所依赖的资源,称之为依赖注入。程序运行时需要某个资源,此时容器就为其提供这个资源。

例:EmpController程序运行时需要EmpService对象,Spring容器就为其提供并注入EmpService对象

IoC与MVC的关系:在Spring框架中,IoC容器和MVC框架紧密地结合在一起,为Web应用的开发提供了一个强大的解决方案。具体来说:

  • IoC容器 负责管理和创建MVC框架中的各个组件(如控制器、服务层对象、DAO等)。例如,在Spring MVC中,控制器(Controller)通常会被声明为Spring管理的Bean,其依赖项(比如服务层对象)也由IoC容器自动注入。

  • MVC框架 则利用这些由IoC容器管理的对象来处理HTTP请求。当一个HTTP请求到达时,Spring MVC会根据请求信息选择合适的控制器来处理该请求,并且控制器可以通过IoC容器轻松访问到它所需要的其他服务或资源。

Spring的IoC容器简化了对象的创建和管理,而Spring MVC则基于这一基础,提供了一套完整的Web应用开发解决方案。两者共同作用,极大地提高了开发效率和代码质量。

<bean name="teacher" class="com.test.bean.ProgramTeacher"/>
<bean name="student" class="com.test.bean.Student">
    <property name="teacher" ref="teacher"/>
</bean>
public static void main(String[] args) {
    ApplicationContext context = new ClassPathXmlApplicationContext("test.xml");
  	//getBean有多种形式,其中第一种就是根据类型获取对应的Bean
  	//容器中只要注册了对应类的Bean或是对应类型子类的Bean,都可以获取到
    Student student = context.getBean(Student.class);
    student.hello();
}

AOP切入:是一种编程技术,它旨在提高模块化,特别是处理程序中那些跨越多个类的功能,这些功能通常被称为横切关注点。例如,日志记录、安全性检查、事务管理等都是常见的横切关注点。

通过AOP我们可以在保证原有业务不变的情况下,添加额外的动作,比如我们的某些方法执行完成之后,需要打印日志,那么这个时候,我们就可以使用AOP来帮助我们完成,它可以批量地为这些方法添加动作。可以说,它相当于将我们原有的方法,在不改变源代码的基础上进行了增强处理。

  1. 需要切入的类,类的哪个方法需要被切入

  2. 切入之后需要执行什么动作

  3. 是在方法执行前切入还是在方法执行后切入

  4. 如何告诉Spring需要进行切入

  • 切面(Aspect):切面是关注点模块化的表现。它将横切关注点的行为封装在一起,比如日志切面、事务切面等。

  • 连接点(Join Point):程序执行过程中的某个点,比如方法调用或异常抛出。Spring AOP 仅支持方法级别的连接点。

  • 切入点(Pointcut):定义了一个或多个连接点,这些连接点是横切关注点所应用的地方。通过切入点表达式(如正则表达式)来指定。

  • 通知(Advice):定义了在特定连接点执行的动作。通知有五种类型: 前置通知(Before):在方法执行之前执行。aop:before 后置通知(After):在方法执行之后执行。aop:after-returnin 最终通知:aop:after 返回通知(After Returning):在方法正常返回之后执行。aop:after-throwing 异常通知(After Throwing):在方法抛出异常之后执行。aop:after-throwing 环绕通知(Around):在方法执行之前和之后都执行,可以控制方法执行的前后逻辑。aop:around

  • 引入(Introduction):允许我们向现有的类添加新方法或属性。

  • 目标对象(Target Object):被一个或多个切面所通知的对象。

  • 织入(Weaving):将切面应用到目标对象以创建新的代理对象的过程。织入可以发生在编译时、类加载时和运行时。Spring AOP 采用的是运行时织入。

注解功能描述使用场景举例
@Before在方法调用前执行,可以访问方法参数但不能阻止方法执行。日志记录、权限检查等。
@AfterReturning方法成功执行后执行,可以访问方法的返回值。结果缓存、审计日志等。
@AfterThrowing当被拦截的方法抛出异常时执行,可以访问异常对象。异常日志记录、发送错误通知等。
@After无论方法执行成功与否,均在方法调用后执行。资源清理、性能监控等。
@Around在方法调用前后执行,可以完全控制方法的执行,包括是否执行该方法。事务管理、动态代理等。
@Pointcut定义切入点表达式,确定哪些方法需要被通知所影响。定义通用的切入点,避免在多个通知中重复相同的切入点表达式。

<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
    <version>6.0.10</version>
</dependency>
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.ClassPathXmlApplicationContext;
public class Main {
    public static void main(String[] args) {
        // 加载 Spring 配置文件
        ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
        // 获取目标对象
        ...
        // 通过目标对象调用方法
        ...
    }
}
// 配置文件
public class Student {
    public void study(){
        System.out.println("室友还在打游戏,我狠狠的学Java,太爽了"); 
      	//现在我们希望在这个方法执行完之后,打印一些其他的内容,在不修改原有代码的情况下,该怎么做呢?
    }
}
public class StudentAOP {
  	//这个方法就是我们打算对其进行的增强操作
    public void boforeStudy() {
        System.out.println("毕业前他们算什么!!!");
    }
    public void afterStudy() {
        System.out.println("为什么毕业了他们都继承家产,我还倒给他们打工,我努力的意义在哪里...");
    }
}
<bean class="org.example.entity.Student"/>
<bean id="studentAOP" class="org.example.entity.StudentAOP"/>
<aop:config>
    <!-- 切入点 -->
    <aop:pointcut id="test" expression="public void org.example.entity.StudentAOP.sing(..)"/>
    <!-- 切面 -->
    <aop:aspect ref="studentAOP">
        <!-- 织入前后 -->
        <aop:bofore method="boforeStudy" pointcut-ref="test"></aop:bofore>
        <aop:after method="afterStudy" pointcut-ref="test"></aop:after>
    </aop:aspect>
</aop:config>
// 接口实现
public class StudentAOP implements MethodBeforeAdvice {
    @Override
    public void before(Method method, Object[] args, Object target) throws Throwable {
        System.out.println("通过Advice实现AOP");
    }
}
public class Student {
    public void study(){
        System.out.println("我是学习方法!");
    }
}
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:aop="http://www.springframework.org/schema/aop"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
           http://www.springframework.org/schema/beans/spring-beans.xsd
           http://www.springframework.org/schema/aop
           http://www.springframework.org/schema/aop/spring-aop.xsd">
    <bean id="student" class="org.example.entity.Student"/>
    <bean id="studentAOP" class="org.example.entity.StudentAOP"/>
    <aop:config>
        <aop:pointcut id="test" expression="execution(* org.example.entity.Student.study())"/>
        <!--  这里只需要添加我们刚刚写好的advisor就可以了,注意是Bean的名字  -->
        <aop:advisor advice-ref="studentAOP" pointcut-ref="test"/>
    </aop:config>
</beans>
// 接口实现
@Aspect
@Component
public class StudentAOP {
    @Before("execution(* org.example.entity.Student.study())")  //execution写法跟之前一样
    public void before(){
        System.out.println("我是之前执行的内容!");
    }
}
动态、静态代理

动态代理AOP底层的原理,Spring的AOP会基于CGLIB库和jdk的操作差不多,是通过Enhancer对象调用。

// 静态代理
public interface Performer {
    void perform();
}
// 目标对象或者被代理对象
public class Singer implements Performer {
    @Override
    public void perform() {
        System.out.println("歌手正在表演...");
    }
}
// 代理对象
public class Agent implements Performer {
    private final Performer performer;
    public Agent(Performer performer) {
        this.performer = performer;
    }
    @Override
    public void perform() {
        // 前置操作
        System.out.println("经纪人:安排演出...");
        // 调用目标对象的方法
        performer.perform();
        // 后置操作
        System.out.println("经纪人:处理合同...");
    }
}
public class Main {
    public static void main(String[] args) {
        // 创建目标对象(歌手)
        Performer singer = new Singer();
        // 创建代理对象(经纪人),传入目标对象
        Performer agent = new Agent(singer);
        // 通过代理对象调用方法
        agent.perform();
    }
}

// 动态代理,基于java.lang.reflect.Proxy类的动态代理
public interface Performer {
    void perform();
}
public class Singer implements Performer {
    @Override
    public void perform() {
        System.out.println("歌手正在表演...");
    }
}
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class Agent implements InvocationHandler {
    private final Performer performer;
    public Agent(Performer performer) {
        this.performer = performer;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 前置操作
        System.out.println("经纪人:安排演出...");
        // 调用目标对象的方法
        Object result = method.invoke(performer, args);
        // 后置操作
        System.out.println("经纪人:处理合同...");
        return result;
    }
}
import java.lang.reflect.Proxy;
public class Main {
    public static void main(String[] args) {
        // 创建目标对象(歌手)
        Performer singer = new Singer();
        // 创建 InvocationHandler(经纪人)
        Agent agent = new Agent(singer);
        // 动态创建代理对象
        Performer proxyPerformer = (Performer) Proxy.newProxyInstance(
            Performer.class.getClassLoader(),
            new Class<?>[] { Performer.class },
            agent
        );
        // 通过代理对象调用方法
        proxyPerformer.perform();
    }
}
@Controller 、@Service、@Repository、@Autowired、@Qualifier和@Repository

@Service、@Repository、@Autowired这 3 个注释和 @Component 是等效的,但是从注释类的命名上,很容易看出这 3 个注释分别和持久层、业务层和控制层(Web 层)相对应。

  • @Service用于标注业务层组件

  • @Controller用于标注控制层组件

  • @Repository用于标注数据访问组件,即Dao组件

  • @Component泛指组件,当组件不好归类的时候,我们可以使用这个注解进行标注。

标记后的类就可以@Autowired来注入其他Bean,可以配合@Qualifier指定注入的是哪一个Bean,避免冲突。

@Resource和@Autowired差不多,但指定默认按名称注入,再类型注入。

SpringMVC
  • M是指业务模型(Model):通俗的讲就是我们之前用于封装数据传递的实体类。

  • V是指用户界面(View):一般指的是前端页面。

  • C则是控制器(Controller):控制器就相当于Servlet的基本功能,处理请求,返回响应。

HandlerMapping接口负责根据传入的请求找到能够处理该请求的处理器对象(即Handler)。Spring MVC中有多种实现这个接口的方式,例如BeanNameUrlHandlerMappingSimpleUrlHandlerMapping等。每个请求到达时,DispatcherServlet会调用HandlerMapping来决定哪个Handler最适合处理该请求。

Handler是指实际处理请求的对象。它通常是一个实现了Controller接口或者注解了@Controller的Java类。在这个对象中,你会编写具体的业务逻辑来响应用户请求。

HandlerAdapter适配器用来支持不同的Handler类型。当DispatcherServlet获取到了Handler之后,它会通过HandlerAdapter来决定如何处理请求。Spring MVC提供了几种默认的HandlerAdapter,如AnnotationMethodHandlerAdapter,它支持基于注解的方法调用。

ViewResolver用于根据逻辑视图名解析出具体的视图对象。当Handler处理完请求后,通常会返回一个逻辑视图名,DispatcherServlet会使用ViewResolver来查找与该逻辑视图名对应的视图对象,然后使用该视图对象来渲染响应结果。

当一个请求到达DispatcherServlet时,它会通过HandlerMapping找到合适的Handler,并通过HandlerAdapter来执行它。执行完毕后,Handler通常会返回一个模型和视图(ModelAndView),DispatcherServlet会使用ViewResolver来解析视图,并最终渲染响应结果。

Controller控制器

有了SpringMVC之后,我们不必再像之前那样一个请求地址创建一个Servlet了,它使用DispatcherServlet替代Tomcat为我们提供的默认的静态资源Servlet,也就是说,现在所有的请求(除了jsp,因为Tomcat还提供了一个jsp的Servlet)都会经过DispatcherServlet进行处理。

那么DispatcherServlet会帮助我们做什么呢?

根据图片我们可以了解,我们的请求到达Tomcat服务器之后,会交给当前的Web应用程序进行处理,而SpringMVC使用DispatcherServlet来处理所有的请求,也就是说它被作为一个统一的访问点,所有的请求全部由它来进行调度。

当一个请求经过DispatcherServlet之后,会先走HandlerMapping,它会将请求映射为HandlerExecutionChain,依次经过HandlerInterceptor有点类似于之前我们所学的过滤器,不过在SpringMVC中我们使用的是拦截器,然后再交给HandlerAdapter,根据请求的路径选择合适的控制器进行处理,控制器处理完成之后,会返回一个ModelAndView对象,包括数据模型和视图,通俗的讲就是页面中数据和页面本身(只包含视图名称即可)。

返回ModelAndView之后,会交给ViewResolver(视图解析器)进行处理,视图解析器会对整个视图页面进行解析,SpringMVC自带了一些视图解析器,但是只适用于JSP页面,我们也可以像之前一样使用Thymeleaf作为视图解析器,这样我们就可以根据给定的视图名称,直接读取HTML编写的页面,解析为一个真正的View。

解析完成后,就需要将页面中的数据全部渲染到View中,最后返回给DispatcherServlet一个包含所有数据的成形页面,再响应给浏览器,完成整个过程。

因此,实际上整个过程我们只需要编写对应请求路径的的Controller以及配置好我们需要的ViewResolver即可,之后还可以继续补充添加拦截器,而其他的流程已经由SpringMVC帮助我们完成了。

@RequestMapping
  1. value: 指定请求的实际地址,指定的地址可以是URI Template 模式;

  2. method: 指定请求的method类型, GET、POST、PUT、DELETE等;

  3. params: 指定request中必须包含某些参数值是,才让该方法处理。

  4. headers: 指定request中必须包含某些指定的header值,才能让该方法处理请求。

  5. consumes: 指定处理请求的提交内容类型(Content-Type),例如application/json, text/html;

  6. produces: 指定返回的内容类型,仅当request请求头中的(Accept)类型中包含该指定类型才返回;

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.web.bind.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.core.annotation.AliasFor;

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Mapping
public @interface RequestMapping {
    String name() default "";

    @AliasFor("path")
    String[] value() default {};

    @AliasFor("value")
    String[] path() default {};

    RequestMethod[] method() default {};

    String[] params() default {};

    String[] headers() default {};

    String[] consumes() default {};

    String[] produces() default {};
}
@RequestParam和@RequestHeader

@RequestParam中填写参数名称,参数的值会自动传递给形式参数,我们可以直接在方法中使用,注意,如果参数名称与形式参数名称相同,即使不添加@RequestParam也能获取到参数值。

一旦添加@RequestParam,那么此请求必须携带指定参数,我们也可以将require属性设定为false来将属性设定为非必须。

@RequestMapping(value = "/index")
public ModelAndView index(@RequestParam(value = "username", required = false) String username){
    System.out.println("接受到请求参数:"+username);
    return new ModelAndView("index");
}
@SessionAttrbutie和@CookieValue

@SessionAttribute 注解用于从当前用户的session中获取属性。当用户会话中保存了一些数据(如登录状态或其他用户偏好设置),并且你需要在控制器中直接访问这些数据时,可以使用此注解来简化代码。

@RequestMapping(value = "/index")
public ModelAndView index(@SessionAttribute(value = "test", required = false) String test,
                          HttpSession session){
    session.setAttribute("test", "xxxx");
    System.out.println(test);
    return new ModelAndView("index");
}
@RequestBody和ResponseBody

@ResponseBody 注解用于标识控制器方法的返回值,表示该方法的返回值应该直接写入HTTP响应体中。Spring会根据HTTP请求头中的Accept字段和返回对象的类型,自动选择合适的转换器将对象序列化为相应的格式(如JSON、XML等)。

  • 当你需要将JSON、XML或其他格式的数据从客户端发送到服务器时。

  • 当你处理的是PUT或POST请求,并且请求体中包含了需要解析的数据时。

@PathVariable 注解用于将URL路径中的变量部分绑定到控制器方法的参数上。当处理带有动态片段的URL时,这个注解特别有用。通常,这些动态片段作为路由的一部分出现在URL中,并且需要被提取出来用于业务逻辑处理。

  • 当你需要从URL路径中提取特定的部分作为参数传递给控制器方法时。

  • 当你构建RESTful API,并希望根据资源ID或者其他标识符来处理请求时。

@ResponseBody 注解用于标识控制器方法的返回值,表示该方法的返回值应该直接写入HTTP响应体中。Spring会根据HTTP请求头中的Accept字段和返回对象的类型,自动选择合适的转换器将对象序列化为相应的格式(如JSON、XML等)。

  • 当你需要直接返回一个对象而不是一个视图名称时。

  • 当你需要返回JSON、XML或其他媒体类型的数据时。

RestFul风格中文释义为表现层状态转换http://localhost:8080/users/1314520

@PostMapping("/submit")
public ResponseEntity<String> submit(@RequestBody MyEntity entity) {
    // 处理entity对象
    return new ResponseEntity<>("Received: " + entity, HttpStatus.OK);
}

@GetMapping("/users/{userId}")
public ResponseEntity<User> getUser(@PathVariable(userId) Long id) {
    // 假设这里有一个service来获取用户信息
    User user = userService.getUserById(id);
    if (user == null) {
        return new ResponseEntity<>(HttpStatus.NOT_FOUND);
    }
    return new ResponseEntity<>(user, HttpStatus.OK);
}

@GetMapping("/getUser")
@ResponseBody
public MyEntity getUser() {
    // 创建一个MyEntity实例并填充数据
    MyEntity entity = new MyEntity();
    entity.setName("John Doe");
    return entity;
}

RestFul风格

‌‌RESTful风格是一种网络应用程序的设计风格和开发方式,基于‌HTTP协议,使用‌XML或‌JSON格式定义。‌这种风格的核心是表现层状态转换(Representational State Transfer, REST),旨在通过URL定位资源,并使用HTTP协议中定义的HTTP方法(如‌GET、‌POST、‌PUT、‌DELETE等)对资源进行操作。RESTful风格不是一种标准或协议,而是一种设计理念和原则,它强调资源的定位和操作,使得软件设计更加简洁、有层次,易于实现缓存等机制。

  • 查询必须发送GET请求

  • 新增必须发送POST请求

  • 修改必须发送PUT或者PATCH请求

  • 删除必须发送DELETE请求

RESTFul对URL的约束和规范的核心是:通过采用不同的请求方式+ URL来确定WEB服务中的资源。RESTful 的英文全称是 Representational State Transfer(表述性状态转移)。简称REST。

表述性(Representational)是:URI + 请求方式。 状态(State)是:服务器端的数据。 转移(Transfer)是:变化。 表述性状态转移是指:通过 URI + 请求方式 来控制服务器端数据的变化。

@Controller
public class MainController {

    @RequestMapping(value = "/index/{id}", method = RequestMethod.GET)
    public String get(@PathVariable("id") String text){
        System.out.println("获取用户:"+text);
        return "index";
    }

    @RequestMapping(value = "/index", method = RequestMethod.POST)
    public String post(String username){
        System.out.println("添加用户:"+username);
        return "index";
    }

    @RequestMapping(value = "/index/{id}", method = RequestMethod.DELETE)
    public String delete(@PathVariable("id") String text){
        System.out.println("删除用户:"+text);
        return "index";
    }

    @RequestMapping(value = "/index", method = RequestMethod.PUT)
    public String put(String username){
        System.out.println("修改用户:"+username);
        return "index";
    }
}
Interceptor拦截器

表单只支持GET和POST请求,所以要拦截器转换DELETE、PUT请求等

拦截器是整个SpringMVC的一个重要内容,拦截器与过滤器类似,都是用于拦截一些非法请求,但是我们之前讲解的过滤器是作用于Servlet之前,只有经过层层的过滤器才可以成功到达Servlet,而拦截器并不是在Servlet之前,它在Servlet与RequestMapping之间,相当于DispatcherServlet在将请求交给对应Controller中的方法之前进行拦截处理,它只会拦截所有Controller中定义的请求映射对应的请求(不会拦截静态资源),这里一定要区分两者的不同。

 @Slf4j
 @Component
 public class LoginInterceptor implements HandlerInterceptor { 
     /**
     * preHandle()方法:目标方法执行前执行. 返回true: 继续执行后续操作;   false:中断后续操作
     * postHandle()方法: 目标方法执行后执行
     * afterCompletion()方法:视图渲染完毕后执行 ,最后执行(暂不关注)
     */
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse res
         log.info("LoginInterceptor ⽬标⽅法执⾏前执⾏ ..");
         return true; 
     }
 
     @Override
     public void postHandle(HttpServletRequest request, HttpServletResponse respo
         log.info("LoginInterceptor ⽬标⽅法执⾏后执⾏");
     } 
     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse
         log.info("LoginInterceptor 视图渲染完毕后执⾏ ,最后执⾏");
     }
 }
                                 
 @Configuration
 public class WebConfig implements WebMvcConfigurer {
     //⾃定义的拦截器对象 
     @Autowired
     private LoginInterceptor loginInterceptor;
 
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
        //注册⾃定义拦截器对象
         registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                 .excludePathPatterns("/user/login");//设置拦截器拦截的请求路径(/**
     }
 }
拦截路径含义举例
/*⼀级路径能匹配/user,/book ,/login ,不能匹配 /user/login
/**任意级路径能匹配/user ,/user/login ,/user/reg
/book/*/book下的⼀级路径能匹配/book/addBook ,不能匹配 /book/addBook/1 ,/book
/book/**/book下的任意级路径能匹配/book,/book/addBook,/book/addBook/2 ,不能匹配/user/login
自定义异常处理

当我们的请求映射方法中出现异常时,会直接展示在前端页面,这是因为SpringMVC为我们提供了默认的异常处理页面,当出现异常时,我们的请求会被直接转交给专门用于异常处理的控制器进行处理。

@ControllerAdvice
public class ErrorController {

    @ExceptionHandler(Exception.class)
    public String error(Exception e, Model model){  //可以直接添加形参来获取异常
        e.printStackTrace();
        model.addAttribute("e", e);
        return "500";
    }
}
@RequestMapping("/index")
public String index(){
    System.out.println("我是处理!");
    if(true) throw new RuntimeException("您的氪金力度不足,无法访问!");
    return "index";
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
  500 - 服务器出现了一个内部错误QAQ
  <div th:text="${e}"></div>
</body>
</html>
JSON数据格式与Axios请求

JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,易于人阅读和编写,同时也易于机器解析和生成。JSON是完全语言无关的文本,可以使用像{ "json": "对象" }这样的结构来表示数据,并且可以轻松地在Web应用中使用JavaScript语言进行处理。

Axios 是一个基于Promise的HTTP客户端,可以在浏览器和node.js环境中运行。它提供了一个直观、灵活的API用于发送异步HTTP请求,并且可以很容易地与JavaScript框架如Vue.js等集成。

// 引入 Axios 库
import axios from 'axios';

axios.get('https://api.example.com/items/42')
  .then(function (response) {
    // 处理成功的响应
    console.log(response.data);
  })
  .catch(function (error) {
    // 处理错误情况
    console.log(error);
  });

// POST
axios.post('https://api.example.com/items', {
  title: 'foo'
})
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

// Node.js环境
const axios = require('axios');
const data = JSON.stringify({
  username: 'test',
  password: '123456'
});

var config = {
  method: 'post',
  url: 'https://api.example.com/items',
  headers: { 
    'Content-Type': 'application/json'
  },
  data : data
};

axios(config)
.then(function (response) {
  console.log(JSON.stringify(response.data));
}).catch(function (error) {
  console.log(error);
});
解读DispatcherServlet源码

最好的项目就是本身!!!

  1. 深入理解机制

    • 深入了解DispatcherServlet是如何初始化context、注册拦截器、初始化视图解析器等操作的。

    • 掌握请求是如何被分发到对应的控制器(Controller)上,并且如何处理MVC中的Model、View和Controller三个部分之间的交互。

  2. 提高调试能力

    • 当应用程序出现一些难以定位的问题时,理解框架内部的执行流程可以帮助更快地找到问题所在。

    • 能够理解请求处理过程中可能发生的异常及其原因。

  3. 增强定制能力

    • 可以根据自己的需求定制Spring MVC的行为,例如修改默认的行为来实现特定的功能或优化性能。

    • 在某些情况下,你可能需要扩展或重写DispatcherServlet的部分功能,比如自定义请求分发逻辑或异常处理机制。

  4. 促进框架贡献

    • 如果你熟悉了源码,那么当你发现框架中的bug或者有好的改进意见时,可以更容易地贡献给开源社区。

  5. 提升开发效率

    • 理解了DispatcherServlet的工作机制后,可以更高效地利用框架提供的特性,避免重复造轮子。

    • 在设计系统架构时,可以更好地结合Spring MVC的优点来规划系统的层次结构。

  6. 学习设计模式

    • DispatcherServlet的实现中运用了许多设计模式,如单例模式、工厂模式、观察者模式等,通过研究源码可以学习这些模式的应用场景及其优点。

  7. 增强理论知识

    • 学习源码的同时,也会加深对相关概念和技术的理解,比如Java多线程、I/O操作、反射机制等。

SpringSecurity

Spring Security是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富;

在 Java 生态中,目前有 Spring Security 和 Apache Shiro 两个安全框架,可以完成认证和授权的功能。

Spring Security 是一个强大的、高度可定制的身份验证和授权框架。它为 Java 应用程序提供了声明式的安全服务,包括用户认证、权限管理等功能。Spring Security 不仅可以保护基于 Spring 的应用程序,也可以与其他框架集成,保护非 Spring 应用程序。

SpringSecurity是一个基于Spring开发的非常强大的权限验证框架,其核心功能包括:

  • 认证 (用户登录)

  • 授权 (此用户能够做哪些事情)

  • 攻击防护 (防止伪造身份攻击)

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

登录

1.自定义登录接口

调用ProviderManager的方法进行认证 如果认证通过生成jwt

把用户信息存入redis中

2.自定义UserDetailsService

在这个实现类中去查询数据库

校验

1.定义Jwt认证过滤器

获取token

解析token获取其中的userid

从redis中获取用户信息

存入SecurityContextHolder

Cookie、Session、Token、JWT

HTTP是无状态协议(对于事务处理没有记忆能力,每次客户端和服务器会话结束时,服务端不会存储会话的任何信息)。

  • Cookie + Session适合于传统的Web应用,特别是那些需要维护较多服务器端状态的应用。

  • Token / JWT更适合现代的API驱动应用,尤其是单页应用(SPA)和移动应用,以及需要支持跨域请求的场景。

Cookie是存储在客户端浏览器上的小数据片段,通常用于保存会话信息(如登录状态)。当用户访问网站时,服务器可以设置一个或多个Cookie,这些Cookie会被浏览器保存,并在后续请求同一网站时自动发送给服务器。

特点:

  • 存储容量有限,通常不超过4KB。

  • 可以设置过期时间,如果未设置则关闭浏览器后失效。

  • 安全性较低,容易受到XSS攻击。

Session是一种服务器端的存储机制,用来保存特定用户的会话所需的信息。当用户登录后,服务器会创建一个Session,并将Session ID返回给客户端,客户端通过Cookie等方式携带这个ID与服务器通信。

特点:

  • 数据存储在服务器端,更加安全。

  • 需要维护Session ID与用户之间的映射关系。

  • 在分布式系统中使用Session需要额外考虑如何同步Session数据。

认证流程:

  1. 用户第一次向服务器发送请求,服务器会根据用户的信息创建一个session;

  2. 请求返回时,将这个session的唯一标识sessionId返回给浏览器;

  3. 浏览器接受sessionId之后,就将sessionId存储到cookie里,同时记录此sessionId属于哪个域名;

  4. 当用户第二次访问的时候,浏览器先会去判断域名下是否包含当前cookie信息,如果存在,就会自动将cookie信息发给服务器,服务器会从cookie中拿到sessionId,并通过sessionId去查找对应的session信息,如果未查到了session信息,说明用户未登录或登录失效,如果查到了,说明用户已经登录,在执行接下来的操作。

Token是一种令牌,用于客户端向服务器证明自己的身份。当用户成功登录后,服务器生成一个Token并返回给客户端,客户端在后续请求中携带此Token来验证身份。每次发送请求都需要携带token,需要将token放到HTTP的Header里。基于token是服务无状态的认证形式,那么就不用在服务器存储token,通过解析token的时间去换取Session的存储空间,从而减轻了服务器的压力,减少频繁的查询数据库。token是完全有系统管理的,所以他可以避免同源策略。

特点:

  • 无状态,即每次请求都包含所有必要的信息,服务器不需要存储任何会话信息。

  • 适用于分布式系统,因为不需要跨服务器共享会话状态。

  • 安全性较高,可以通过加密等手段增强安全性。

身份认证流程:

  1. 客户端先向浏览器发送登录请求;

  2. 服务器接收到客户端的请求,先去校验登录信息;

  3. 检验通过之后,为该用户签发token,并返回给客户端;

  4. 客户端将接收到的参数存到cookie或LocalStorage里;

  5. 客户端每次想服务器发起请求都会携带这个token;

  6. 服务器收到请求,先去校验token,如果校验通,就返回数据;

JWT是一种开放标准(RFC 7519),用于在网络应用环境间安全地传输信息。JWT是一个紧凑且URL安全的字符串,可以编码一定量的数据。

JWT(JSON WebTokens)是目前最流行的跨域认证解决方案。

JWT的原理:JWT认证成功之后,则会返回给客户端一个JSON对象,服务器完全靠这个对象认定用户身份。

  • 特点:

    • 包含三个部分:Header(头部)、Payload(负载)、Signature(签名)。

    • Payload可以携带用户信息,便于传递和验证。

    • 支持跨域认证,易于在微服务架构中使用。

    • 需要妥善处理Token的过期与刷新问题。

登录验证
<!--java-jwt坐标-->
<dependency>
    <groupId>com.auth0</groupId>
    <artifactId>java-jwt</artifactId>
    <version>4.4.0</version>
</dependency>
<!--SpringBoot-redis坐标-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
sping:
  data:
    redis:
     host: localhost
     port: 6379
@RestController
@RequestMapping("/user")
@Validated
public class UserController {
    @Autowired
    private UserService userService;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Post("/login")
    public Result<Sting> login(@Pattern(regexp = "^\\S{5,16}$") String username, @Pattern(regexp = "^\\S{5,16}$") String password) {
        ....
        Map<String, Object> claims = new HashMap<>();
        claims.put("id", loginUser.getId());
        claims.put("username", loginUser.getUsername());
        String token = JwtUtil.getToken(claims);
        ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
        operations.set(tokken, token, 1, TimeUnit.HOURS)
        // 删除operations.getOperations().deleto(token),记得参数加@RequestHeader("Authorization") String token
    }
}
public class JwtUtil {
    private static final String KEY = "mengtian";
	//接收业务数据,生成token并返回
    public static String genToken(Map<String, Object> claims) {
        return JWT.create()
                .withClaim("claims", claims)
                .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 ))
                .sign(Algorithm.HMAC256(KEY));
    }
	//接收token,验证token,并返回业务数据
    public static Map<String, Object> parseToken(String token) {
        return JWT.require(Algorithm.HMAC256(KEY))
                .build()
                .verify(token)
                .getClaim("claims")
                .asMap();
    }
 }

// 通过拦截器统一验证
@Component
public class LoginInterceptor implements HandlerInterceptor {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public boolean postHandle(HttpServletRequest request, HttpServletResponse response, Objest handler) throes Exception {
        String token = request.getHeader("Authorization");
        try {
            ValueOperations<String, String> operations = stringRedisTemplate.opsForValue();
            String redisToken = operations.get(token);
            if(redisToken == null){
                throw new RuntimeException;
            }
            Mapp<String, Object> claims = JwtUtil.parseToken(Token);
            ThreadLocalUtil.set(claims);
            return true;
        } catch (Exception e){
            response.setStatus(401);
            return false;
        }
    }
     @Override
     public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Objest handler, Excption ex) throes Exception {
         ThreadLocalUtil.remove;
     }
}

@Configuration
public class WebConfig implements WebMvcConfigurer{
    @Aoutowired
    private LoginInterceptor loginInterceptor;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //注册⾃定义拦截器对象
         registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/user/login","/user/register");//设置拦截器拦截的请求路径(/**
     }
}

跨域问题

Java 应用中的跨域问题通常出现在客户端(如浏览器)尝试从不同的源(域名、协议或端口)请求资源时。由于同源策略的限制,这种请求默认会被浏览器阻止,以防止潜在的安全风险。注意跨域问题只有在前端发生!

// 全局配置
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("http://localhost:8080")
            .allowedMethods("GET", "POST", "PUT", "DELETE")
            .allowedHeaders("*")
            .allowCredentials(true);
    }
}

// 注释方法
@CrossOrigin(origins = "http://localhost:8080")

// 过滤器
public class CrossOriginFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletResponse response = (HttpServletResponse) res;
        response.setHeader("Access-Control-Allow-Origin", "http://example.com");
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with, authorization");
        chain.doFilter(req, res);
    }
}

// Spring Security
@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .cors()
        .and()
        // 其他配置...
}
// 还有前端nginx可以配置
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080', // 后端API地址
        changeOrigin: true,
        pathRewrite: { '^/api': '' },
      },
    },
  },
};

微信小程序

官网:微信小程序

HttpClient

HttpClient 是Apache Jakarta Common 下的子项目,可以用来提供高效的、最新的、功能丰富的支持 HTTP 协议的客户端编程工具包,并且它支持 HTTP 协议最新的版本和建议。

需要调用其他系统接口时,尤其是基于HTTP协议的接口。例如:微信支付、查看地图、获取短信验证码、获取天气。

  • HttpClient:Http客户端对象,使用该类型对象可发起Http请求。(接口)

  • HttpClients:构建器,用于获得HttpClient对象。

  • CloseableHttpClient:具体实现类,实现了HttpClient接口。

  • HttpGet:Get方式请求类型。

  • HttpPost:Post方式请求类型。

  1. 创建HttpClient对象

  2. 创建Http请求对象(比如我们要发送一个get请求,我们就需要构造HttpGet对象)

  3. 调用HttpClient的execute对象方法发送请求

 <dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.13</version>
</dependency>

import com.alibaba.fastjson.JSONObject;
import org.apache.http.NameValuePair;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
 * Http工具类
 */
public class HttpClientUtil {
    static final  int TIMEOUT_MSEC = 5 * 1000;
    /**
     * 发送GET方式请求
     * @param url
     * @param paramMap
     * @return
     */
    public static String doGet(String url,Map<String,String> paramMap){
        // 创建Httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();
        String result = "";
        CloseableHttpResponse response = null;
        try{
            URIBuilder builder = new URIBuilder(url);
            if(paramMap != null){
                for (String key : paramMap.keySet()) {
                    builder.addParameter(key,paramMap.get(key));
                }
            }
            URI uri = builder.build();
            //创建GET请求
            HttpGet httpGet = new HttpGet(uri);
            //发送请求
            response = httpClient.execute(httpGet);
            //判断响应状态
            if(response.getStatusLine().getStatusCode() == 200){
                result = EntityUtils.toString(response.getEntity(),"UTF-8");
            }
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                response.close();
                httpClient.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return result;
    }
    /**
     * 发送POST方式请求
     * @param url
     * @param paramMap
     * @return
     * @throws IOException
     */
    public static String doPost(String url, Map<String, String> paramMap) throws IOException {
        // 创建Httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();
        CloseableHttpResponse response = null;
        String resultString = "";
        try {
            // 创建Http Post请求
            HttpPost httpPost = new HttpPost(url);

            // 创建参数列表
            if (paramMap != null) {
                List<NameValuePair> paramList = new ArrayList();
                for (Map.Entry<String, String> param : paramMap.entrySet()) {
                    paramList.add(new BasicNameValuePair(param.getKey(), param.getValue()));
                }
                // 模拟表单
                UrlEncodedFormEntity entity = new UrlEncodedFormEntity(paramList);
                httpPost.setEntity(entity);
            }
            httpPost.setConfig(builderRequestConfig());
            // 执行http请求
            response = httpClient.execute(httpPost);
            resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
        } catch (Exception e) {
            throw e;
        } finally {
            try {
                response.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        return resultString;
    }
    /**
     * 发送POST方式请求
     * @param url
     * @param paramMap
     * @return
     * @throws IOException
     */
    public static String doPost4Json(String url, Map<String, String> paramMap) throws IOException {
        // 创建Httpclient对象
        CloseableHttpClient httpClient = HttpClients.createDefault();
        CloseableHttpResponse response = null;
        String resultString = "";
        try {
            // 创建Http Post请求
            HttpPost httpPost = new HttpPost(url);

            if (paramMap != null) {
                //构造json格式数据
                JSONObject jsonObject = new JSONObject();
                for (Map.Entry<String, String> param : paramMap.entrySet()) {
                    jsonObject.put(param.getKey(),param.getValue());
                }
                StringEntity entity = new StringEntity(jsonObject.toString(),"utf-8");
                //设置请求编码
                entity.setContentEncoding("utf-8");
                //设置数据类型
                entity.setContentType("application/json");
                httpPost.setEntity(entity);
            }
            httpPost.setConfig(builderRequestConfig());
            // 执行http请求
            response = httpClient.execute(httpPost);
            resultString = EntityUtils.toString(response.getEntity(), "UTF-8");
        } catch (Exception e) {
            throw e;
        } finally {
            try {
                response.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return resultString;
    }
    private static RequestConfig builderRequestConfig() {
        return RequestConfig.custom()
                .setConnectTimeout(TIMEOUT_MSEC)
                .setConnectionRequestTimeout(TIMEOUT_MSEC)
                .setSocketTimeout(TIMEOUT_MSEC).build();
    }
}
pCI、Monitor、control unit、Scanner;CPU bF CT mF fF sT
event-driven、Assignment;tprogram、osteps、aperson、acomputer、P
dstorage、tstorage、ergcr;sC、sB、wF、vN、fA
第八单元
1、家庭或小型办公室可能只需要基本的安全性,而大型企业可能需要高维护和高级的软件和硬件以防止恶意攻击和垃圾邮件。
2、信息安全是保护数据的可用性、隐私性和完整性的过程。
3、没有安全系统是万无一失的,但是采取基本和实际的步骤来保护数据对于良好的信息安全是至关重要的。
4、为了使访问尽可能安全,用户应该创建混合使用大小写字母、数字和符号的密码,并且避免容易猜测的组合,例如生日或姓氏。
5、黑客获取安全信息的一种方法是通过恶意软件,包括计算机病毒、间谍软件、蠕虫和其他程序。
6、使用强大的杀毒软件是提高信息安全的最好方法之一。
7、大多数操作系统都包含一个基本的防病毒程序,可以在一定程度上保护计算机。
8、防火墙通过防止对网络的未授权访问帮助维护计算机信息安全。
9、防火墙过滤通过它们的信息,只允许授权的内容。
10、在商业和个人行为中,通过谨慎和常识来维护信息安全的重要性不能低估。
第六单元
1、在计算机网络中,计算设备使用节点之间的连接(数据链路)彼此交换数据。
2、这些数据链路通过有线介质(如有线或光缆)或无线介质(如WiFi)建立。
3、局域网(LAN)是连接有限地理区域内的计算机和设备的网络,如家庭、学校、办公楼或位置接近的建筑群。
4、SAN主要用于使服务器能够访问诸如磁盘阵列、磁带库和光盘盒之类的存储设备,以便这些设备看起来像本地连接到操作系统的设备。
5、广域网通常利用公共运营商例如电话公司提供的传输设施。
6、交换机不同于集线器,因为它只将帧转发到通信中所涉及的物理端口,而不是所有连接的端口。
7、路由器使用其路由表来确定在哪里转发分组。
8、Internet是一个全球互联计算机网络系统,使用Internet协议套件(TCP/IP)链接全球设备。
9、1990年代初,商业网络和企业的连接标志着向现代互联网过渡的开始。
10、因特网在技术实现或访问和使用策略上都没有集中管理。
第五单元
1、操作系统(OS)是管理计算机硬件和软件资源并为计算机程序提供公共服务的系统软件。
2、Linux发行版在服务器和超级计算领域占据主导地位。Linux发行版在服务器和超级计算领域占据主导地位。
3、单任务系统一次只能运行一个程序,而多任务操作系统允许多个程序同时运行。
4、多任务的特征可以是抢占型和合作型。
5、分时操作系统调度任务以便有效地使用系统,并且还可以包括用于向多个用户分配处理器时间、大容量存储、打印和其他资源的成本分配的记账软件。
6、分布式操作系统管理一组不同的计算机,并使它们看起来像单个计算机。
7、实时操作系统是保证在特定时刻及时处理事件或数据的操作系统。
8、移动操作系统(或移动OS)是用于电话、平板电脑、智能手表或其他移动设备的操作系统。
9、Android比流行的桌面操作系统Windows更流行,而且一般来说,智能手机的使用(即使没有平板电脑)也超过桌面的使用。
10、它是封闭源代码和专有的,并且构建在开源达尔文操作系统上。

三 技术与中间件

3.1 Redis数据库

Redis是一个基于内存的key-value结构数据库

  • 基于内存存储,读写性能高

  • 适合存储热点数据(热点商品、咨讯、新闻)

  • 企业应用广泛

点击查看源网页

灵魂拷问: 不是学了MySQL吗,存数据也能存了啊,又学一个数据库干嘛?

在前面我们学习了MySQL数据库,它是一种传统的关系型数据库,我们可以使用MySQL来更好地管理和组织我们的数据,虽然在小型Web应用下,只需要一个MySQL+Mybatis自带的缓存系统就可以胜任大部分的数据存储工作。但是MySQL的缺点也很明显,它的数据始终是存储在硬盘上的,对于我们的用户信息这种不需要经常发生修改的内容,使用MySQL存储确实可以,但是如果是快速更新或是频繁使用的数据,比如微博热搜、双十一秒杀,这些数据不仅要求服务器需要提供更高的响应速度,而且还需要面对短时间内上百万甚至上千万次访问,而MySQL的磁盘IO读写性能完全不能满足上面的需求,能够满足上述需求的只有内存,因为速度远高于磁盘IO。

因此,我们需要寻找一种更好的解决方案,来存储上述这类特殊数据,弥补MySQL的不足,以应对大数据时代的重重考验。

NoSQL概论

NoSQL全称是Not Only SQL(不仅仅是SQL)它是一种非关系型数据库,相比传统SQL关系型数据库,它:

  • 不保证关系数据的ACID特性

  • 并不遵循SQL标准

  • 消除数据之间关联性

乍一看,这玩意不比MySQL垃圾?我们再来看看它的优势:

  • 远超传统关系型数据库的性能

  • 非常易于扩展

  • 数据模型更加灵活

  • 高可用

这样,NoSQL的优势一下就出来了,这不就是我们正要寻找的高并发海量数据的解决方案吗!

NoSQL数据库分为以下几种:

  • 键值存储数据库: 所有的数据都是以键值方式存储的,类似于我们之前学过的HashMap,使用起来非常简单方便,性能也非常高。

  • 列存储数据库: 这部分数据库通常是用来应对分布式存储的海量数据。键仍然存在,但是它们的特点是指向了多个列。

  • 文档型数据库: 它是以一种特定的文档格式存储数据,比如JSON格式,在处理网页等复杂数据时,文档型数据库比传统键值数据库的查询效率更高。

  • 图形数据库: 利用类似于图的数据结构存储数据,结合图相关算法实现高速访问。

其中我们要学习的Redis数据库,就是一个开源的键值存储数据库,所有的数据全部存放在内存中,它的性能大大高于磁盘IO,并且它也可以支持数据持久化,他还支持横向扩展、主从复制等。

实际生产中,我们一般会配合使用Redis和MySQL以发挥它们各自的优势,取长补短。

Redis安装和部署

我们这里还是使用Windows安装Redis服务器,但是官方指定是安装到Linux服务器上,我们后面学习了Linux之后,再来安装到Linux服务器上。由于官方并没有提供Windows版本的安装包,我们需要另外寻找:


基本操作

在我们之前使用MySQL时,我们需要先在数据库中创建一张表,并定义好表的每个字段内容,最后再通过insert语句向表中添加数据,而Redis并不具有MySQL那样的严格的表结构,Redis是一个键值数据库,因此,可以像Map一样的操作方式,通过键值对向Redis数据库中添加数据(操作起来类似于向一个HashMap中存放数据)

在Redis下,数据库是由一个整数索引标识,而不是由一个数据库名称。 默认情况下,我们连接Redis数据库之后,会使用0号数据库,我们可以通过Redis配置文件中的参数来修改数据库总数,默认为16个。

我们可以通过select语句进行切换:

select 序号;
数据操作

我们来看看,如何向Redis数据库中添加数据:

set <key> <value>
-- 一次性多个
mset [<key> <value>]...

所有存入的数据默认会以字符串的形式保存,键值具有一定的命名规范,以方便我们可以快速定位我们的数据属于哪一个部分,比如用户的数据:

-- 使用冒号来进行板块分割,比如下面表示用户XXX的信息中的name属性,值为lbw
set user:info:用户ID:name lbw

我们可以通过键值获取存入的值:

get <key>

你以为Redis就仅仅只是存取个数据吗?它还支持数据的过期时间设定:

set <key> <value> EX 秒
set <key> <value> PX 毫秒

当数据到达指定时间时,会被自动删除。我们也可以单独为其他的键值对设置过期时间:

expire <key> 秒

通过下面的命令来查询某个键值对的过期时间还剩多少:

ttl <key>
-- 毫秒显示
pttl <key>
-- 转换为永久
persist <key>

那么当我们想直接删除这个数据时呢?直接使用:

del <key>...

删除命令可以同时拼接多个键值一起删除。

当我们想要查看数据库中所有的键值时:

keys *

也可以查询某个键是否存在:

exists <key>...

还可以随机拿一个键:

randomkey

我们可以将一个数据库中的内容移动到另一个数据库中:

move <key> 数据库序号

修改一个键为另一个键:

rename <key> <新的名称>
-- 下面这个会检查新的名称是否已经存在
renamex <key> <新的名称>

如果存放的数据是一个数字,我们还可以对其进行自增自减操作:

-- 等价于a = a + 1
incr <key>
-- 等价于a = a + b
incrby <key> b
-- 等价于a = a - 1
decr <key>

最后就是查看值的数据类型:

type <key>

Redis数据库也支持多种数据类型,但是它更偏向于我们在Java中认识的那些数据类型。

数据类型介绍

一个键值对除了存储一个String类型的值以外,还支持多种常用的数据类型。

Hash

这种类型本质上就是一个HashMap,也就是嵌套了一个HashMap罢了,在Java中就像这样:

#Redis默认存String类似于这样:
Map<String, String> hash = new HashMap<>();
#Redis存Hash类型的数据类似于这样:
Map<String, Map<String, String>> hash = new HashMap<>();

它比较适合存储类这样的数据,由于值本身又是一个Map,因此我们可以在此Map中放入类的各种属性和值,以实现一个Hash数据类型存储一个类的数据。

我们可以像这样来添加一个Hash类型的数据:

hset <key> [<字段> <值>]...

我们可以直接获取:

hget <key> <字段>
-- 如果想要一次性获取所有的字段和值
hgetall <key>

同样的,我们也可以判断某个字段是否存在:

hexists <key> <字段>

删除Hash中的某个字段:

hdel <key>

我们发现,在操作一个Hash时,实际上就是我们普通操作命令前面添加一个h,这样就能以同样的方式去操作Hash里面存放的键值对了,这里就不一一列出所有的操作了。我们来看看几个比较特殊的。

我们现在想要知道Hash中一共存了多少个键值对:

hlen <key>

我们也可以一次性获取所有字段的值:

hvals <key>

唯一需要注意的是,Hash中只能存放字符串值,不允许出现嵌套的的情况。

List

我们接着来看List类型,实际上这个猜都知道,它就是一个列表,而列表中存放一系列的字符串,它支持随机访问,支持双端操作,就像我们使用Java中的LinkedList一样。

我们可以直接向一个已存在或是不存在的List中添加数据,如果不存在,会自动创建:

-- 向列表头部添加元素
lpush <key> <element>...
-- 向列表尾部添加元素
rpush <key> <element>...
-- 在指定元素前面/后面插入元素
linsert <key> before/after <指定元素> <element>

同样的,获取元素也非常简单:

-- 根据下标获取元素
lindex <key> <下标>
-- 获取并移除头部元素
lpop <key>
-- 获取并移除尾部元素
rpop <key>
-- 获取指定范围内的
lrange <key> start stop

注意下标可以使用负数来表示从后到前数的数字(Python:搁这儿抄呢是吧):

-- 获取列表a中的全部元素
lrange a 0 -1

没想到吧,push和pop还能连着用呢:

-- 从前一个数组的最后取一个数出来放到另一个数组的头部,并返回元素
rpoplpush 当前数组 目标数组

它还支持阻塞操作,类似于生产者和消费者,比如我们想要等待列表中有了数据后再进行pop操作:

-- 如果列表中没有元素,那么就等待,如果指定时间(秒)内被添加了数据,那么就执行pop操作,如果超时就作废,支持同时等待多个列表,只要其中一个列表有元素了,那么就能执行
blpop <key>... timeout
Set和SortedSet

Set集合其实就像Java中的HashSet一样(我们在JavaSE中已经讲解过了,HashSet本质上就是利用了一个HashMap,但是Value都是固定对象,仅仅是Key不同)它不允许出现重复元素,不支持随机访问,但是能够利用Hash表提供极高的查找效率。

向Set中添加一个或多个值:

sadd <key> <value>...

查看Set集合中有多少个值:

scard <key>

判断集合中是否包含:

-- 是否包含指定值
sismember <key> <value>
-- 列出所有值
smembers <key>

集合之间的运算:

-- 集合之间的差集
sdiff <key1> <key2>
-- 集合之间的交集
sinter <key1> <key2>
-- 求并集
sunion <key1> <key2>
-- 将集合之间的差集存到目标集合中
sdiffstore 目标 <key1> <key2>
-- 同上
sinterstore 目标 <key1> <key2>
-- 同上
sunionstore 目标 <key1> <key2>

移动指定值到另一个集合中:

smove <key> 目标 value 

移除操作:

-- 随机移除一个幸运儿
spop <key>
-- 移除指定
srem <key> <value>...

那么如果我们要求Set集合中的数据按照我们指定的顺序进行排列怎么办呢?这时就可以使用SortedSet,它支持我们为每个值设定一个分数,分数的大小决定了值的位置,所以它是有序的。

我们可以添加一个带分数的值:

zadd <key> [<value> <score>]...

同样的:

-- 查询有多少个值
zcard <key>
-- 移除
zrem <key> <value>...
-- 获取区间内的所有
zrange <key> start stop

由于所有的值都有一个分数,我们也可以根据分数段来获取:

-- 通过分数段查看
zrangebyscore <key> start stop [withscores] [limit]
-- 统计分数段内的数量
zcount <key>  start stop
-- 根据分数获取指定值的排名
zrank <key> <value>

redis基本操作命令 - 简书

有关Bitmap、HyperLogLog和Geospatial等数据类型,这里暂时不做介绍,感兴趣可以自行了解。


持久化

我们知道,Redis数据库中的数据都是存放在内存中,虽然很高效,但是这样存在一个非常严重的问题,如果突然停电,那我们的数据不就全部丢失了吗?它不像硬盘上的数据,断电依然能够保存。

这个时候我们就需要持久化,我们需要将我们的数据备份到硬盘上,防止断电或是机器故障导致的数据丢失。

持久化的实现方式有两种方案:一种是直接保存当前已经存储的数据,相当于复制内存中的数据到硬盘上,需要恢复数据时直接读取即可;还有一种就是保存我们存放数据的所有过程,需要恢复数据时,只需要将整个过程完整地重演一遍就能保证与之前数据库中的内容一致。

RDB

RDB就是我们所说的第一种解决方案,那么如何将数据保存到本地呢?我们可以使用命令:

save
-- 注意上面这个命令是直接保存,会占用一定的时间,也可以单独开一个子进程后台执行保存
bgsave

执行后,会在服务端目录下生成一个dump.rdb文件,而这个文件中就保存了内存中存放的数据,当服务器重启后,会自动加载里面的内容到对应数据库中。保存后我们可以关闭服务器:

shutdown

重启后可以看到数据依然存在。

点击查看图片来源

虽然这种方式非常方便,但是由于会完整复制所有的数据,如果数据库中的数据量比较大,那么复制一次可能就需要花费大量的时间,所以我们可以每隔一段时间自动进行保存;还有就是,如果我们基本上都是在进行读操作,而没有进行写操作,实际上只需要偶尔保存一次即可,因为数据几乎没有怎么变化,可能两次保存的都是一样的数据。

我们可以在配置文件中设置自动保存,并设定在一段时间内写入多少数据时,执行一次保存操作:

save 300 10 # 300秒(5分钟)内有10个写入
save 60 10000 # 60秒(1分钟)内有10000个写入

配置的save使用的都是bgsave后台执行。

AOF

虽然RDB能够很好地解决数据持久化问题,但是它的缺点也很明显:每次都需要去完整地保存整个数据库中的数据,同时后台保存过程中也会产生额外的内存开销,最严重的是它并不是实时保存的,如果在自动保存触发之前服务器崩溃,那么依然会导致少量数据的丢失。

而AOF就是另一种方式,它会以日志的形式将我们每次执行的命令都进行保存,服务器重启时会将所有命令依次执行,通过这种重演的方式将数据恢复,这样就能很好解决实时性存储问题。

rdb和aof区别

但是,我们多久写一次日志呢?我们可以自己配置保存策略,有三种策略:

  • always:每次执行写操作都会保存一次

  • everysec:每秒保存一次(默认配置),这样就算丢失数据也只会丢一秒以内的数据

  • no:看系统心情保存

可以在配置文件中配置:

# 注意得改成也是
appendonly yes

# appendfsync always
appendfsync everysec
# appendfsync no

重启服务器后,可以看到服务器目录下多了一个appendonly.aof文件,存储的就是我们执行的命令。

AOF的缺点也很明显,每次服务器启动都需要进行过程重演,相比RDB更加耗费时间,并且随着我们的操作变多,不断累计,可能到最后我们的aof文件会变得无比巨大,我们需要一个改进方案来优化这些问题。

Redis有一个AOF重写机制进行优化,比如我们执行了这样的语句:

lpush test 666
lpush test 777
lpush test 888

实际上用一条语句也可以实现:

lpush test 666 777 888

正是如此,只要我们能够保证最终的重演结果和原有语句的结果一致,无论语句如何修改都可以,所以我们可以通过这种方式将多条语句进行压缩。

我们可以输入命令来手动执行重写操作:

bgrewriteaof

或是在配置文件中配置自动重写:

# 百分比计算,这里不多介绍
auto-aof-rewrite-percentage 100
# 当达到这个大小时,触发自动重写
auto-aof-rewrite-min-size 64mb

至此,我们就完成了两种持久化方案的介绍,最后我们再来进行一下总结:

  • AOF:

    • 优点:存储速度快、消耗资源少、支持实时存储

    • 缺点:加载速度慢、数据体积大

  • RDB:

    • 优点:加载速度快、数据体积小

    • 缺点:存储速度慢大量消耗资源、会发生数据丢失


事务和锁机制

和MySQL一样,在Redis中也有事务机制,当我们需要保证多条命令一次性完整执行而中途不受到其他命令干扰时,就可以使用事务机制。

我们可以使用命令来直接开启事务:

multi

当我们输入完所有要执行的命令时,可以使用命令来立即执行事务:

exec

我们也可以中途取消事务:

discard

实际上整个事务是创建了一个命令队列,它不像MySQL那种在事务中也能单独得到结果,而是我们提前将所有的命令装在队列中,但是并不会执行,而是等我们提交事务的时候再统一执行。

又提到锁了,实际上这个概念对我们来说已经不算是陌生了。实际上在Redis中也会出现多个命令同时竞争同一个数据的情况,比如现在有两条命令同时执行,他们都要去修改a的值,那么这个时候就只能动用锁机制来保证同一时间只能有一个命令操作。

虽然Redis中也有锁机制,但是它是一种乐观锁,不同于MySQL,我们在MySQL中认识的锁是悲观锁,那么什么是乐观锁什么是悲观锁呢?

  • 悲观锁:时刻认为别人会来抢占资源,禁止一切外来访问,直到释放锁,具有强烈的排他性质。

  • 乐观锁:并不认为会有人来抢占资源,所以会直接对数据进行操作,在操作时再去验证是否有其他人抢占资源。

Redis中可以使用watch来监视一个目标,如果执行事务之前被监视目标发生了修改,则取消本次事务:

watch

我们可以开两个客户端进行测试。

取消监视可以使用:

unwatch

至此,Redis的基础内容就讲解完毕了,在之后的SpringCloud阶段,我们还会去讲解集群相关的知识,包括主从复制、哨兵模式等。


使用Java与Redis交互

既然了解了如何通过命令窗口操作Redis数据库,那么我们如何使用Java来操作呢?

这里我们需要使用到Jedis框架,它能够实现Java与Redis数据库的交互,依赖:

<dependencies>
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>4.0.0</version>
    </dependency>
</dependencies>
基本操作

我们来看看如何连接Redis数据库,非常简单,只需要创建一个对象即可:

public static void main(String[] args) {
    //创建Jedis对象
    Jedis jedis = new Jedis("localhost", 6379);
  	
  	//使用之后关闭连接
  	jedis.close();
}

通过Jedis对象,我们就可以直接调用命令的同名方法来执行Redis命令了,比如:

public static void main(String[] args) {
    //直接使用try-with-resouse,省去close
    try(Jedis jedis = new Jedis("192.168.10.3", 6379)){
        jedis.set("test", "lbwnb");   //等同于 set test lbwnb 命令
        System.out.println(jedis.get("test"));  //等同于 get test 命令
    }
}

Hash类型的数据也是这样:

public static void main(String[] args) {
    try(Jedis jedis = new Jedis("192.168.10.3", 6379)){
        jedis.hset("hhh", "name", "sxc");   //等同于 hset hhh name sxc
        jedis.hset("hhh", "sex", "19");    //等同于 hset hhh age 19
        jedis.hgetAll("hhh").forEach((k, v) -> System.out.println(k+": "+v));
    }
}

我们接着来看看列表操作:

public static void main(String[] args) {
    try(Jedis jedis = new Jedis("192.168.10.3", 6379)){
        jedis.lpush("mylist", "111", "222", "333");  //等同于 lpush mylist 111 222 333 命令
        jedis.lrange("mylist", 0, -1)
                .forEach(System.out::println);    //等同于 lrange mylist 0 -1
    }
}

实际上我们只需要按照对应的操作去调用同名方法即可,所有的类型封装Jedis已经帮助我们完成了。

SpringBoot整合Redis

我们接着来看如何在SpringBoot项目中整合Redis操作框架,只需要一个starter即可,但是它底层没有用Jedis,而是Lettuce:

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

starter提供的默认配置会去连接本地的Redis服务器,并使用0号数据库,当然你也可以手动进行修改:

spring:
  redis:
  	#Redis服务器地址
    host: 192.168.10.3
    #端口
    port: 6379
    #使用几号数据库
    database: 0

starter已经给我们提供了两个默认的模板类:

@Configuration(
    proxyBeanMethods = false
)
@ConditionalOnClass({RedisOperations.class})
@EnableConfigurationProperties({RedisProperties.class})
@Import({LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class})
public class RedisAutoConfiguration {
    public RedisAutoConfiguration() {
    }

    @Bean
    @ConditionalOnMissingBean(
        name = {"redisTemplate"}
    )
    @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<Object, Object> template = new RedisTemplate();
        template.setConnectionFactory(redisConnectionFactory);
        return template;
    }

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnSingleCandidate(RedisConnectionFactory.class)
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) {
        return new StringRedisTemplate(redisConnectionFactory);
    }
}

那么如何去使用这两个模板类呢?我们可以直接注入StringRedisTemplate来使用模板:

@SpringBootTest
class SpringBootTestApplicationTests {

    @Autowired
    StringRedisTemplate template;

    @Test
    void contextLoads() {
        ValueOperations<String, String> operations = template.opsForValue();
        operations.set("c", "xxxxx");   //设置值
        System.out.println(operations.get("c"));   //获取值
      	
        template.delete("c");    //删除键
        System.out.println(template.hasKey("c"));   //判断是否包含键
    }

}

实际上所有的值的操作都被封装到了ValueOperations对象中,而普通的键操作直接通过模板对象就可以使用了,大致使用方式其实和Jedis一致。

我们接着来看看事务操作,由于Spring没有专门的Redis事务管理器,所以只能借用JDBC提供的,只不过无所谓,正常情况下反正我们也要用到这玩意:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
@Service
public class RedisService {

    @Resource
    StringRedisTemplate template;

    @PostConstruct
    public void init(){
        template.setEnableTransactionSupport(true);   //需要开启事务
    }

    @Transactional    //需要添加此注解
    public void test(){
        template.multi();
        template.opsForValue().set("d", "xxxxx");
        template.exec();
    }
}

我们还可以为RedisTemplate对象配置一个Serializer来实现对象的JSON存储:

@Test
void contextLoad2() {
    //注意Student需要实现序列化接口才能存入Redis
    template.opsForValue().set("student", new Student());
    System.out.println(template.opsForValue().get("student"));
}

使用Redis做缓存

我们可以轻松地使用Redis来实现一些框架的缓存和其他存储。

Mybatis二级缓存

还记得我们在学习Mybatis讲解的缓存机制吗,我们当时介绍了二级缓存,它是Mapper级别的缓存,能够作用与所有会话。但是当时我们提出了一个问题,由于Mybatis的默认二级缓存只能是单机的,如果存在多台服务器访问同一个数据库,实际上二级缓存只会在各自的服务器上生效,但是我们希望的是多台服务器都能使用同一个二级缓存,这样就不会造成过多的资源浪费。

img

我们可以将Redis作为Mybatis的二级缓存,这样就能实现多台服务器使用同一个二级缓存,因为它们只需要连接同一个Redis服务器即可,所有的缓存数据全部存储在Redis服务器上。我们需要手动实现Mybatis提供的Cache接口,这里我们简单编写一下:

//实现Mybatis的Cache接口
public class RedisMybatisCache implements Cache {

    private final String id;
    private static RedisTemplate<Object, Object> template;

   	//注意构造方法必须带一个String类型的参数接收id
    public RedisMybatisCache(String id){
        this.id = id;
    }

  	//初始化时通过配置类将RedisTemplate给过来
    public static void setTemplate(RedisTemplate<Object, Object> template) {
        RedisMybatisCache.template = template;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public void putObject(Object o, Object o1) {
      	//这里直接向Redis数据库中丢数据即可,o就是Key,o1就是Value,60秒为过期时间
        template.opsForValue().set(o, o1, 60, TimeUnit.SECONDS);
    }

    @Override
    public Object getObject(Object o) {
      	//这里根据Key直接从Redis数据库中获取值即可
        return template.opsForValue().get(o);
    }

    @Override
    public Object removeObject(Object o) {
      	//根据Key删除
        return template.delete(o);
    }

    @Override
    public void clear() {
      	//由于template中没封装清除操作,只能通过connection来执行
				template.execute((RedisCallback<Void>) connection -> {
          	//通过connection对象执行清空操作
            connection.flushDb();
            return null;
        });
    }

    @Override
    public int getSize() {
      	//这里也是使用connection对象来获取当前的Key数量
        return template.execute(RedisServerCommands::dbSize).intValue();
    }
}

缓存类编写完成后,我们接着来编写配置类:

@Configuration
public class MainConfiguration {
    @Resource
    RedisTemplate<Object, Object> template;

    @PostConstruct
    public void init(){
      	//把RedisTemplate给到RedisMybatisCache
        RedisMybatisCache.setTemplate(template);
    }
}

最后我们在Mapper上启用此缓存即可:

//只需要修改缓存实现类implementation为我们的RedisMybatisCache即可
@CacheNamespace(implementation = RedisMybatisCache.class)
@Mapper
public interface MainMapper {

    @Select("select name from student where sid = 1")
    String getSid();
}

最后我们提供一个测试用例来查看当前的二级缓存是否生效:

@SpringBootTest
class SpringBootTestApplicationTests {


    @Resource
    MainMapper mapper;

    @Test
    void contextLoads() {
        System.out.println(mapper.getSid());
        System.out.println(mapper.getSid());
        System.out.println(mapper.getSid());
    }

}

手动使用客户端查看Redis数据库,可以看到已经有一条Mybatis生成的缓存数据了。

Token持久化存储

我们之前使用SpringSecurity时,remember-me的Token是支持持久化存储的,而我们当时是存储在数据库中,那么Token信息能否存储在缓存中呢,当然也是可以的,我们可以手动实现一个:

//实现PersistentTokenRepository接口
@Component
public class RedisTokenRepository implements PersistentTokenRepository {
  	//Key名称前缀,用于区分
    private final static String REMEMBER_ME_KEY = "spring:security:rememberMe:";
    @Resource
    RedisTemplate<Object, Object> template;

    @Override
    public void createNewToken(PersistentRememberMeToken token) {
      	//这里要放两个,一个存seriesId->Token,一个存username->seriesId,因为删除时是通过username删除
        template.opsForValue().set(REMEMBER_ME_KEY+"username:"+token.getUsername(), token.getSeries());
        template.expire(REMEMBER_ME_KEY+"username:"+token.getUsername(), 1, TimeUnit.DAYS);
        this.setToken(token);
    }

  	//先获取,然后修改创建一个新的,再放入
    @Override
    public void updateToken(String series, String tokenValue, Date lastUsed) {
        PersistentRememberMeToken token = this.getToken(series);
        if(token != null)
           this.setToken(new PersistentRememberMeToken(token.getUsername(), series, tokenValue, lastUsed));
    }

    @Override
    public PersistentRememberMeToken getTokenForSeries(String seriesId) {
        return this.getToken(seriesId);
    }

  	//通过username找seriesId直接删除这两个
    @Override
    public void removeUserTokens(String username) {
        String series = (String) template.opsForValue().get(REMEMBER_ME_KEY+"username:"+username);
        template.delete(REMEMBER_ME_KEY+series);
        template.delete(REMEMBER_ME_KEY+"username:"+username);
    }

  
  	//由于PersistentRememberMeToken没实现序列化接口,这里只能用Hash来存储了,所以单独编写一个set和get操作
    private PersistentRememberMeToken getToken(String series){
        Map<Object, Object> map = template.opsForHash().entries(REMEMBER_ME_KEY+series);
        if(map.isEmpty()) return null;
        return new PersistentRememberMeToken(
                (String) map.get("username"),
                (String) map.get("series"),
                (String) map.get("tokenValue"),
                new Date(Long.parseLong((String) map.get("date"))));
    }

    private void setToken(PersistentRememberMeToken token){
        Map<String, String> map = new HashMap<>();
        map.put("username", token.getUsername());
        map.put("series", token.getSeries());
        map.put("tokenValue", token.getTokenValue());
        map.put("date", ""+token.getDate().getTime());
        template.opsForHash().putAll(REMEMBER_ME_KEY+token.getSeries(), map);
        template.expire(REMEMBER_ME_KEY+token.getSeries(), 1, TimeUnit.DAYS);
    }
}

接着把验证Service实现了:

@Service
public class AuthService implements UserDetailsService {

    @Resource
    UserMapper mapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Account account = mapper.getAccountByUsername(username);
        if(account == null) throw new UsernameNotFoundException("");
        return User
                .withUsername(username)
                .password(account.getPassword())
                .roles(account.getRole())
                .build();
    }
}

Mapper也安排上:

@Data
public class Account implements Serializable {
    int id;
    String username;
    String password;
    String role;
}
@CacheNamespace(implementation = MybatisRedisCache.class)
@Mapper
public interface UserMapper {

    @Select("select * from users where username = #{username}")
    Account getAccountByUsername(String username);
}

最后配置文件配一波:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
            .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .and()
            .rememberMe()
            .tokenRepository(repository);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth
            .userDetailsService(service)
            .passwordEncoder(new BCryptPasswordEncoder());
}

OK,启动服务器验证一下吧。


三大缓存问题

注意: 这部分内容作为选学内容。

虽然我们可以利用缓存来大幅度提升我们程序的数据获取效率,但是使用缓存也存在着一些潜在的问题。

缓存穿透

img

当我们去查询一个一定不存在的数据,比如Mybatis在缓存是未命中的情况下需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。

这显然是很浪费资源的,我们希望的是,如果这个数据不存在,为什么缓存这一层不直接返回空呢,这时就不必再去查数据库了,但是也有一个问题,缓存不去查数据库怎么知道数据库里面到底有没有这个数据呢?

这时我们就可以使用布隆过滤器来进行判断。什么是布隆过滤器?(当然不是打辅助的那个布隆,只不过也挺像,辅助布隆也是挡子弹的)

点击查看图片来源

使用布隆过滤器,能够告诉你某样东西一定不存在或是某样东西可能存在。

布隆过滤器本质是一个存放二进制位的bit数组,如果我们要添加一个值到布隆过滤器中,我们需要使用N个不同的哈希函数来生成N个哈希值,并对每个生成的哈希值指向的bit位置1,如上图所示,一共添加了三个值abc。

接着我们给一个d,那么这时就可以进行判断,如果说d计算的N个哈希值的位置上都是1,那么就说明d可能存在;这时候又来了个e,计算后我们发现有一个位置上的值是0,这时就可以直接断定e一定不存在。

缓存击穿

img

某个 Key 属于热点数据,访问非常频繁,同一时间很多人都在访问,在这个Key失效的瞬间,大量的请求到来,这时发现缓存中没有数据,就全都直接请求数据库,相当于击穿了缓存屏障,直接攻击整个系统核心。

这种情况下,最好的解决办法就是不让Key那么快过期,如果一个Key处于高频访问,那么可以适当地延长过期时间。

缓存雪崩

img

当你的Redis服务器炸了或是大量的Key在同一时间过期,这时相当于缓存直接GG了,那么如果这时又有很多的请求来访问不同的数据,同一时间内缓存服务器就得向数据库大量发起请求来重新建立缓存,很容易把数据库也搞GG。

解决这种问题最好的办法就是设置高可用,也就是搭建Redis集群,当然也可以采取一些服务熔断降级机制,这些内容我们会在SpringCloud阶段再进行探讨。

3.2 Mybatis

面向对象三层架构:web层(SpringMVC)、server层(Spring)、dao层(Mybatis)

MyBatis 是一款优秀的持久层框架,它支持定制化 SQL、存储过程以及高级映射。MyBatis 避免了几乎所有的 JDBC 代码和手动设置参数以及获取结果集。MyBatis 可以使用简单的 XML 或注解来配置和映射原生信息,将接口和 Java 的 POJOs(Plain Ordinary Java Object,普通的 Java对象)映射成数据库中的记录。

  • 简单易学:相比Hibernate等全ORM(Object对象 Relational关系 Mapping映射)框架,MyBatis更加简单直观。

  • 高效灵活:提供强大的动态SQL功能,满足各种复杂查询需求。

  • 易于维护:良好的分离了业务逻辑与数据访问逻辑,使得代码结构清晰。

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

在前面JDBC的学习中,虽然我们能够通过JDBC来连接和操作数据库,但是哪怕只是完成一个SQL语句的执行,都需要编写大量的代码,更不用说如果我还需要进行实体类映射,将数据转换为我们可以直接操作的实体类型,JDBC很方便,但是还不够方便,我们需要一种更加简洁高效的方式来和数据库进行交互。

再次强调: 学习厉害的框架或是厉害的技术,并不是为了一定要去使用它,而是它们能够使得我们在不同的开发场景下,合理地使用这些技术,以灵活地应对需要解决的问题。

image-20230306163528771

XML语言概述

在开始介绍Mybatis之前,XML语言发明最初是用于数据的存储和传输,它可以长这样:

<?xml version="1.0" encoding="UTF-8" ?>
<outer>
  <name>梦天</name></name>
  <desc>怎么又在玩电动啊</desc>
	<inner type="1">
    <age>17</age>
    <sex>男</sex>
  </inner>
</outer>

如果你学习过前端知识,你会发现它和HTML几乎长得一模一样!但是请注意,虽然它们长得差不多,但是他们的意义却不同,HTML主要用于通过编排来展示数据,而XML主要是存放数据,它更像是一个配置文件!当然,浏览器也是可以直接打开XML文件的。

一个XML文件存在以下的格式规范:

  • 必须存在一个根节点,将所有的子标签全部包含。

  • 可以但不必须包含一个头部声明(主要是可以设定编码格式)

  • 所有的标签必须成对出现,可以嵌套但不能交叉嵌套

  • 区分大小写。

  • 标签中可以存在属性,比如上面的type="1"就是inner标签的一个属性,属性的值由单引号或双引号包括。

XML文件也可以使用注释:

<?xml version="1.0" encoding="UTF-8" ?>
<!-- 注释内容 -->

通过IDEA我们可以使用Ctrl+/来快速添加注释文本(不仅仅适用于XML,还支持很多种类型的文件)

那如果我们的内容中出现了<或是>字符,那该怎么办呢?我们就可以使用XML的转义字符来代替:

img

如果嫌一个一个改太麻烦,也可以使用CD来快速创建不解析区域:

<test>
    <name><![CDATA[我看你<><><>是一点都不懂哦>>>]]></name>
</test>

那么,我们现在了解了XML文件的定义,现在该如何去解析一个XML文件呢?比如我们希望将定义好的XML文件读取到Java程序中,这时该怎么做呢?

JDK为我们内置了一个叫做org.w3c的XML解析库,我们来看看如何使用它来进行XML文件内容解析:

// 创建DocumentBuilderFactory对象
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
// 创建DocumentBuilder对象
try {
    DocumentBuilder builder = factory.newDocumentBuilder();
    Document d = builder.parse("file:mappers/test.xml");
    // 每一个标签都作为一个节点
    NodeList nodeList = d.getElementsByTagName("test");  // 可能有很多个名字为test的标签
    Node rootNode = nodeList.item(0); // 获取首个

    NodeList childNodes = rootNode.getChildNodes(); // 一个节点下可能会有很多个节点,比如根节点下就囊括了所有的节点
    //节点可以是一个带有内容的标签(它内部就还有子节点),也可以是一段文本内容

    for (int i = 0; i < childNodes.getLength(); i++) {
        Node child = childNodes.item(i);
        if(child.getNodeType() == Node.ELEMENT_NODE)  //过滤换行符之类的内容,因为它们都被认为是一个文本节点
        System.out.println(child.getNodeName() + ":" +child.getFirstChild().getNodeValue());
        // 输出节点名称,也就是标签名称,以及标签内部的文本(内部的内容都是子节点,所以要获取内部的节点)
    }
} catch (Exception e) {
    e.printStackTrace();
}

当然,学习和使用XML只是为了更好地去认识Mybatis的工作原理,以及如何使用XML来作为Mybatis的配置文件,这是在开始之前必须要掌握的内容(使用Java读取XML内容不要求掌握,但是需要知道Mybatis就是通过这种方式来读取配置文件的)

不仅仅是Mybatis,包括后面的Spring等众多框架都会用到XML来作为框架的配置文件!

初次使用Mybatis

那么我们首先来感受一下Mybatis给我们带来的便捷,就从搭建环境开始,中文文档网站:https://mybatis.org/mybatis-3/zh/configuration.html

我们需要导入Mybatis的依赖,Jar包需要在github上下载,如果卡得一匹,连不上可以在视频简介处从分享的文件中获取。同样地放入到项目的根目录下,右键作为依赖即可!(依赖变多之后,我们可以将其放到一个单独的文件夹,不然会很繁杂)

依赖导入完成后,我们就可以编写Mybatis的配置文件了(现在不是在Java代码中配置了,而是通过一个XML文件去配置,这样就使得硬编码的部分大大减少,项目后期打包成Jar运行不方便修复,但是通过配置文件,我们随时都可以去修改,就变得很方便了,同时代码量也大幅度减少,配置文件填写完成后,我们只需要关心项目的业务逻辑而不是如何去读取配置文件)我们按照官方文档给定的提示,在项目根目录下新建名为mybatis-config.xml的文件,并填写以下内容:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
  PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
  "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
  <environments default="development">
    <environment id="development">
      <transactionManager type="JDBC"/>
      <dataSource type="POOLED">
        <property name="driver" value="${驱动类(含包名)}"/>
        <property name="url" value="${数据库连接URL}"/>
        <property name="username" value="${用户名}"/>
        <property name="password" value="${密码}"/>
      </dataSource>
    </environment>
  </environments>
</configuration>

我们发现,在最上方还引入了一个叫做DTD(文档类型定义)的东西,它提前帮助我们规定了一些标签,我们就需要使用Mybatis提前帮助我们规定好的标签来进行配置(因为只有这样Mybatis才能正确识别我们配置的内容)

通过进行配置,我们就告诉了Mybatis我们链接数据库的一些信息,包括URL、用户名、密码等,这样Mybatis就知道该链接哪个数据库、使用哪个账号进行登陆了(也可以不使用配置文件,这里不做讲解,还请各位小伙伴自行阅读官方文档)

配置文件完成后,我们需要在Java程序启动时,让Mybatis对配置文件进行读取并得到一个SqlSessionFactory对象:

public static void main(String[] args) throws FileNotFoundException {
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(new FileInputStream("mybatis-config.xml"));
    try (SqlSession sqlSession = sqlSessionFactory.openSession(true)){
			//暂时还没有业务
    }
}

直接运行即可,虽然没有干什么事情,但是不会出现错误,如果之前的配置文件编写错误,直接运行会产生报错!那么现在我们来看看,SqlSessionFactory对象是什么东西:

img

每个基于 MyBatis 的应用都是以一个 SqlSessionFactory 的实例为核心的,我们可以通过SqlSessionFactory来创建多个新的会话,SqlSession对象,每个会话就相当于我不同的地方登陆一个账号去访问数据库,你也可以认为这就是之前JDBC中的Statement对象,会话之间相互隔离,没有任何关联。

而通过SqlSession就可以完成几乎所有的数据库操作,我们发现这个接口中定义了大量数据库操作的方法,因此,现在我们只需要通过一个对象就能完成数据库交互了,极大简化了之前的流程。

我们来尝试一下直接读取实体类,读取实体类肯定需要一个映射规则,比如类中的哪个字段对应数据库中的哪个字段,在查询语句返回结果后,Mybatis就会自动将对应的结果填入到对象的对应字段上。首先编写实体类,,直接使用Lombok是不是就很方便了:

import lombok.Data;

@Data
public class Student {
    int sid;   //名称最好和数据库字段名称保持一致,不然可能会映射失败导致查询结果丢失
    String name;
    String sex;
}

在根目录下重新创建一个mapper文件夹,新建名为TestMapper.xml的文件作为我们的映射器,并填写以下内容:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="TestMapper">
    <select id="selectStudent" resultType="com.test.entity.Student">
        select * from student
    </select>
</mapper>

其中namespace就是命名空间,每个Mapper都是唯一的,因此需要用一个命名空间来区分,它还可以用来绑定一个接口。我们在里面写入了一个select标签,表示添加一个select操作,同时id作为操作的名称,resultType指定为我们刚刚定义的实体类,表示将数据库结果映射为Student类,然后就在标签中写入我们的查询语句即可。

编写好后,我们在配置文件中添加这个Mapper映射器:

<mappers>
    <mapper url="file:mappers/TestMapper.xml"/>
    <!--    这里用的是url,也可以使用其他类型,我们会在后面讲解    -->
</mappers>

最后在程序中使用我们定义好的Mapper即可:

public static void main(String[] args) throws FileNotFoundException {
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(new FileInputStream("mybatis-config.xml"));
    try (SqlSession sqlSession = sqlSessionFactory.openSession(true)){
        List<Student> student = sqlSession.selectList("selectStudent");
        student.forEach(System.out::println);
    }
}

我们会发现,Mybatis非常智能,我们只需要告诉一个映射关系,就能够直接将查询结果转化为一个实体类!

配置Mybatis

在了解了Mybatis为我们带来的便捷之后,现在我们就可以正式地去学习使用Mybatis了!

由于SqlSessionFactory一般只需要创建一次,因此我们可以创建一个工具类来集中创建SqlSession,这样会更加方便一些:

public class MybatisUtil {

    //在类加载时就进行创建
    private static SqlSessionFactory sqlSessionFactory;
    static {
        try {
            sqlSessionFactory = new SqlSessionFactoryBuilder().build(new FileInputStream("mybatis-config.xml"));
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
    }

    /**
     * 获取一个新的会话
     * @param autoCommit 是否开启自动提交(跟JDBC是一样的,如果不自动提交,则会变成事务操作)
     * @return SqlSession对象
     */
    public static SqlSession getSession(boolean autoCommit){
        return sqlSessionFactory.openSession(autoCommit);
    }
}

现在我们只需要在main方法中这样写即可查询结果了:

public static void main(String[] args) {
    try (SqlSession sqlSession = MybatisUtil.getSession(true)){
        List<Student> student = sqlSession.selectList("selectStudent");
        student.forEach(System.out::println);
    }
}

之前我们演示了,如何创建一个映射器来将结果快速转换为实体类,但是这样可能还是不够方便,我们每次都需要去找映射器对应操作的名称,而且还要知道对应的返回类型,再通过SqlSession来执行对应的方法,能不能再方便一点呢?

现在,我们可以通过namespace来绑定到一个接口上,利用接口的特性,我们可以直接指明方法的行为,而实际实现则是由Mybatis来完成。

public interface TestMapper {
    List<Student> selectStudent();
}

将Mapper文件的命名空间修改为我们的接口,建议同时将其放到同名包中,作为内部资源:

<mapper namespace="com.test.mapper.TestMapper">
    <select id="selectStudent" resultType="com.test.entity.Student">
        select * from student
    </select>
</mapper>

作为内部资源后,我们需要修改一下配置文件中的mapper定义,不使用url而是resource表示是Jar内部的文件:

<mappers>
    <mapper resource="com/test/mapper/TestMapper.xml"/>
</mappers>

现在我们就可以直接通过SqlSession获取对应的实现类,通过接口中定义的行为来直接获取结果:

public static void main(String[] args) {
    try (SqlSession sqlSession = MybatisUtil.getSession(true)){
        TestMapper testMapper = sqlSession.getMapper(TestMapper.class);
        List<Student> student = testMapper.selectStudent();
        student.forEach(System.out::println);
    }
}

那么肯定有人好奇,TestMapper明明是一个我们自己定义接口啊,Mybatis也不可能提前帮我们写了实现类啊,那这接口怎么就出现了一个实现类呢?我们可以通过调用getClass()方法来看看实现类是个什么:

TestMapper testMapper = sqlSession.getMapper(TestMapper.class);
System.out.println(testMapper.getClass());

我们发现,实现类名称很奇怪,名称为com.sun.proxy.$Proxy4,它是通过动态代理生成的,相当于动态生成了一个实现类,而不是预先定义好的,有关Mybatis这一部分的原理,我们放在最后一节进行讲解。

接下来,我们再来看配置文件,之前我们并没有对配置文件进行一个详细的介绍:

<configuration>
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://localhost:3306/study"/>
                <property name="username" value="test"/>
                <property name="password" value="123456"/>
            </dataSource>
        </environment>
    </environments>
    <mappers>
        <mapper resource="com/test/mapper/TestMapper.xml"/>
    </mappers>
</configuration>

首先就从environments标签说起,一般情况下,我们在开发中,都需要指定一个数据库的配置信息,包含连接URL、用户、密码等信息,而environment就是用于进行这些配置的!实际情况下可能会不止有一个数据库连接信息,比如开发过程中我们一般会使用本地的数据库,而如果需要将项目上传到服务器或是防止其他人的电脑上运行时,我们可能就需要配置另一个数据库的信息,因此,我们可以提前定义好所有的数据库信息,该什么时候用什么即可!

environments标签上有一个default属性,来指定默认的环境,当然如果我们希望使用其他环境,可以修改这个默认环境,也可以在创建工厂时选择环境:

sqlSessionFactory = new SqlSessionFactoryBuilder()
        .build(new FileInputStream("mybatis-config.xml"), "环境ID");

我们还可以给类型起一个别名,以简化Mapper的编写:

<!-- 需要在environments的上方 -->
<typeAliases>
    <typeAlias type="com.test.entity.Student" alias="Student"/>
</typeAliases>

现在Mapper就可以直接使用别名了:

<mapper namespace="com.test.mapper.TestMapper">
    <select id="selectStudent" resultType="Student">
        select * from student
    </select>
</mapper>

如果这样还是很麻烦,我们也可以直接让Mybatis去扫描一个包,并将包下的所有类自动起别名(别名为首字母小写的类名)

<typeAliases>
    <package name="com.test.entity"/>
</typeAliases>

也可以为指定实体类添加一个注解,来指定别名:

@Data
@Alias("lbwnb")
public class Student {
    private int sid;
    private String name;
    private String sex;
}

当然,Mybatis也包含许多的基础配置,通过使用:

<settings>
    <setting name="" value=""/>
</settings>

所有的配置项可以在中文文档处查询,本文不会进行详细介绍,在后面我们会提出一些比较重要的配置项。

有关配置文件的介绍就暂时到这里为止,我们讨论的重心应该是Mybatis的应用,而不是配置文件,所以省略了一部分内容的讲解。

增删改查

、、、、、

在了解了Mybatis的一些基本配置之后,我们就可以正式来使用Mybatis来进行数据库操作了!

在前面我们演示了如何快速进行查询,我们只需要编写一个对应的映射器既可以了:

<mapper namespace="com.test.mapper.TestMapper">
    <select id="studentList" resultType="Student">
        select * from student
    </select>
</mapper>

当然,如果你不喜欢使用实体类,那么这些属性还可以被映射到一个Map上:

<select id="selectStudent" resultType="Map">
    select * from student
</select>
public interface TestMapper {
    List<Map> selectStudent();
}

Map中就会以键值对的形式来存放这些结果了。

通过设定一个resultType属性,让Mybatis知道查询结果需要映射为哪个实体类,要求字段名称保持一致。那么如果我们不希望按照这样的规则来映射呢?我们可以自定义resultMap来设定映射规则:

<resultMap id="Test" type="Student">
    <result column="sid" property="sid"/>
    <result column="sex" property="name"/>
    <result column="name" property="sex"/>
</resultMap>

通过指定映射规则,我们现在名称和性别一栏就发生了交换,因为我们将其映射字段进行了交换。

如果一个类中存在多个构造方法,那么很有可能会出现这样的错误:

### Exception in thread "main" org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: org.apache.ibatis.executor.ExecutorException: No constructor found in com.test.entity.Student matching [java.lang.Integer, java.lang.String, java.lang.String]
### The error may exist in com/test/mapper/TestMapper.xml
### The error may involve com.test.mapper.TestMapper.getStudentBySid
### The error occurred while handling results
### SQL: select * from student where sid = ?
### Cause: org.apache.ibatis.executor.ExecutorException: No constructor found in com.test.entity.Student matching [java.lang.Integer, java.lang.String, java.lang.String]
	at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
	...

这时就需要使用constructor标签来指定构造方法:

<resultMap id="test" type="Student">
    <constructor>
        <arg column="sid" javaType="Integer"/>
        <arg column="name" javaType="String"/>
    </constructor>
</resultMap>

值得注意的是,指定构造方法后,若此字段被填入了构造方法作为参数,将不会通过反射给字段单独赋值,而构造方法中没有传入的字段,依然会被反射赋值,有关resultMap的内容,后面还会继续讲解。

如果数据库中存在一个带下划线的字段,我们可以通过设置让其映射为以驼峰命名的字段,比如my_test映射为myTest

<settings>
    <setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>

如果不设置,默认为不开启,也就是默认需要名称保持一致。

我们接着来看看条件查询,既然是条件查询,那么肯定需要我们传入查询条件,比如现在我们想通过sid字段来通过学号查找信息:

Student getStudentBySid(int sid);
<select id="getStudentBySid" parameterType="int" resultType="Student">
    select * from student where sid = #{sid}
</select>

我们通过使用#{xxx}或是${xxx}来填入我们给定的属性,实际上Mybatis本质也是通过PreparedStatement首先进行一次预编译,有效地防止SQL注入问题,但是如果使用${xxx}就不再是通过预编译,而是直接传值,因此我们一般都使用#{xxx}来进行操作。

使用parameterType属性来指定参数类型(非必须,可以不用,推荐不用)

接着我们来看插入、更新和删除操作,其实与查询操作差不多,不过需要使用对应的标签,比如插入操作:

<insert id="addStudent" parameterType="Student">
    insert into student(name, sex) values(#{name}, #{sex})
</insert>
int addStudent(Student student);

我们这里使用的是一个实体类,我们可以直接使用实体类里面对应属性替换到SQL语句中,只需要填写属性名称即可,和条件查询是一样的。

复杂查询

一个老师可以教授多个学生,那么能否一次性将老师的学生全部映射给此老师的对象呢,比如:

@Data
public class Teacher {
    int tid;
    String name;
    List<Student> studentList;
}

映射为Teacher对象时,同时将其教授的所有学生一并映射为List列表,显然这是一种一对多的查询,那么这时就需要进行复杂查询了。而我们之前编写的都非常简单,直接就能完成映射,因此我们现在需要使用resultMap来自定义映射规则:

<select id="getTeacherByTid" resultMap="asTeacher">
        select *, teacher.name as tname from student inner join teach on student.sid = teach.sid
                              inner join teacher on teach.tid = teacher.tid where teach.tid = #{tid}
</select>

<resultMap id="asTeacher" type="Teacher">
    <id column="tid" property="tid"/>
    <result column="tname" property="name"/>
    <collection property="studentList" ofType="Student">
        <id property="sid" column="sid"/>
        <result column="name" property="name"/>
        <result column="sex" property="sex"/>
    </collection>
</resultMap>

可以看到,我们的查询结果是一个多表联查的结果,而联查的数据就是我们需要映射的数据(比如这里是一个老师有N个学生,联查的结果也是这一个老师对应N个学生的N条记录),其中id标签用于在多条记录中辨别是否为同一个对象的数据,比如上面的查询语句得到的结果中,tid这一行始终为1,因此所有的记录都应该是tid=1的教师的数据,而不应该变为多个教师的数据,如果不加id进行约束,那么会被识别成多个教师的数据!

通过使用collection来表示将得到的所有结果合并为一个集合,比如上面的数据中每个学生都有单独的一条记录,因此tid相同的全部学生的记录就可以最后合并为一个List,得到最终的映射结果,当然,为了区分,最好也设置一个id,只不过这个例子中可以当做普通的result使用。

了解了一对多,那么多对一又该如何查询呢,比如每个学生都有一个对应的老师,现在Student新增了一个Teacher对象,那么现在又该如何去处理呢?

@Data
@Accessors(chain = true)
public class Student {
    private int sid;
    private String name;
    private String sex;
    private Teacher teacher;
}

@Data
public class Teacher {
    int tid;
    String name;
}

现在我们希望的是,每次查询到一个Student对象时都带上它的老师,同样的,我们也可以使用resultMap来实现(先修改一下老师的类定义,不然会很麻烦):

<resultMap id="test2" type="Student">
    <id column="sid" property="sid"/>
    <result column="name" property="name"/>
    <result column="sex" property="sex"/>
    <association property="teacher" javaType="Teacher">
        <id column="tid" property="tid"/>
        <result column="tname" property="name"/>
    </association>
</resultMap>
<select id="selectStudent" resultMap="test2">
    select *, teacher.name as tname from student left join teach on student.sid = teach.sid
                                                 left join teacher on teach.tid = teacher.tid
</select>

通过使用association进行关联,形成多对一的关系,实际上和一对多是同理的,都是对查询结果的一种处理方式罢了。

事务操作

我们可以在获取SqlSession关闭自动提交来开启事务模式,和JDBC其实都差不多:

public static void main(String[] args) {
    try (SqlSession sqlSession = MybatisUtil.getSession(false)){
        TestMapper testMapper = sqlSession.getMapper(TestMapper.class);

        testMapper.addStudent(new Student().setSex("男").setName("小王"));

        testMapper.selectStudent().forEach(System.out::println);
    }
}

我们发现,在关闭自动提交后,我们的内容是没有进入到数据库的,现在我们来试一下在最后提交事务:

sqlSession.commit();

在事务提交后,我们的内容才会被写入到数据库中。现在我们来试试看回滚操作:

try (SqlSession sqlSession = MybatisUtil.getSession(false)){
    TestMapper testMapper = sqlSession.getMapper(TestMapper.class);

    testMapper.addStudent(new Student().setSex("男").setName("小王"));

    testMapper.selectStudent().forEach(System.out::println);
    sqlSession.rollback();
    sqlSession.commit();
}

回滚操作也印证成功。

动态SQL

  • <if>:元素用于条件判断。只有当test属性中的表达式为真时,才会将<if>标签内的内容包含在生成的SQL语句中。

  • <choose>(<when>、<otherwise>):这些元素提供了类似于Java语言中的switch-case-default结构的功能。<choose>是父标签,里面可以有多个<when>子标签,每个<when>都代表一个条件分支;如果所有<when>条件都不满足,则会执行<otherwise>标签中的内容。

  • <where>:<where>元素用于简化SQL语句中WHERE子句的编写。它会自动处理AND或OR前缀的问题,即当<where>内部没有任何条件时,它不会生成WHERE关键字;如果有条件,它会自动移除第一个条件前面多余的AND或OR。

  • <trim> :用于处理 SQL 语句中多余的前缀或后缀,如 WHERE、SET 关键字后面的多余 AND 或 OR,以及 UPDATE 语句中可能存在的多余逗号等。

  • <foreach>:<foreach>元素主要用于遍历集合,通常与IN语句配合使用。collection属性指定要遍历的集合,item表示集合中的每个元素,open和close分别定义了遍历结果前后添加的内容,separator则定义了元素之间的分隔符。

<select id="findActiveBlog" resultType="Blog">
  SELECT * FROM Blog
  WHERE state = 'ACTIVE'
  <if test="title != null">
    AND title = #{title}
  </if>
</select>

<select id="findActiveBlogLike" resultType="Blog">
  SELECT * FROM Blog
  WHERE state = 'ACTIVE'
  <choose>
    <when test="title != null">
      AND title like #{title}
    </when>
    <when test="author != null and author.name != null">
      AND author_name like #{author.name}
    </when>
    <otherwise>
      AND featured = 1
    </otherwise>
  </choose>
</select>

<select id="findActiveBlogWithTitleOrAuthor" resultType="Blog">
  SELECT * FROM Blog
  <where>
    <if test="state != null">
      state = #{state}
    </if>
    <if test="title != null">
      AND title like #{title}
    </if>
    <if test="author != null">
      AND author like #{author}
    </if>
  </where>
</select>

<select id="findActiveBlogWithTitleOrAuthor" resultType="Blog">
  SELECT * FROM Blog
  <trim prefix="WHERE" prefixOverrides="AND |OR ">
    <if test="state != null">
      state = #{state}
    </if>
    <if test="title != null">
      AND title like #{title}
    </if>
    <if test="author != null">
      AND author like #{author}
    </if>
  </trim>
</select>

<select id="selectPostIn" resultType="domain.blog.Post">
  SELECT *
  FROM POST P
  WHERE ID in
  <foreach item="id" index="index" collection="list"
           open="(" separator="," close=")">
    #{id}
  </foreach>
</select>

在之前JDBC讲解的时候,我们就提到过批量执行语句的问题,当我们要执行很多条语句时,大家可能会一个一个地提交:

//现在要求把下面所有用户都插入到数据库中
List<String> users = List.of("小刚", "小强", "小王", "小美", "小黑子");
//使用for循环来一个一个执行insert语句
for (String user : users) {
    statement.executeUpdate("insert into user (name, age) values ('" + user + "', 18)");
}

虽然这样看似非常完美,也符合逻辑,但是实际上我们每次执行SQL语句,都像是去厨房端菜到客人桌上一样,我们每次上菜的时候只从厨房端一个菜,效率非常低,但是如果我们每次上菜推一个小推车装满N个菜一起上,效率就会提升很多,而数据库也是这样,我们每一次执行SQL语句,都需要一定的时间开销,但是如果我把这些任务合在一起告诉数据库,效率会截然不同:

可见,使用循环操作执行数据库相关操作实际上非常耗费资源,不仅带来网络上的额外开销,还有数据库的额外开销,我们更推荐大家使用批处理来优化这种情况,一次性提交一个批量操作给数据库。

需要在Mybatis中开启批处理,我们只需要在创建SqlSession时进行一些配置即可:

factory.openSession(ExecutorType.BATCH, autoCommit);

在使用openSession时直接配置ExecutorType为BATCH即可,这样SqlSession会开启批处理模式,在多次处理相同SQL时会尽可能转换为一次执行,开启批处理后,无论是否处于事务模式下,我们都需要flushStatements()来一次性提交之前是所有批处理操作:

TestMapper mapper = session.getMapper(TestMapper.class);
for (int i = 1; i <= 5; i++) {
    mapper.deleteUserById(i);
}
session.flushStatements();

此时日志中可以看到Mybatis在尽可能优化我们的SQL操作:

除了使用批处理之外,Mybatis还为我们提供了一种更好的方式来处理这种问题,我们可以使用动态SQL来一次性生成一个批量操作的SQL语句,这里先介绍一下什么是动态SQL。

动态 SQL 是 MyBatis 的强大特性之一。如果你使用过 JDBC 或其它类似的框架,你应该能理解根据不同条件拼接 SQL 语句有多痛苦,例如拼接时要确保不能忘记添加必要的空格,还要注意去掉列表最后一个列名的逗号。利用动态 SQL,可以彻底摆脱这种痛苦。

简单来说,动态SQL在执行时可以进行各种条件判断以及循环拼接等操作,极大地提升了SQL语句编写的的灵活性。

我们先来看看条件判断,在编写SQL时,我们可以添加一些用于条件判断的标签到SQL语句中,比如我们希望在根据ID查询用户时,如果查询的ID大于3,那么必须同时要满足大于18岁这个条件,这看似是一个很奇怪的查询条件,但是在我们进入到公司后确实可能会遇到这些奇葩需求,此时动态SQL就能很轻松实现这个操作:

<select id="selectUserById" resultType="User">
    select * from user where id = #{id}
    <if test="id > 3">
        and age > 18
    </if>
</select>

这里我们使用if标签表示里面的内容会在判断条件满足时拼接到后面,如果不满足,那么就不拼接里面的内容到原本的SQL中,其中test属性就是我们需要填写的判断条件,它采用OGNL表达式进行编写,语法与Java比较相似,如果各位小伙伴比较感兴趣也可以前往:OGNL - Apache Commons OGNL - Object Graph Navigation Library 详细了解。

可以看到,当我们查询条件不同时,Mybatis会选择性拼接我们的SQL语句:

除了if操作之外,Mybatis还针对多分支情况提供了choose操作,它类似于Java中的switch语句,比如现在我们希望在查询用户时,ID等于1的必须同时要满足小于18岁,ID等于2的必须满足等于18岁,其他情况的必须满足大于18岁,我们可以像这样进行编写:

<select id="selectUserById" resultType="User">
    select * from user where id = #{id}
    <choose>
        <when test="id == 1">
             and age &lt;= 18
        </when>
        <when test="id == 2">
            and age = 18
        </when>
        <otherwise>
            and age > 18
        </otherwise>
    </choose>
</select>

注意在when中不允许使用<或是>这种模糊匹配的条件。

最后我们再来介绍一下foreach操作,它与Java中的for类似,可以实现批量操作,这非常适合处理我们前面说的批量执行SQL的问题:

for (int i = 1; i <= 5; i++) {
    mapper.deleteUserById(i);
}

但是实际上这种情况完全可以简写为一个SQL语句:

DELETE FROM users WHERE id IN (1, 2, 3, 4, 5);

所以,现在我们使用foreach来完成它就很简单了:

<delete id="deleteUsers">
    delete from user where id in
    <foreach collection="list" item="item" index="index" open="(" separator="," close=")">
        #{item}
    </foreach>
</delete>

其中collection就是我们需要遍历的集合或是数组等任意可迭代对象,item和index分别代表我们在foreach标签中使用每一个元素和下标的变量名称,最后open和close用于控制起始和结束位置添加的符号,separator用于控制分隔符,现在执行以下操作:

session.delete("deleteUsers", List.of(1, 2, 3, 4, 5));

最后实际执行的SQL为:

是不是感觉有点那味了?我们再来看一个例子,比如现在我们想要批量插入一些用户到数据库里面,原本Java应该这样写,但是这是一种极其不推荐的做法:

TestMapper mapper = session.getMapper(TestMapper.class);
List<User> users = List.of(new User("小美", 17),
        new User("小张", 18),
        new User("小刘", 19));
for (User user : users) {
    mapper.insertUser(user);
}

实际上这种操作完全可以浓缩为一个SQL语句:

INSERT INTO user (name, age) VALUES ('小美', 17), ('小张', 18), ('小刘', 19);

那这时又可以直接使用咱们的动态SQL来完成操作了:

<insert id="insertAllUser">
    insert into user (name, age) values
    <foreach collection="list" item="user" separator=",">
        (#{user.name}, #{user.age})
    </foreach>
</insert>

通过使用动态SQL语句,我们基本上可以解决大部分的SQL查询和批量处理场景了。

缓存机制

MyBatis 内置了一个强大的事务性查询缓存机制,它可以非常方便地配置和定制。

其实缓存机制我们在之前学习IO流的时候已经提及过了,我们可以提前将一部分内容放入缓存,下次需要获取数据时,我们就可以直接从缓存中读取,这样的话相当于直接从内存中获取而不是再去向数据库索要数据,效率会更高。

因此Mybatis内置了一个缓存机制,我们查询时,如果缓存中存在数据,那么我们就可以直接从缓存中获取,而不是再去向数据库进行请求。

image-20230306163638882

Mybatis存在一级缓存和二级缓存,我们首先来看一下一级缓存,默认情况下,只启用了本地的会话缓存,它仅仅对一个会话中的数据进行缓存(一级缓存无法关闭,只能调整),我们来看看下面这段代码:

public static void main(String[] args) throws InterruptedException {
    try (SqlSession sqlSession = MybatisUtil.getSession(true)){
        TestMapper testMapper = sqlSession.getMapper(TestMapper.class);
        Student student1 = testMapper.getStudentBySid(1);
        Student student2 = testMapper.getStudentBySid(1);
        System.out.println(student1 == student2);
    }
}

我们发现,两次得到的是同一个Student对象,也就是说我们第二次查询并没有重新去构造对象,而是直接得到之前创建好的对象。如果还不是很明显,我们可以修改一下实体类:

@Data
@Accessors(chain = true)
public class Student {

    public Student(){
        System.out.println("我被构造了");
    }

    private int sid;
    private String name;
    private String sex;
}

我们通过前面的学习得知Mybatis在映射为对象时,在只有一个构造方法的情况下,无论你构造方法写成什么样子,都会去调用一次构造方法,如果存在多个构造方法,那么就会去找匹配的构造方法。我们可以通过查看构造方法来验证对象被创建了几次。

结果显而易见,只创建了一次,也就是说当第二次进行同样的查询时,会直接使用第一次的结果,因为第一次的结果已经被缓存了。

那么如果我修改了数据库中的内容,缓存还会生效吗:

public static void main(String[] args) throws InterruptedException {
    try (SqlSession sqlSession = MybatisUtil.getSession(true)){
        TestMapper testMapper = sqlSession.getMapper(TestMapper.class);
        Student student1 = testMapper.getStudentBySid(1);
        testMapper.addStudent(new Student().setName("小李").setSex("男"));
        Student student2 = testMapper.getStudentBySid(1);
        System.out.println(student1 == student2);
    }
}

我们发现,当我们进行了插入操作后,缓存就没有生效了,我们再次进行查询得到的是一个新创建的对象。

也就是说,一级缓存,在进行DML操作后,会使得缓存失效,也就是说Mybatis知道我们对数据库里面的数据进行了修改,所以之前缓存的内容可能就不是当前数据库里面最新的内容了。还有一种情况就是,当前会话结束后,也会清理全部的缓存,因为已经不会再用到了。但是一定注意,一级缓存只针对于单个会话,多个会话之间不相通。

public static void main(String[] args) {
    try (SqlSession sqlSession = MybatisUtil.getSession(true)){
        TestMapper testMapper = sqlSession.getMapper(TestMapper.class);

        Student student2;
        try(SqlSession sqlSession2 = MybatisUtil.getSession(true)){
            TestMapper testMapper2 = sqlSession2.getMapper(TestMapper.class);
            student2 = testMapper2.getStudentBySid(1);
        }

        Student student1 = testMapper.getStudentBySid(1);
        System.out.println(student1 == student2);
    }
}

注意: 一个会话DML操作只会重置当前会话的缓存,不会重置其他会话的缓存,也就是说,其他会话缓存是不会更新的!

一级缓存给我们提供了很高速的访问效率,但是它的作用范围实在是有限,如果一个会话结束,那么之前的缓存就全部失效了,但是我们希望缓存能够扩展到所有会话都能使用,因此我们可以通过二级缓存来实现,二级缓存默认是关闭状态,要开启二级缓存,我们需要在映射器XML文件中添加:

<cache/>

可见二级缓存是Mapper级别的,也就是说,当一个会话失效时,它的缓存依然会存在于二级缓存中,因此如果我们再次创建一个新的会话会直接使用之前的缓存,我们首先根据官方文档进行一些配置:

<cache
  eviction="FIFO"
  flushInterval="60000"
  size="512"
  readOnly="true"/>

我们来编写一个代码:

public static void main(String[] args) {
    Student student;
    try (SqlSession sqlSession = MybatisUtil.getSession(true)){
        TestMapper testMapper = sqlSession.getMapper(TestMapper.class);
        student = testMapper.getStudentBySid(1);
    }

    try (SqlSession sqlSession2 = MybatisUtil.getSession(true)){
        TestMapper testMapper2 = sqlSession2.getMapper(TestMapper.class);
        Student student2 = testMapper2.getStudentBySid(1);
        System.out.println(student2 == student);
    }
}

我们可以看到,上面的代码中首先是第一个会话在进行读操作,完成后会结束会话,而第二个操作重新创建了一个新的会话,再次执行了同样的查询,我们发现得到的依然是缓存的结果。

那么如果我不希望某个方法开启缓存呢?我们可以添加useCache属性来关闭缓存:

<select id="getStudentBySid" resultType="Student" useCache="false">
    select * from student where sid = #{sid}
</select>

我们也可以使用flushCache="false"在每次执行后都清空缓存,通过这这个我们还可以控制DML操作完成之后不清空缓存。

<select id="getStudentBySid" resultType="Student" flushCache="true">
    select * from student where sid = #{sid}
</select>

添加了二级缓存之后,会先从二级缓存中查找数据,当二级缓存中没有时,才会从一级缓存中获取,当一级缓存中都还没有数据时,才会请求数据库,因此我们再来执行上面的代码:

public static void main(String[] args) {
    try (SqlSession sqlSession = MybatisUtil.getSession(true)){
        TestMapper testMapper = sqlSession.getMapper(TestMapper.class);

        Student student2;
        try(SqlSession sqlSession2 = MybatisUtil.getSession(true)){
            TestMapper testMapper2 = sqlSession2.getMapper(TestMapper.class);
            student2 = testMapper2.getStudentBySid(1);
        }

        Student student1 = testMapper.getStudentBySid(1);
        System.out.println(student1 == student2);
    }
}

得到的结果就会是同一个对象了,因为现在是优先从二级缓存中获取。

读取顺序:二级缓存 => 一级缓存 => 数据库

image-20230306163717033

虽然缓存机制给我们提供了很大的性能提升,但是缓存存在一个问题,我们之前在计算机组成原理中可能学习过缓存一致性问题,也就是说当多个CPU在操作自己的缓存时,可能会出现各自的缓存内容不同步的问题,而Mybatis也会这样,我们来看看这个例子:

public static void main(String[] args) throws InterruptedException {
    try (SqlSession sqlSession = MybatisUtil.getSession(true)){
        TestMapper testMapper = sqlSession.getMapper(TestMapper.class);
        while (true){
            Thread.sleep(3000);
            System.out.println(testMapper.getStudentBySid(1));
        }
    }
}

我们现在循环地每三秒读取一次,而在这个过程中,我们使用IDEA手动修改数据库中的数据,将1号同学的学号改成100,那么理想情况下,下一次读取将无法获取到小明,因为小明的学号已经发生变化了。

但是结果却是依然能够读取,并且sid并没有发生改变,这也证明了Mybatis的缓存在生效,因为我们是从外部进行修改,Mybatis不知道我们修改了数据,所以依然在使用缓存中的数据,但是这样很明显是不正确的,因此,如果存在多台服务器或者是多个程序都在使用Mybatis操作同一个数据库,并且都开启了缓存,需要解决这个问题,要么就得关闭Mybatis的缓存来保证一致性:

<settings>
    <setting name="cacheEnabled" value="false"/>
</settings>
<select id="getStudentBySid" resultType="Student" useCache="false" flushCache="true">
    select * from student where sid = #{sid}
</select>

要么就需要实现缓存共用,也就是让所有的Mybatis都使用同一个缓存进行数据存取,在后面,我们会继续学习Redis、Ehcache、Memcache等缓存框架,通过使用这些工具,就能够很好地解决缓存一致性问题。

使用注解开发

在之前的开发中,我们已经体验到Mybatis为我们带来的便捷了,我们只需要编写对应的映射器,并将其绑定到一个接口上,即可直接通过该接口执行我们的SQL语句,极大的简化了我们之前JDBC那样的代码编写模式。那么,能否实现无需xml映射器配置,而是直接使用注解在接口上进行配置呢?答案是可以的,也是现在推荐的一种方式(也不是说XML就不要去用了,由于Java 注解的表达能力和灵活性十分有限,可能相对于XML配置某些功能实现起来会不太好办,但是在大部分场景下,直接使用注解开发已经绰绰有余了)

首先我们来看一下,使用XML进行映射器编写时,我们需要现在XML中定义映射规则和SQL语句,然后再将其绑定到一个接口的方法定义上,然后再使用接口来执行:

<insert id="addStudent">
    insert into student(name, sex) values(#{name}, #{sex})
</insert>
int addStudent(Student student);

而现在,我们可以直接使用注解来实现,每个操作都有一个对应的注解:

@Insert("insert into student(name, sex) values(#{name}, #{sex})")
int addStudent(Student student);

当然,我们还需要修改一下配置文件中的映射器注册:

<mappers>
    <mapper class="com.test.mapper.MyMapper"/>
    <!--  也可以直接注册整个包下的 <package name="com.test.mapper"/>  -->
</mappers>

通过直接指定Class,来让Mybatis知道我们这里有一个通过注解实现的映射器。

我们接着来看一下,如何使用注解进行自定义映射规则:

@Results({
        @Result(id = true, column = "sid", property = "sid"),
        @Result(column = "sex", property = "name"),
        @Result(column = "name", property = "sex")
})
@Select("select * from student")
List<Student> getAllStudent();

直接通过@Results注解,就可以直接进行配置了,此注解的value是一个@Result注解数组,每个@Result注解都都一个单独的字段配置,其实就是我们之前在XML映射器中写的:

<resultMap id="test" type="Student">
    <id property="sid" column="sid"/>
    <result column="name" property="sex"/>    
  	<result column="sex" property="name"/>
</resultMap>

现在我们就可以通过注解来自定义映射规则了。那么如何使用注解来完成复杂查询呢?我们还是使用一个老师多个学生的例子:

@Results({
        @Result(id = true, column = "tid", property = "tid"),
        @Result(column = "name", property = "name"),
        @Result(column = "tid", property = "studentList", many =
            @Many(select = "getStudentByTid")
        )
})
@Select("select * from teacher where tid = #{tid}")
Teacher getTeacherBySid(int tid);

@Select("select * from student inner join teach on student.sid = teach.sid where tid = #{tid}")
List<Student> getStudentByTid(int tid);

我们发现,多出了一个子查询,而这个子查询是单独查询该老师所属学生的信息,而子查询结果作为@Result注解的一个many结果,代表子查询的所有结果都归入此集合中(也就是之前的collection标签)

<resultMap id="asTeacher" type="Teacher">
    <id column="tid" property="tid"/>
    <result column="tname" property="name"/>
    <collection property="studentList" ofType="Student">
        <id property="sid" column="sid"/>
        <result column="name" property="name"/>
        <result column="sex" property="sex"/>
    </collection>
</resultMap>

同理,@Result也提供了@One子注解来实现一对一的关系表示,类似于之前的assocation标签:

@Results({
        @Result(id = true, column = "sid", property = "sid"),
        @Result(column = "sex", property = "name"),
        @Result(column = "name", property = "sex"),
        @Result(column = "sid", property = "teacher", one =
            @One(select = "getTeacherBySid")
        )
})
@Select("select * from student")
List<Student> getAllStudent();

如果现在我希望直接使用注解编写SQL语句但是我希望映射规则依然使用XML来实现,这时该怎么办呢?

@ResultMap("test")
@Select("select * from student")
List<Student> getAllStudent();

提供了@ResultMap注解,直接指定ID即可,这样我们就可以使用XML中编写的映射规则了,这里就不再演示了。

那么如果出现之前的两个构造方法的情况,且没有任何一个构造方法匹配的话,该怎么处理呢?

@Data
@Accessors(chain = true)
public class Student {

    public Student(int sid){
        System.out.println("我是一号构造方法"+sid);
    }

    public Student(int sid, String name){
        System.out.println("我是二号构造方法"+sid+name);
    }

    private int sid;
    private String name;
    private String sex;
}

我们可以通过@ConstructorArgs注解来指定构造方法:

@ConstructorArgs({
        @Arg(column = "sid", javaType = int.class),
        @Arg(column = "name", javaType = String.class)
})
@Select("select * from student where sid = #{sid} and sex = #{sex}")
Student getStudentBySidAndSex(@Param("sid") int sid, @Param("sex") String sex);

得到的结果和使用constructor标签效果一致,这里就不多做讲解了。

我们发现,当参数列表中出现两个以上的参数时,会出现错误:

@Select("select * from student where sid = #{sid} and sex = #{sex}")
Student getStudentBySidAndSex(int sid, String sex);
Exception in thread "main" org.apache.ibatis.exceptions.PersistenceException: 
### Error querying database.  Cause: org.apache.ibatis.binding.BindingException: Parameter 'sid' not found. Available parameters are [arg1, arg0, param1, param2]
### Cause: org.apache.ibatis.binding.BindingException: Parameter 'sid' not found. Available parameters are [arg1, arg0, param1, param2]
	at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:153)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:145)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:140)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectOne(DefaultSqlSession.java:76)
	at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:87)
	at org.apache.ibatis.binding.MapperProxy$PlainMethodInvoker.invoke(MapperProxy.java:145)
	at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:86)
	at com.sun.proxy.$Proxy6.getStudentBySidAndSex(Unknown Source)
	at com.test.Main.main(Main.java:16)

原因是Mybatis不明确到底哪个参数是什么,因此我们可以添加@Param来指定参数名称:

@Select("select * from student where sid = #{sid} and sex = #{sex}")
Student getStudentBySidAndSex(@Param("sid") int sid, @Param("sex") String sex);

探究: 要是我两个参数一个是基本类型一个是对象类型呢?

System.out.println(testMapper.addStudent(100, new Student().setName("小陆").setSex("男")));
@Insert("insert into student(sid, name, sex) values(#{sid}, #{name}, #{sex})")
int addStudent(@Param("sid") int sid, @Param("student")  Student student);

那么这个时候,就出现问题了,Mybatis就不能明确这些属性是从哪里来的:

### SQL: insert into student(sid, name, sex) values(?, ?, ?)
### Cause: org.apache.ibatis.binding.BindingException: Parameter 'name' not found. Available parameters are [student, param1, sid, param2]
	at org.apache.ibatis.exceptions.ExceptionFactory.wrapException(ExceptionFactory.java:30)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.update(DefaultSqlSession.java:196)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.insert(DefaultSqlSession.java:181)
	at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:62)
	at org.apache.ibatis.binding.MapperProxy$PlainMethodInvoker.invoke(MapperProxy.java:145)
	at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:86)
	at com.sun.proxy.$Proxy6.addStudent(Unknown Source)
	at com.test.Main.main(Main.java:16)

那么我们就通过参数名称.属性的方式去让Mybatis知道我们要用的是哪个属性:

@Insert("insert into student(sid, name, sex) values(#{sid}, #{student.name}, #{student.sex})")
int addStudent(@Param("sid") int sid, @Param("student")  Student student);

那么如何通过注解控制缓存机制呢?

@CacheNamespace(readWrite = false)
public interface MyMapper {

    @Select("select * from student")
    @Options(useCache = false)
    List<Student> getAllStudent();

使用@CacheNamespace注解直接定义在接口上即可,然后我们可以通过使用@Options来控制单个操作的缓存启用。

探究Mybatis的动态代理机制

在探究动态代理机制之前,我们要先聊聊什么是代理:其实顾名思义,就好比我开了个大棚,里面栽种的西瓜,那么西瓜成熟了是不是得去卖掉赚钱,而我们的西瓜非常多,一个人肯定卖不过来,肯定就要去多找几个开水果摊的帮我们卖,这就是一种代理。实际上是由水果摊老板在帮我们卖瓜,我们只告诉老板卖多少钱,而至于怎么卖的是由水果摊老板决定的。

img

那么现在我们来尝试实现一下这样的类结构,首先定义一个接口用于规范行为:

public interface Shopper {

    //卖瓜行为
    void saleWatermelon(String customer);
}

然后需要实现一下卖瓜行为,也就是我们要告诉老板卖多少钱,这里就直接写成成功出售:

public class ShopperImpl implements Shopper{

    //卖瓜行为的实现
    @Override
    public void saleWatermelon(String customer) {
        System.out.println("成功出售西瓜给 ===> "+customer);
    }
}

最后老板代理后肯定要用自己的方式去出售这些西瓜,成交之后再按照我们告诉老板的价格进行出售:

public class ShopperProxy implements Shopper{

    private final Shopper impl;

    public ShopperProxy(Shopper impl){
        this.impl = impl;
    }

    //代理卖瓜行为
    @Override
    public void saleWatermelon(String customer) {
        //首先进行 代理商讨价还价行为
        System.out.println(customer + ":哥们,这瓜多少钱一斤啊?");
        System.out.println("老板:两块钱一斤。");
        System.out.println(customer + ":你这瓜皮子是金子做的,还是瓜粒子是金子做的?");
        System.out.println("老板:你瞅瞅现在哪有瓜啊,这都是大棚的瓜,你嫌贵我还嫌贵呢。");
        System.out.println(customer + ":给我挑一个。");

        impl.saleWatermelon(customer);   //讨价还价成功,进行我们告诉代理商的卖瓜行为
    }
}

现在我们来试试看:

public class Main {
    public static void main(String[] args) {
        Shopper shopper = new ShopperProxy(new ShopperImpl());
        shopper.saleWatermelon("小强");
    }
}

这样的操作称为静态代理,也就是说我们需要提前知道接口的定义并进行实现才可以完成代理,而Mybatis这样的是无法预知代理接口的,我们就需要用到动态代理。

JDK提供的反射框架就为我们很好地解决了动态代理的问题,在这里相当于对JavaSE阶段反射的内容进行一个补充。

public class ShopperProxy implements InvocationHandler {

    Object target;
    public ShopperProxy(Object target){
        this.target = target;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String customer = (String) args[0];
        System.out.println(customer + ":哥们,这瓜多少钱一斤啊?");
        System.out.println("老板:两块钱一斤。");
        System.out.println(customer + ":你这瓜皮子是金子做的,还是瓜粒子是金子做的?");
        System.out.println("老板:你瞅瞅现在哪有瓜啊,这都是大棚的瓜,你嫌贵我还嫌贵呢。");
        System.out.println(customer + ":行,给我挑一个。");
        return method.invoke(target, args);
    }
}

通过实现InvocationHandler来成为一个动态代理,我们发现它提供了一个invoke方法,用于调用被代理对象的方法并完成我们的代理工作。现在就可以通过Proxy.newProxyInstance来生成一个动态代理类:

public static void main(String[] args) {
    Shopper impl = new ShopperImpl();
    Shopper shopper = (Shopper) Proxy.newProxyInstance(impl.getClass().getClassLoader(),
            impl.getClass().getInterfaces(), new ShopperProxy(impl));
    shopper.saleWatermelon("小强");
  	System.out.println(shopper.getClass());
}

通过打印类型我们发现,就是我们之前看到的那种奇怪的类:class com.sun.proxy.$Proxy0,因此Mybatis其实也是这样的来实现的(肯定有人问了:Mybatis是直接代理接口啊,你这个不还是要把接口实现了吗?)那我们来改改,现在我们不代理任何类了,直接做接口实现:

public class ShopperProxy implements InvocationHandler {

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        String customer = (String) args[0];
        System.out.println(customer + ":哥们,这瓜多少钱一斤啊?");
        System.out.println("老板:两块钱一斤。");
        System.out.println(customer + ":你这瓜皮子是金子做的,还是瓜粒子是金子做的?");
        System.out.println("老板:你瞅瞅现在哪有瓜啊,这都是大棚的瓜,你嫌贵我还嫌贵呢。");
        System.out.println(customer + ":行,给我挑一个。");
        return null;
    }
}
public static void main(String[] args) {
    Shopper shopper = (Shopper) Proxy.newProxyInstance(Shopper.class.getClassLoader(),
            new Class[]{ Shopper.class },   //因为本身就是接口,所以直接用就行
            new ShopperProxy());
    shopper.saleWatermelon("小强");
    System.out.println(shopper.getClass());
}

3.3 MybatisPlus

MyBatis 最佳搭档,只做增强不做改变,为简化开发、提高效率而生。更适合单表查询!

  1. 导入依赖代替Mybatis

  2. 定义Mapper接口并且继承BaseMapper

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
    <version>3.5.9</version>
</dependency>

快速入门

常见注释

@TableName:用于指定实体类映射到数据库中的表名。

@TableId:用于标识主键字段,并且可以指定主键生成策略,默认id字段作为主键。type 属性支持多种类型IdType.,如 AUTO(数据库 ID 自增)、INPUT(手动输入)、ID_WORKER(全局唯一 ID)、UUID(32位字符串)、ASSIGN_ID等。

@TableField:用于指定实体类属性与数据库字段的对应关系。可以设置 exist=false 表示该字段在数据库中不存在,常用于逻辑删除或乐观锁等场景。成员变量为is开头类型为boolean和数据库关键字冲突要使用!

注意:MyBatis-Plus 默认支持驼峰命名转换,即 Java 属性的驼峰命名格式会自动转换为数据库字段的下划线命名格式。例如,Java 中的 userName 对应数据库中的 user_name

常见配置
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/数据库?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=UTC
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
  global-config:
    db-config:
      id-type: auto # 主键生成策略
      logic-delete-value: 1 # 逻辑已删除值
      logic-not-delete-value: 0 # 逻辑未删除值
  configuration:
    map-underscore-to-camel-case: true # 开启驼峰命名转换
    cache-enabled: false # 关闭二级缓存
  mapper-locations: classpath:mapper/*.xml # 映射文件位置
  type-aliases-package: com.example.yourproject.entity # 实体类包路径
条件构造器
  1. QueryWrapper: 用于构建查询条件,可以用于 selectcountexists 等查询操作。

  2. UpdateWrapper: 用于构建更新条件,可以用于 update 操作。

  3. LambdaQueryWrapper和LambdaUpdateWrapper: Lambda 表达式可以更简洁地编写条件表达式。推荐!!

public List<User> getUsersByConditions(String name, Integer age, Boolean isAdmin) {
    QueryWrapper<User> queryWrapper = new QueryWrapper<>();
    queryWrapper.select("id", "name", "age")
    		   .eq("name", name) //等于
                .ge("age", age) // 大于等于
                .eq("is_admin", isAdmin)
                .orderByDesc("create_time"); //降序
    return userMapper.selectList(queryWrapper);
}

public boolean updateUserEmailAndStatus(Long id, String email, Integer status) {
    UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
    updateWrapper.eq("id", id)
                 .set("email", email) //修改列的值
                 .set("status", status);
    return userMapper.update(null, updateWrapper) > 0;
}

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public List<User> getUsersByName(String name) {
        LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.eq(User::getName, name);
        return userMapper.selectList(lambdaQueryWrapper);
    }
}

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public boolean updateUserStatus(Long id, Integer status) {
        LambdaUpdateWrapper<User> lambdaUpdateWrapper = new LambdaUpdateWrapper<>();
        lambdaUpdateWrapper.eq(User::getId, id)
                          .set(User::getStatus, status);
        return userMapper.update(null, lambdaUpdateWrapper) > 0;
    }
}
自定义SQL

通过Wrapper半自动生成SQL,适合复杂业务!

  1. 通过Wrapper构建where条件

  2. 在mapper中添加说明wrapper变量名称为ew

  3. 自定义SQL,并且通过传递 ew 参数来接收 Wrapper 对象

Service接口
public interface UserSerice extends IService<User>{
    ...
}
public clas UserServiceImapl extends ServiceImpl<UserMapper, User> implements IUserSerice{
    ...
}

3.4 SpringCloud

3.5 RabbitMQ

是实现了高级消息队列协议(AMQP)的开源消息代理软件(亦称面向消息的中间件)。RabbitMQ服务器是用Erlang语言编写的,而集群和故障转移是构建在开放电信平台框架上的。所有主要的编程语言均有与代理接口通讯的客户端库。

  • 可伸缩性:集群服务(微服务)

  • 消息持久化:从内存持久化消息到硬盘,再从硬盘加载到内存

3.6 SpingSecurity+OAuth2

Spring Security是一个强大的和高度可定制的身份验证和访问控制框架。Spring Security :: Spring Security

  • 身份验证

  • 授权

  • 防御攻击

OAuth2是一种授权框架,它允许第三方服务在不暴露用户凭证的情况下获取有限的访问权限。

<!-- 导入依赖后,默认生成http://localhost:8080/login,账号user密码在控制台 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
</dependency>

底层原理

Spring Security默认会帮我们:

  1. 启用HTTP Basic认证:默认情况下,Spring Security会启用HTTP Basic认证方式。这意味着任何未经过身份验证的请求都会收到一个401 Unauthorized响应,并提示客户端进行身份验证。

  2. 启用CSRF防护:默认情况下,Spring Security会启用跨站请求伪造(CSRF)防护,这对于Web表单提交尤其重要。这意味着每个POST请求都需要包含一个有效的CSRF令牌。

  3. 会话管理:Spring Security会管理用户的会话,确保用户在登录之后的状态得到保持,直到他们显式地登出或会话过期。

  4. 默认安全规则:默认情况下,所有来自HTTP GET请求的静态资源(如CSS、JavaScript、图片文件等)都是公开可访问的,而所有的其他请求(特别是POST请求)则需要经过身份验证。

  5. 自动生成登录页面:如果应用程序没有提供自定义的登录页面,Spring Security会自动生成一个简单的HTML登录页面,该页面允许用户输入用户名和密码。

  6. 密码编码器:默认情况下,Spring Security使用BCryptPasswordEncoder来对密码进行编码,这是一种强大的单向哈希函数,用于安全地存储密码。

  7. 记住我功能:虽然默认配置中没有开启“记住我”功能,但你可以轻松地通过配置启用它,以便用户可以选择在一定时间内免于再次登录。

  8. 异常处理:Spring Security提供了一套异常处理机制,可以妥善处理与安全相关的异常情况,如未认证的访问尝试、权限不足等

四 快速项目搭建若依

RuoYi 是一个 Java EE 企业级快速开发平台,基于经典技术组合(Spring Boot、Apache Shiro、MyBatis、Thymeleaf、Bootstrap),内置模块如:部门管理、角色用户、菜单及按钮授权、数据权限、系统参数、日志管理、通知公告等。在线定时任务配置;支持集群,支持多数据源,支持分布式事务。

官方网站:RuoYi 若依官方网站 |后台管理系统|权限管理系统|快速开发框架|企业管理系统|开源框架|微服务框架|前后端分离框架|开源后台系统|RuoYi|RuoYi-Vue|RuoYi-Cloud|RuoYi框架|RuoYi开源|RuoYi视频|若依视频|RuoYi开发文档|若依开发文档|Java开源框架|Java|SpringBoot|SrpingBoot2.0|SrpingCloud|Alibaba|MyBatis|Shiro|OAuth2.0|Thymeleaf|BootStrap|Vue|Element-UI||www.ruoyi.vip

4.1 RuoYi-Vue前后端分离版本

基于SpringBoot、Spring Security、Jwt、Vue的前后端分离的后台管理系统。

RuoYi-Vue 是一个 Java EE 企业级快速开发平台,基于经典技术组合(Spring Boot、Spring Security、MyBatis、Jwt、Vue),内置模块如:部门管理、角色用户、菜单及按钮授权、数据权限、系统参数、日志管理、代码生成等。在线定时任务配置;支持集群,支持多数据源,支持分布式事务。

  • JDK >= 1.8

  • MySQL >= 5.7

  • Maven >= 3.0

  • Node >= 12

  • Redis >= 3

搭建后端项目

搭建前端项目

  • Git克隆并初始化项目:

  • 安装依赖

  • 运行前端项目

# 克隆项目
git clone https://github.com/yangzongzhuan/RuoYi-Vue3

# 安装依赖
npm install

# 建议不要直接使用 cnpm 安装依赖,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题
npm install --registry=https://registry.npmmirror.com

# 启动服务
npm run dev

# 构建测试环境
npm run build:stage

# 构建生产环境
npm run build:prod

生成器

  • 准备SQL并导入数据库

  • 配置代码生成信息

  • 下载代码并导入项目

  • 升级改造

项目结构

  • ruoyi-admin(后台服务)

  • ruoyi-common(通用工具)

  • ruoui-framework(框架核心)

  • ruoui-generator(代码生成)

  • ruoyi-quartz(定时任务)

  • ruoyi-system(系统模块)

二次开发

  1. 若依架构修改器:RuoYi-MT 发行版 - Gitee.com

  2. 新建项目名-merchant子模块

    • 新建子模块

    • 父工程版本锁定

    • 项目名-admin添加依赖

  3. 代码生成

    • 创建目录菜单

    • 添加数据字典

    • 配置代码生成信息

    • 下载代码并导入项目

五 运维

5.1 Linux

创始人:林纳斯.托瓦兹,1991年大学期间创建

Linux内核:The Linux Kernel Archives

Ubuntu:oldubuntu-releases-releases-20.04.3安装包下载_开源镜像站-阿里云

VMware WorkStation Pro16:Desktop Hypervisor Solutions | VMware

SSH登陆:https://www.netsarang.com/zh/free-for-home-school/ sudo apt install openssh-server #输入后还需要你输入当前用户的密码才可以执行,至于为什么我们后面会说

点击查看图片来源

目录结构

我们一般习惯将软件装到D盘,文件数据存在E盘,系统和一些环境安装在C盘,根据不同的盘符进行划分,并且每个盘都有各自的存储容量大小。而在Linux中,没有这个概念,所有的文件都是位于根目录下的:

注意:Linux目录路径层次关系用:/,开头用:/,例如:/usr/local/hello.txt

img

我们可以看到根目录下有很多个文件夹,它们都有着各自的划分:

  • /bin 可执行二进制文件的目录,如常用的命令 ls、tar、mv、cat 等实际上都是一些小的应用程序

  • /home 普通用户的主目录,对应Windows下的C:/Users/用户名/

  • /root root用户的主目录(root用户是具有最高权限的用户,之后会讲)

  • /boot 内核文件的引导目录, 放置 linux 系统启动时用到的一些文件

  • /sbing 超级用户使用的指令文件

  • /tmp 临时文件目录,一般用户或正在执行的程序临时存放文件的目录,任何人都可以访问,重要数据不可放置在此目录下。

  • /dev 设备文件目录,在Linux中万物皆文件,实际上你插入的U盘等设备都会在dev目录下生成一个文件,我们可以很方便地通过文件IO方式去操作外设,对嵌入式开发极为友好。

  • /lib 共享库,系统使用的函数库的目录,程序在执行过程中,需要调用一些额外的参数时需要函数库的协助。

  • /usr 第三方 程序目录

  • /etc 配置程序目录,系统配置文件存放的目录

  • /var 可变文件,放置系统执行过程中经常变化的文件

  • /opt 用户使用目录,给主机额外安装软件所摆放的目录。

我们可以直接输入命令来查看目录下的所有文件:

#只显示文件名称,且不显示隐藏文件,默认在:/home/用户名
ls [-a -l -h] [Linux路径] #-a所有文件,-l列表(竖向排列)的形式展示,-h易于阅读列出文件大小K、M、G
#显示隐藏文件以及文件详细信息
ll

那么我们如何才能像Windows那样方便的管理Linux中的文件呢?我们可以使用FTP管理工具,默认情况下Ubuntu是安装了SFTP服务器的。

也可以使用Xftp来进行管理,官网:https://www.netsarang.com/zh/free-for-home-school/

命令

注:liunx下每一个目录都有权限(红叉表示不可访问);权限机制(root为超级管理员)

CTRL + ALT + T打开新的终端

常用命令
命令对应英文含义
lslist查看当前文件夹下的内容
pwdprint wrok directory查看当前所在文件夹
cd [+目录名]change directory切换文件夹
touch [+文件名]touch如果文件不存在,在当前目录下新建一个文件
vim [+文件名]Vi IMproved以vim方式打开文件
cat [+文件名]concatenate查看文件内容
mkdir [+目录名]make directory在当前目录下创建一个文件夹(创建目录)
rm [+文件名]remove删除制指的文件名
clearclear清屏
sudo
sudo su - //切换到root用户
sudo useradd study //创建用户study
sudo userdel study //删除用户
exit //退出

sudo dpkg -i XXX.deb  #软件安装(使用包全名)

sudo reboot(重启电脑)
 
sudo apt --purge autoremove
 
sudo apt install update-manager-core
 
sudo do-release-upgrade

sudo apt update #软件更新
sudo apt upgrade

sudo do-release-upgrade -d
目录切换 cd

命令: cd 目录 注意:Linux 所有的 目录文件名 都是大小写敏感的

相对路径 在输入路径时,最前面不是 / 或者 ~,表示相对 当前目录 所在的目录位置 绝对路径 在输入路径时,最前面是 / 或者 ~,表示从 根目录/home目录 开始的具体目录位置

命令含义
d /切换到根目录
cd切换到当前用户的主目录(/home/用户目录)
cd ~切换到当前用户的主目录(/home/用户目录)
cd .保持在当前目录不变
cd ../切换到上一级目录或cd ..
cd /usr切换到根目录下的usr目录
cd -切换到上次访问的目录
pwd查看当前工作目录
目录查看 ls [-al]:(list)

命令:ls [-al] 注:以 . 开头的文件为隐藏文件,需要用 -a 参数才能显示

命令含义
ls查看当前目录下的所有目录和文件
ls -a查看当前目录下的所有目录和文件(包括隐藏的文件)
ls -l 或 ll列表查看当前目录下的所有目录和文件(以列表形式显示文件的详细信息)
ls -h -l配合 -l 以人性化的方式显示文件大小
ls /dir查看指定目录下的所有目录和文件 如:ls /usr
ls -R递归显示当前目录及子目录下的文件名
代表用户主目录(home/用户目录)
.代表当前目录
..代表上一级目录

ls 通配符的使用

通配符含义
*代表任意个数个字符
?代表任意一个字符,至少 1 个
[]表示可以匹配字符组中的任一一个
[abc]匹配 a、b、c 中的任意一个
[a-f]匹配从 a 到 f 范围内的的任意一个字符
创建目录mkdir

命令:mkdir 目录

命令含义
mkdir aaa在当前目录下创建一个名为aaa的目录
mkdir /usr/aaa在指定目录下创建一个名为aaa的目录
mkdir -p aa/bb/c使用-p参数,可以递归创建目录(将路径的层次目录全部创建)
删除目录和文件rm

命令:rm [-rf] 目录 注意:使用 rm 命令要小心,因为文件删除后不能恢复

选项含义语法:
-f强制删除,忽略不存在的文件,无需提示$ rm -f 文件位置/文件名
-r递归地删除目录下的内容,删除文件夹时必须加此参数$ rm -r 文件位置/文件名

常用命令:

注意:rm不仅可以删除目录,也可以删除其他文件或压缩包,为了方便大家的记忆,无论删除任何目录或文件,都直接使用 rm -rf 目录/文件/压缩包 r:递归删除 f:强制删除

命令含义
rm 文件删除当前目录下的文件
rm -f 文件强制删除,删除当前目录的的文件(无提示信息)
rmdir只能删除空目录
rm -r aaa递归删除当前目录下的aaa目录
rm -rf aaa递归删除当前目录下的aaa目录(无提示信息)
rm -rf *将当前目录下的所有目录和文件全部删除(无提示信息)
rm -rf /*【自杀命令!慎用!慎用!慎用!】 将根目录下的所有文件全部删除
rm -ri *为了避免删除发生错误,使用如下命令来询问
修改目录mvcp
命令对应英文作用
tree [+目录名]tree以树状图列出文件目录结构
cp 源文件 目标文件copy复制文件或者目录
mv 源文件 目标文件move移动文件或者目录/文件或者目录重命名

tree

tree 命令可以以树状图列出文件目录结构

选项含义语法:
-d只显示目录$ tree -d

mv

mv 命令可以用来 移动 文件目录,也可以给 文件或目录重命名

选项含义语法:
-i覆盖文件前提示文件:$ mv -i ~/Documents/read.txt ~/sql/
-i覆盖文件前提示,也可以剪切目录:$ mv -i ~/Documents/b ~/sql/
-i覆盖文件前提示,也可以对各种文件,压缩包等进行重命名的操作重命名:$ mv -i 原文件(夹)名 新文件(夹)名
拷贝目录cp

命令:cp -r 目录名称 //目录拷贝的目标位置 -r代表递归

示例:将/usr/tmp目录下的aaa目录复制到 /usr目录下面 cp /usr/tmp/aaa/usr

注意:cp命令不仅可以拷贝目录还可以拷贝文件,压缩包等,拷贝文件和压缩包时不用写-r递归 cp -rf /usr/tmp/aaa/* /usr/tmp/aaa/usr

选项含义语法:
-i覆盖文件前提示$ cp -i ~/sql/123.txt ~/Documents/read.txt
-r若给出的源文件是目录文件,则 cp 将递归复制该目录下的所有子目录和文件,目标文件必须为一个目录名$ cp -r ~/sql/a ~/Documents/b
搜索目录find

命令:find 目录 参数 文件名称

示例:find /usr/tmp -name 'a*' 查找/usr/tmp目录下的所有以a开头的目录或文件

  • "." 表示从当前目录开始递归查找

  • “ -name '*.exe' "根据名称来查找,要查找所有以.exe结尾的文件夹或者文件

  • " -type f "查找的类型为文件

  • "-print" 输出查找的文件目录名

  • -size 145800c 指定文件的大小

  • -exec rm -rf {} \; 递归删除(前面查询出来的结果)

  • -user:查找指定用户名的文件

  • -group:查找知道用户组的文件

创建文件touch

命令:touch 文件名 创建文件或修改文件时间 如果文件 不存在,可以创建一个空白文件 如果文件 已经存在,可以修改文件的末次修改日期 示例:在当前目录创建一个名为aa.txt的文件 touch aa.txt

修改文件vivim

vim基本使用:

命令含义
vim [+文件名]以vim方式打开文件
i插入模式(Insert Mode)
Esc进入命令模式 (Command Mode)
:q退出
:wq保存并退出
:q!强行退出
cat [+文件名]查看文本内容
查看文件catmoregrep

cat 命令可以用来 查看文件内容、创建文件、文件合并、追加文件内容 等功能 cat 会一次显示所有的内容,适合 查看内容较少 的文本文件

more/less 命令可以用于分屏显示文件内容,每次只显示一页内容 适合于 查看内容较多的文本文件

head/tail命令可以查看文件前/后十行

Linux 系统中 grep 命令是一种强大的文本搜索工具 grep允许对文本文件进行 模式查找,所谓模式查找,又被称为正则表达式,后续会详细讲解

查看文件内容

命令对应英文作用
cat 文件名concatenate查看文件内容、创建文件、文件合并、追加文件内容等功能
more 文件名more分屏显示文件内容
grep 搜索文本 文件名grep搜索文本文件内容

cat参数:

选项含义语法:
-b对非空输出行编号,nl命令等价$cat -b ~/sql/123.txt
-n对输出的所有行编号$cat -n ~/sql/123.txt

more参数:

操作键功能
空格键显示手册页的下一屏
Enter 键一次滚动手册页的一行
b回滚一屏
f前滚一屏
q退出
/word搜索 word 字符串

grep参数:

有空格记得加单/双引号"人生短短 几何愁"

选项含义语法:
-c统计匹配行的数量$ grep -c 人生 ~/sql/123.txt
-n显示匹配行及行号$ grep -n 人生 ~/sql/123.txt
-v显示不包含匹配文本的所有行(相当于求反)$ grep -v 人生 ~/sql/123.txt
-i忽略大小写$ grep -i 人生 ~/sql/123.txt

常用的两种模式查找

参数含义
^a行首,搜寻以 a 开头的行
ke$行尾,搜寻以 ke 结束的行
echo 文字内容、重定向(>)和管道(|)

echo 会在终端中显示参数指定的文字,通常会和 重定向 联合使用 -n:不换行显示,-e解析转义字符

重定向>、>>

  • Linux 允许将命令执行结果 重定向到一个 文件

  • 将本应显示在终端上的内容 输出/追加指定文件

  • 例如:ls 2.txt 2> error.txt #错误信息重定向

管道 |

  • Linux 允许将 一个命令的输出 可以通过管道 做为 另一个命令的输入

  • 可以理解现实生活中的管子,管子的一头塞东西进去,另一头取出来,这里 | 的左右分为两端, 左端塞东西(写),右端取东西(读)

  • wc命令统计行数、字数及字节数(-l行数、-w字数、-c字节数)

  • sort命令排序(默认正序,-r逆序)

选项含义语法:
>表示输出,如果文件不存在,则创建并写入新内容,如果文件存在,会覆盖文件原有的内容含义$ echo hello qzp > ~/sql/a/a.txt
>>表示追加,如果文件不存在,则创建并写入追加的新内容,如果文件存在,会将内容追加到已有文件的末尾$ echo hello world >> ~/sql/a/a.txt
创建文件链接ln

ln [-s创建符号链接(类似于快捷方式)] 源文件或目录 目标文件或目录

打包/解包tar

tar 是 Linux 中最常用的 备份工具,此命令可以 把一系列文件 打包到 一个大文件中,也可以把一个 打包的大文件恢复成一系列文件

# 打包文件
tar -cvf 打包文件.tar 被打包的文件/路径...
# 解包文件
tar -xvf 打包文件.tar
# 压缩文件
tar -zcvf 打包文件.tar.gz 被压缩的文件/路径...
# 解压缩文件
tar -zxvf 打包文件.tar.gz
# 解压缩到指定路径,注意是大写C
tar -zxvf 打包文件.tar.gz -C 目标路径

压缩/解压缩gzip/gunzip
  • tar 与 gzip 命令结合可以使用实现文件 打包和压缩

  • tar 只负责打包文件,但不压缩

  • 用 gzip 压缩 tar 打包后的文件,其扩展名一般用 xxx.tar.gz

查阅命令帮助信息helpman
  • 显示 command 命令的帮助信息

  • 查阅 command 命令的使用手册

  • man 是 manual 的缩写,是 Linux 提供的一个 手册,包含了绝大部分的命令、函数的详细使用

系统命令unamewhereisdatepsssstat

uname 命令

用于打印系统内核的一些基本信息。它有多个选项来获取不同类型的系统信息。常见的选项包括:

-a: 显示所有信息。 -s: 显示系统名称。 -n: 显示节点名(通常是主机名)。 -r: 显示内核版本号。 -v: 显示内核发布版本。 -m: 显示机器硬件名称。 -p: 显示处理器类型。 -i: 显示硬件平台。

whereis 命令

用来查找二进制文件、源代码文件以及帮助文件的位置。它主要用来定位命令或程序的位置。使用方法是提供一个程序名作为参数。

-b:只查找二进制文件 -m:只查找说明/帮助文件

例如:whereis -b ls

date 命令

用来显示或设置系统的日期和时间。如果不带任何参数,默认会显示当前的日期和时间。

ps (process status) 命令

用来查看当前活动进程的状态。它有许多选项来过滤和展示不同的进程信息。

  • -A 或者 --all 显示所有进程的信息。

  • -u 或者 --user 显示属于当前用户的所有进程。

  • -e 显示所有进程(与-A相同)。

  • -f 或者 --forest 显示完整的树形结构(包括父进程ID)。

常用组合如 -aux 可以显示所有用户的活跃进程。 例如:ps aux | head -n 2

ss (socket statistics) 命令

用来显示网络连接状态,类似于 netstat 命令,但是 ss 提供了更多的信息并且更加高效。

  • -t 或者 --tcp 显示TCP连接。

  • -u 或者 --udp 显示UDP连接。

  • -l 或者 --listening 显示监听端口的连接。

  • -a 或者 --all 显示所有连接。

例如:ss -tuln

stat 命令

用来显示文件或者文件系统的状态信息。它可以显示文件的元数据,比如文件大小、权限、所有者、修改时间等。

例如:stat file.txt

文本编辑器

namo

nano 是一个简单易用的文本编辑器,专为命令行设计。它不需要额外的学习就可以使用,对于初学者来说是一个很好的选择。nano 的特点包括:

  • 易用性nano 拥有一个简单的图形用户界面(GUI),在启动时显示快捷键列表,使得新手也能很快上手。

  • 语法高亮:根据文件扩展名自动进行语法高亮。

  • 多语言支持:内置对多种语言的支持。

  • 自动换行:当一行文字超过屏幕宽度时自动换行。

  • 文件备份:在编辑过程中创建备份文件。

vi/vim

vi(Visual editor)是一个功能强大的文本编辑器,在许多系统中默认安装。vim(Vi IMproved)则是 vi 的改进版本,增加了更多特性,如语法高亮、图形界面支持等。vi 的特点包括:

  • 模式切换:

    vi有三种主要的工作模式:命令模式、插入模式和底线命令模式。通过键入特定的字符可以在这些模式之间切换。

    • 命令模式:默认模式,可以使用键盘上的字母、数字以及一些特殊字符来进行编辑操作,如删除、复制、粘贴等。

    • 插入模式:允许用户输入文本。可以通过按 Esc 键回到命令模式。

    • 底线命令模式:从命令模式下输入 : 进入,可以执行保存、退出等操作。

  • 可扩展性:通过插件可以扩展功能,如语法高亮、自动完成等。

  • 高度定制:用户可以根据自己的需求配置 .vimrc 文件来自定义编辑器的行为。

  1. 进入插入模式:i(在光标当前位置开始插入),a(在光标当前位置后开始插入),o(在当前行下一行开始插入)。

  2. 保存并退出:在命令模式下输入 :wq (如果文件已更改),或者 :q! (放弃更改并退出)。

  3. 删除一行:在命令模式下输入 dd。

  4. 复制一行:在命令模式下输入 yy。

  5. 粘贴一行:在命令模式下移动到目标位置后输入 p。

命令模式下的光标移动命令:

  • ^ 直接调到本行最前面

  • $ 直接跳到本行最后面

  • gg 直接跳到第一行

  • [N]G 跳转到第N行

  • [N]方向键 向一个方向跳转N个字符

在末行模式下,常用的复杂命令有:

  • :set number 开启行号

  • :w 保存

  • :wq或:x 保存并关闭

  • :q 关闭

  • :q! 强制关闭

搜索与替换操作
命令作用
/word从当前光标位置开始,向下搜索名为word的字符串
?word从当前光标位置开始,向上搜索名为word的字符串
n重复前一个搜索的动作(如果默认是向下搜索的则继续向下搜索)
N反向重复前一个搜索的动作(如果默认是向下搜索的则继续向上搜)
:%s/A/B/g搜索全文,把符合A的内容全部替换为B,“/”为分隔符(@、#亦可)
:%s/A/B搜索全文,把每行第一个符合A的内容替换为B,每行后面的不改变
:n1,n2s/A/B/g表示在n1和n2行间搜索,把符合A的内容全部替换为B

用户命令

useradd

useradd [参数选项] 用户名

在创建用户账户时,默认采用/home/“创建的用户名”作为主目录,/bin/bash作为登录解释器

参数作用
-d指定账户主目录(默认为/home/“创建的用户名”)
-e指定账户过期的日期,格式为MM/DD/YY或YYYY-MM-DD
-g指定账户的主组群(必须是已经存在的)
-G指定账户所属的附属组,各组用逗号分隔(必须是已经存在的)
-M/-m不创建主目录/要创建主目录
-N不创建和用户账户同名的私有用户组
-s指定用户账户登录时使用的Shell解释器
-u指定用户账户的UID
passwd

此命令来设置用户登录密码,该命令操作语法的格式为:passwd [参数选项] [用户账户名]若指定了帐户名称,则设置指定账户的登录密码,原密码自动被覆盖。只有root用户才有权设置指定账户的密码,一般用户只能设置或修改自己账户的密码(不带参数)

参数作用
-l锁定用户,禁止其登录
-u解除锁定,允许用户登录
-S显示用户的密码是否被锁定
-d清空帐户密码,账户不能登录系统
usermod

此命令用来修改用户的属性,该命令操作语法的格式为:usermod [参数选项] 用户账户名系统管理员可以直接用文本编辑器修改/etc/passwd中的用户信息,也可以用usermod命令修改已存在的用户信息,如用户的UID、主组群/附属组群、默认shell等。

参数作用
-md参数-m与-d连用,可重新指定用户的主目录并自动把旧的数据转移过去
-e修改账户过期的日期,格式为MM/DD/YY或YYYY-MM-DD
-g修改账户的主组群(必须已经存在的)
-G修改账户所属的附属组,各组用逗号分隔(必须已经存在的)
-l(小写字母L)修改用户账号名称(usermod -l 新账户名 旧账户名)
-L(大写字母L)锁定用户禁止其登录系统
-s修改用户账户登录时使用的Shell解释器
-u修改用户账户的UID
-U解锁用户,允许其登录系统
userdel

此命令用来删除用户,该命令操作语法的格式为:userdel [-r] 用户账户名

参数作用
-f强制删除用户
-r用户主目录中的文件将随用户主目录和用户邮箱一起删除
groupadd

用户组的管理工作主要涉及组的添加、修改和删除。组的添加、修改和删除实则是对/etc/group文件的更新。

此命令用来添加新的用户组,该命令操作语法的格式为:groupadd [参数选项] 组名

参数作用
-g指定组的GID
gpasswd

此命令可以用来管理组账户,可将已存在的用户添加到另一用户组中,也可对用户执行删除账户或密码、指定用户管理员等操作。该命令操作语法的格式为:

gpasswd [参数选项] 组名

注:root可以设置多个普通用户作为群组的管理员,但也只能做“将用户加入群组”和“将用户移出群组”的操作。使用“usermod -G group_name user_name”这个命令可以添加一个用户到指定的组,但会清空以前添加的组。因此使用gpasswd添加一个用户到一个组,同时保留以前添加的组。

参数作用
-a user向组中添加用户user
-d user从组中移除用户user
-r移除组的密码
-A user001,…将群组的控制权交给user001,…等用户管理,设置 user1,... 等用户为群组的管理员,仅root用户可用此命令。
groupmod

此命令用来修改已存在的用户组信息,如GID、组名等,该命令操作语法的格式为:

groupmod [参数选项] 组名

参数作用
-g指定组的新GID
-n修改组名
groupdel

此命令用来删除用户组(必须组中无用户才能删除),该命令操作语法的格式为:

groupdel [参数选项] 组名

文件基本权限

这里的权限主要指文件对于用户的权限,也就是用户能对文件所作操作的控制。

  1. 开始第一位类型有“d”、“-”和“l”,除此之外还有“b”、“c”、“s”等。

  2. “-”表示该文件为普通文件;

  3. “l”表示该文件为软链接文件;

  4. “b”表示该文件为块设备,比如 /dev/sda;

  5. “c”表示该文件为串行端口设备,例如键盘、鼠标;

  6. “s”表示该文件为套接字文件(socket),用于进程间通信。

  7. 后面的九位,每3位为一组,由“r”、“w”、“x”组合而成,其中“r”代表可读(read),“w”代表可写(write),“x”代表可执行(execute)。前三位是所属主(user,简写u)的权限,中间三为是所属组(group,简写g)的权限,最后三位是其他用户(others,简写o)的权限。

“drwxr-xr-x 2 root root 4096 8月 18 09:17 dirPerm”它表示的意思是:dirPerm文件为目录,root用户对该目录可读可写可执行,root组成员和其他用户对该目录可读不可写但可执行。

文件权限数字法表示:例如,某个文件的权限为7则代表可读、可写、可执行(4+2+1);若权限为6则代表可读、可写(4+2)

权限分配文件所属主文件所属组其他用户
执行执行执行
字符表示rwxrwxrwx
数字表示421421421
最终权限rwxrwxrwx
777
chmod

用来改变用户对文件的读写执行权限,该命令操作语法的格式为:

chmod [ -R ] ijk 文件

如果文件是目录,要同时更改目录下的所有文件,则需要使用“-R”参数进行递归更改,“ijk”是权限的数字表示法。

字符表示法通常使用u,g,o,a来代表user,group,others,all的属性进行增加或者减少(读、写或执行)权限的设置。

例如:chmod 777 filePerm、chmod a-x filePerm 、chmod u+x,g=r,o-w filePerm

chown

用来更改文件的所属主和所属组,该命令操作语法的格式为:

chown [ -R ] 所属主[:所属主] 文件

“-R” 参数的作用等同于chmod命令中的“ –R ”参数,也表示递归更改

例如:chown -R bin dir1、chown :daemon file1

umask

用来显示或设定文件模式掩码。,该命令操作语法的格式为:

umask ijk

普通文件的预设值为666(-rw-rw-rw-),目录的预设值为777(drwxrwxrwx),umask的预设值为“022”,一个文件取得的实际权限为文件的预设值减去umask值。

文件特殊权限

普通用户在执行passwd命令时,临时拥有了所属主root的权限,而root拥有最高权限可以强制写操作。

注:如果二进制文件不存在x权限,而加入了s权限,则user位中x的位置变成S(大写字母S)。 chmod u+s specFile、chmod 4755 specFile

特殊权限同样可以采用数字方式,在三位基本权限数字前再加一位,代表特殊权限。其中4代表SUID,2代表SGID,1代表SBIT。

  1. SUID:/etc/passwd和 /etc/shadow文件存放着用户的账户和密码信息。从它们的权限看,普通用户(others)并不具有修改文件的权限。但普通用户可以通过passwd命令来修改自己密码。

  2. SGID:SGID权限可针对命令和目录设置。如果对命令应用SGID,则与SUID类似,只是执行者临时得到所属组的权限,此情况较少用到,用得较多的是对目录的设置。 注意:不能修改

  3. SBIT:SBIT(Sticky Bit)只针对目录有效,采取SBIT特殊权限就只能使文件的所属主和root用户进行删除、重命名和移动等操作,其他用户没有权限进行对应操作。注意:t和T,不能删除

正则表达式

在Linux系统里无论是查找某个文档,亦或查询某个日志文件来进行内容分析,为了提高效率都会用到正则表达式。

非特殊字符代表自己,如果要表示特殊字符需要在前面加上反斜杠“ \ ”。

touch a{1,2}.txt // 创建a1.txt和a2.txt ls a[23].txt // 查看a2.txt和a3.txt

通配符作用
*匹配 0 或多个字符
匹配任意一个字符
[list]匹配 list 中的任意单一字符
[!list]或[^list]匹配 除list 中的任意单一字符
[c1-c2]匹配 c1-c2 中的任意单一字符 如:[0-9]、[a-z]
[!c1-c2]或[^c1-c2]匹配不在c1-c2的任意字符
{string1,string2,...}匹配 sring1 或 string2 (或更多)其一字符串
字符作用
正则表达式的模式匹配字符
.(点号)匹配单个任意字符
[15]匹配字符串中含有1或者5(注意:系统不会认为是“15”)
[0-9a-zA-Z]匹配字符串中的数字以及大小写字母
[^字符]匹配除[ ]内的字符之外的字符
与模式匹配字符配合使用的量词
*匹配零个或个前面的字符
.*表示零个或多个任意字符,空行也包含在内
{n}表示重复n次前面的字符
{n,}表示重复至少n次前面的字符
{n1,n2}表示重复n1到n2次前面的字符
控制字符
^表示行的开始
$表示行的结尾
^$表示空行
\引用特殊字符
grep/egrep/sed

grep 用来搜索文本文件中与给定模式匹配的行。基本语法如下:

grep [选项] 模式 文件 常用选项: -i:忽略大小写差异。 -v:反转匹配,即显示不匹配模式的行。 -r:递归搜索目录。 -l:只列出文件名而不显示具体匹配行。 -n:显示匹配行的行号。

示例: 查找包含 "example" 的行:grep example file.txt 忽略大小写的查找:grep -i example file.txt

egrep egrep 实际上是 grep -E 的别名,它使用扩展正则表达式(Extended Regular Expressions)。扩展正则表达式比基本正则表达式更灵活,支持更多的字符类和运算符。

示例: 使用扩展正则表达式查找:egrep "example.*pattern" file.txt sed sed(Stream Editor)是一个强大的文本流编辑工具,它可以用来执行基本的文本转换工作。基本语法如下:

sed [选项] '命令' 文件 常用命令: s/pattern/replacement/flags:替换模式。例如 s/example/new/g 将每行中的 "example" 替换为 "new"。 p:打印匹配模式的行。 d:删除行。 a:在匹配行之后添加文本。 i:在匹配行之前插入文本。 示例: 将所有 "example" 替换为 "replacement":sed 's/example/replacement/g' file.txt 删除包含 "example" 的行:sed '/example/d' file.txt 在包含 "example" 的行后添加 "new line":sed '/example/a new line' file.txt

软件安装apt命令

apt update
apt upgrade
apt install
apt remove
apt autoremove
apt list

shell语法基础

Shell 脚本通常以 #!/bin/bash 开头,这被称为 shebang,指定了脚本使用的解释器,通常为.sh结尾。

#!/bin/bash
echo "Hello, World!"

# 变量
name="Alice"
age=30
echo "Name: $name, Age: $age"

# 字符串
str1="Hello, $name"
str2='Hello, $name'
echo $str1  # 输出: Hello, Alice
echo $str2  # 输出: Hello, $name

# 数组
fruits=("apple" "banana" "cherry")
echo ${fruits[0]}  # 输出: apple
echo ${fruits[@]}  # 输出: apple banana cherry

# 条件
if [ $age -gt 18 ]; then
    echo "You are an adult."
else
    echo "You are not an adult."
fi

# 循环
# for 循环
for fruit in "${fruits[@]}"; do
    echo "I like $fruit"
done

# while 循环
count=1
while [ $count -le 5 ]; do
    echo "Count: $count"
    ((count++))
done

# 函数
greet() {
    local name=$1
    echo "Hello, $name!"
}

greet "Bob"  # 输出: Hello, Bob!

# 输入输出
read -p "Enter your name: " name
echo "Hello, $name!"

# 文件
if [ -f "file.txt" ]; then
    echo "file.txt is a regular file."
elif [ -d "file.txt" ]; then
    echo "file.txt is a directory."
else
    echo "file.txt does not exist."
fi

# 替换
current_date=$(date)
echo "Today is $current_date"

# 管道和重定向
ls | grep "txt"  # 列出包含 "txt" 的文件
ls > files.txt   # 将 ls 的输出重定向到 files.txt
cat < files.txt  # 从 files.txt 读取内容

# 退出
if command; then
    echo "Command succeeded."
else
    echo "Command failed with status $?"
fi

服务器DHCP、FTP、WEB、DNS

  1. DHCP (Dynamic Host Configuration Protocol) 动态主机配置协议

  • 用途:自动分配IP地址及其他网络配置参数给网络中的设备。

  • 工作原理:当一个设备连接到网络时,它会发送一个请求来获取IP地址等信息。DHCP服务器响应这个请求,并提供一个可用的IP地址以及相关的配置信息如子网掩码、默认网关、DNS服务器地址等。

  • 优点:简化了网络管理,减少了手动配置错误的可能性。

  1. FTP (File Transfer Protocol) 文件传输协议

  • 用途:用于在网络上进行文件的上传与下载。

  • 工作原理:基于客户端-服务器模型,用户通过FTP客户端软件连接到FTP服务器,然后可以执行文件的上传(PUT)或下载(GET)操作。

  • 安全性考虑:标准FTP不加密用户名、密码及数据本身,因此在传输敏感信息时应使用SFTP(SSH File Transfer Protocol)或FTPS(FTP over SSL/TLS)等安全版本。

  1. WEB (Web Services) 网络服务

  • 用途:提供网页内容和服务,支持互联网浏览。

  • 工作原理:由Web服务器(如Apache, Nginx)处理HTTP(S)请求,根据请求的内容返回相应的HTML页面或其他资源。

  • 技术栈:包括前端(HTML, CSS, JavaScript)和后端(PHP, Python, Node.js等),以及数据库(MySQL, PostgreSQL等)。

  • 应用场景:从简单的个人博客到复杂的企业级应用都依赖于Web服务。

  1. DNS (Domain Name System) 域名系统

  • 用途:将易于记忆的域名转换为IP地址,以便计算机能够找到并访问网络资源。

  • 工作原理:当用户尝试访问一个网站时,首先查询本地缓存;如果没有找到,则向最近的DNS服务器发出请求。如果该服务器不知道答案,它会继续向上级DNS服务器询问,直到获得正确的IP地址并将结果返回给用户。

  • 重要性:对于互联网运作至关重要,确保了网络通信的顺畅。

# 查看DHCP(vsftpd、apache2、bind9)
dpkg -l | grep dhcp
# 更新
apt update
apt -y install isc-dhcp-server
# DHCP配置文件
cat /etc/dhcp/dhcpd.conf
vi /etc/dhcp/dhcpd.conf
#启动
systemctl start isc-dhcp-server
systemctl enable isc-dhcp-server
systemctl is-active isc-dhcp-server
# 打印信息
systemctl status isc-dhcp-server
journalctl _PID=4872

5.2 docker

Docker 是一个开源的应用容器引擎,让开发者能够打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的 Linux 或 Windows 服务器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。Docker 允许开发者在一个隔离的环境中构建、测试和部署应用,而不用担心底层基础设施的变化。

快速入门

Docker 的核心概念

  • 镜像 (Image):一个只读模板,用于创建 Docker 容器。镜像可以看作是容器的基础。镜像可以包含一个文件系统的所有内容,包括应用程序、运行时、工具、库等等。

  • 容器 (Container):运行中的镜像实例。每个容器都是相互隔离的、安全的单元。容器是从镜像创建的运行实例。

  • 仓库 (Registry):存储 Docker 镜像的地方。公共仓库如 Docker Hub 提供了大量的镜像,用户也可以搭建私有仓库。

Docker 的基本命令

以下是一些常用的 Docker 命令:

管理镜像
  • docker pull <image>: 从 Docker Hub 下载指定的镜像。

  • docker images: 列出本地系统上的 Docker 镜像。

  • docker rmi <image>: 删除本地的 Docker 镜像。

管理容器
  • docker run <image>: 创建一个新的容器,并运行一个命令。

  • docker ps: 查看正在运行的容器。

  • docker stop <container>: 停止一个或多个容器。

  • docker rm <container>: 移除一个或多个容器。

其他常用命令
  • docker build -t <tag> <directory>: 使用 Dockerfile 构建一个镜像。

  • docker logs <container>: 获取容器的日志输出。

  • docker exec <container> <command>: 在一个运行中的容器里执行命令。

  • docker-compose up: 使用 Docker Compose 文件启动服务。

  • docker network create <network>: 创建一个网络。

  • docker info: 显示 Docker 系统信息。

部署项目

docker build -t 镜像名称 .
dis # 查看
docker run -d --name 容器名字 8080:8080 --network 网络名 镜像名称
docker logs -f 容器名字
docker stop 容器名字
docker rm 容器名字

Docker Compose

Docker Compose 是一个工具,允许用户定义和运行多个 Docker 容器作为一个服务。它使用 docker-compose.yml 文件来配置应用程序的服务。这使得可以很容易地创建和管理一个包含多个容器的应用程序环境。

version: '3.8'  # 指定 Compose 文件版本

services:  # 定义服务
  web:
    image: nginx:latest  # 使用 Nginx 镜像
    ports:
      - "80:80"  # 将主机的 80 端口映射到容器的 80 端口
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf  # 挂载 Nginx 配置文件
    depends_on:
      - app  # 依赖于 app 服务

  app:
    build: ./app  # 从本地目录构建镜像
    environment:
      - FLASK_ENV=development  # 设置环境变量
    volumes:
      - ./app:/app  # 挂载代码目录
    ports:
      - "5000:5000"  # 将主机的 5000 端口映射到容器的 5000 端口

networks:  # 定义网络
  default:
    driver: bridge  # 使用桥接网络

volumes:  # 定义数据卷
  db_data:

Dockerfile

Dockerfile 是一个文本文件,其中包含了一系列命令,用户可以调用 docker build 命令来创建一个镜像。这个文件允许用户自定义镜像的内容,包括安装软件包、复制文件、设置环境变量等。

5.3 k8s

kubernetes,简称K8s,是用8代替名字中间的8个字符“ubernete”而成的缩写。是一个开源的,用于管理云平台中多个主机上的容器化的应用,Kubernetes的目标是让部署容器化的应用简单并且高效(powerful),Kubernetes提供了应用部署,规划,更新,维护的一种机制。

  • 服务发现和负载均衡

  • 存储编排

  • 自动部署和回滚

  • 自动完成装箱计算

  • 自我修复

  • 密钥与配置管理

k8s 本质上就是用来简化微服务的开发和部署的,关注点包括自愈和自动伸缩、调度和发布、调用链监控、配置管理、Metrics 监控、日志监控、弹性和容错、API 管理、服务安全等,k8s 将这些微服务的公共关注点以组件形式封装打包到 k8s 这个大平台中,让开发人员在开发微服务时专注于业务逻辑的实现,而不需要去特别关系微服务底层的这些公共关注点,大大简化了微服务应用的开发和部署,提高了开发效率。

六 Java面试

简历

  1. 基本信息

  2. 教育背景

  3. 个人技能:注意要熟悉能聊,必要技术(springboot+ssm+redis+数据库)+其他技术(微服务、ES、MQ、原码、高并发、jvm,技术选型、设计能力...),要详细描写技术聊你熟悉技术

  4. 项目经验:项目名称、周期、技术栈、简介、负责模块(成就指标QPS数据量),可以在gitee和github或者比赛,独立完成!

  5. 个人优势和证书

求其上,得其中;求其中,得其下,求其下,必败

如果我想进中厂,就要做进大厂的准备。如果我想找到月薪1W+的工作,就需要做月薪1W5+的准备。如果我的目标就是找到工作,起码要做冲洗中小厂的准备。如果我的目标就是找个小公司混日子,大概率凉凉。

redis

  • 缓存:穿透(空值,布隆过滤器)、击穿(互斥锁,逻辑过期)、雪崩、双写一致(延迟双删、分布式锁[读共享锁、写排他锁])、持久化、数据过期、淘汰策略

  • 分布式锁:setnx、redisson

  • 消息队列、延迟队列:数据类型

穿透

缓存穿透是指查询一个一定不存在的数据,由于存储层查不到数据因此不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。这种情况大概率是遭到了攻击,通常使用布隆过滤器。

布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否在一个集合中。虽然它可能会有误判(即假阳性),但不会出现假阴性。因此,可以在请求到达缓存之前先通过布隆过滤器进行检查,如果布隆过滤器返回不存在,则直接返回,避免查询数据库。

击穿

给某一个key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,这些并发的请求可能会瞬间把DB压垮。

  1. 互斥锁,强一致,性能差

  2. 逻辑过期,高可用,性能优,不能保证数据绝对一致

雪崩

指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

  • 给不同的Key的TTL添加随机值

  • 利用Redis集群提高服务的可用性:哨兵模式、集群模式

  • 给缓存业务添加降级限流策略:ngxin或spring cloud gateway,降级可做为系统的保底策略,适用于穿透、击穿、雪崩

  • 给业务添加多级缓存:Guava或Caffeine

双写一致

当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致。

  1. 允许延时一致的业务,采用异步通知:

    • 使用MQ中间中间件,更新数据之后,通知缓存删除;

    • 利用canal中间件,不需要修改业务代码,伪装为mysql的一个从节点,canal通过读取binlog数据更新缓存。

  2. 强一致性的,采用Redisson提供的读写锁

    • 共享锁:读锁readLock,加锁之后,其他线程可以共享读操作;

    • 排他锁:独占锁writeLock也叫,加锁之后,阻塞其他线程读写操作。

持久化

  1. RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。

  2. AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

MySQL

优化

慢查询
  • 聚合查询

  • 多表查询表

  • 数据量过大查询

  • 深度分页查询

  1. 开源工具

    • 调试工具:Arthas

    • 运维工具:Prometheus 、Skywalking

  2. MySQL自带慢日志:方案二:MySQL自带慢日志慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志如果要开启慢查询日志,需要在MySQL的配置文件(/etc/my.cnf)中配置如下信息:

# 开启MySQL慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2秒,SQL语句执行时间超过2秒,就会视为慢查询,记录慢查询日志
long_query_time=2
# 配置完毕之后,通过以下指令重新启动MySQL服务器进行测试,查看慢日志文件中记录的信息 /var/lib/mysql/localhost-slow.log。

分析:在select前面加EXPLAIN或者DESC分析,注意type这条sql的连接的类型,性能由好到差为NULL、system(查询系统中的表)、const(根据主键查询)、eq_ref(主键索引查询或唯一索引查询)、ref(索引查询r)、range(范围查询)、 index(索引树扫描)、all(全盘扫描)

  • 通过key和key_len检查是否命中了索引(索引本身存在是否有失效的情况)

  • 通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描

  • 通过extra建议判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复

七 融合人工智能技术的企业级应用开发

项目名称:智能客服助手

项目背景

随着互联网的发展,企业与客户之间的互动日益频繁。传统的客服系统已经不能满足现代客户的需求,尤其是在响应速度和服务质量方面。因此,开发一个能够自动识别客户需求、提供个性化建议并能持续学习优化的智能客服系统变得尤为重要。

项目目标

开发一款智能客服助手,它能够:

自动回答客户的常见问题。 通过自然语言处理理解复杂问题并转交至人工客服。 根据历史对话记录和用户行为预测客户需求,提供个性化建议。 收集反馈数据,持续改进模型精度。

技术栈

前端: Vue.js 或 React.js 后端: Python(Flask/Django) 数据库: MySQL 或 MongoDB AI模型: TensorFlow/Keras 或 PyTorch 自然语言处理: spaCy 或 NLTK 推荐系统: LightFM 或 Surprise 消息队列: RabbitMQ 或 Kafka 容器化部署: Docker 持续集成/持续部署: Jenkins/GitLab CI 监控: Prometheus/Grafana

项目结构

前端项目结构

src/: 存放源代码 assets/: 静态资源文件夹 components/: Vue/React组件 services/: API调用 store/: Vuex/Redux状态管理 App.vue/App.js: 主组件 main.js: 应用入口文件

后端项目结构

app/: 应用逻辑 controllers/: 控制器 models/: 数据模型 routes/: 路由配置 services/: 业务逻辑 config/: 配置文件 tests/: 测试用例 requirements.txt: Python依赖包清单 app.py: Flask/Django入口文件

功能模块

用户界面

登录/注册页面 客服聊天窗口 历史对话记录 用户反馈提交表单

自动回复系统

使用NLP技术解析用户问题 匹配FAQ数据库,提供答案 若无法匹配,则记录问题并转人工客服

推荐系统

根据用户历史交互记录生成推荐内容 实现个性化服务体验

机器学习模型训练

使用TensorFlow/Keras或PyTorch训练模型 模型版本管理 模型性能评估

日志与监控

记录系统操作日志 实时监控系统健康状况

部署方案

使用Docker容器化部署应用 利用Kubernetes集群管理容器 CI/CD自动化部署流水线

测试计划

单元测试 集成测试 性能测试 安全测试

项目实施步骤

需求分析:确定具体需求和目标。 技术选型:根据需求选择合适的技术栈。 系统设计:设计系统架构图,划分模块。 开发实施:按照模块逐步开发。 测试上线:完成测试后,部署上线。 维护更新:根据用户反馈不断迭代优化。

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值