• JDBC概述详解


    本文全面的介绍了JDBC的相关知识,包括其基础与高级应用、Service层的事务管理、ThreadLocal(本地线程变量)、 数据库连接池、Commons Dbutils的原理及其使用!

    一、JDBC基础

    1.1JDBC介绍

    • JDBC (全称Java DataBase Contectivity) :Java与数据库的连接,数据库编程。
    • JDBC 是Java语⾔(JDK)为完成数据库的访问操作提供的⼀套统⼀的标准
    • 驱动包下载: 下载驱动jar包,下载地址:https://mvnrepository.com/,打开⽹址搜索 mysql 。
    • (开发者通过JDK提供的规范与数据库厂商提供的驱动,将驱动类加载到程序中使用,进而达到通过程序操作数据库)

    请添加图片描述


    1.2JDBC的核心类与接口

    1.java.sql.DriverManager类 (驱动管理器)

    • 注册驱动
    • 创建数据库连接

    (1)注册驱动

    • 在Driver类中的静态初始化块中,注册驱动:DriverManager.registerDriver(new
      Driver());
    public class Driver extends NonRegisteringDriver implements >java.sql.Driver {
       public Driver() throws SQLException {
       }
    
       static {
           try {
               DriverManager.registerDriver(new Driver());
           } catch (SQLException var1) {
               throw new RuntimeException("Can't register driver!");
           }
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 在我们的应⽤程序中⼿动注册驱动的代码也可以省略
      【Class.forName(“com.mysql.cj.jdbc.Driver”);】

      如果我们没有⼿动注册驱动,驱动管理器在获取连接的时候发现没有注册驱动则读取 驱动jar/META-INF/servicesjava.sql.Driver⽂件中配置的驱动类路径进⾏注册 不推荐
      在这里插入图片描述

    (2)获取连接

    // url 数据库服务器的地址
    // username 数据库连接⽤⼾名
    // password 数据库连接密码
    Connection connection = DriverManager.getConnection(url, "root","123456");
    
    • 1
    • 2
    • 3
    • 4

    2.java.sql.Connection接⼝ (数据库连接)

    Connection对象表⽰Java应⽤程序与数据库之间的连接

    • 通过Connection接⼝对象,获取执⾏SQL语句的Statement对象
    • 完成数据的事务管理

    (1) 获取Statement对象

    • Statement接⼝: 编译执⾏静态SQL指令
    Statement statement = connection.createStatement();
    
    • 1
    • PreparedStatement接⼝:继承了Statement接⼝,预编译动态SQL指令(解决SQL注⼊问题)
    PreparedStatement preparedStatement = connection.prepareStatement(sql);
    
    • 1
    • CallableStatement接⼝:继承了PreparedStatement接⼝,可以调⽤存储过程
    CallableStatement callableStatement = connection.prepareCall(sql);
    
    • 1

    (2)事务管理

    //开启事务(关闭事务⾃动提交)
    connection.setAutoCommit(false);
    //事务回滚
    connection.rollback();
    //提交事务
    connection.commit();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    3.java.sql.Connection接⼝ (数据库连接)

    —用于编译、执行SQL指令
    在这里插入图片描述

    // 执⾏DML操作的SQL指令(返回值是受影响行数)
    int row = statement.executeUpdate(sql);
    // 执⾏DQL操作的SQL指令(返回结果集)
    ResultSet rs = statement.executeQuery(sql);
    
    • 1
    • 2
    • 3
    • 4

    4.java.sql.ResultSet接⼝ (结果集

    — ResultSet接⼝对象,表⽰查询操作返回的结果集,提供了便利的⽅法⽤于获取结果集中的数据

    	//res.next()用于判断下一个位置是否还有值,初始时位于首元素之前
    		while (res.next()){
    		//res.getInt("tid"):通过数据库字段名获取数据
    		//res.getInt(2):通过字段列标获取数据(列标从1开始)
                 teacher=new Teacher(res.getInt("tid"),res.getString("tname"),
                 res.getString("gender"),res.getDate("workingdate"),
                  res.getString("workgrade"));
         }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    1.3SQL注⼊问题

    (1)什么是SQL注⼊问题

    • 在JDBC操作SQL指令编写过程中,如果SQL指令中需要数据,我们可以通过字符串拼接的形式将参数拼接到SQL指令中,如 String sql = "delete from books where book_id="+s; (s就是拼接到SQL中的变量)
    • 使⽤字符串拼接变量的形式来设置SQL语句中的数据,可能会导致因变量值的改变引起SQL指令的原意发⽣改变 ,这就被称为SQL注⼊。SQL注⼊问题是需要避免的
    • 例如:
      如果s的值为1,SQL指令 : delete from books where book_id=1,
      如果s的值为1 or 1=1,SQL指令:delete from books where book_id=1 or 1=1, 那么SQL中的条件则是⼀个恒等式(sql指令发生变化)

    (2)如何解决SQL注⼊问题

    使⽤PreparedStatement进⾏SQL预编译解决SQL注⼊问题:

    • 在编写SQL指令时,如果SQL指令中需要参数,⼀律使⽤?参数占位符
    • 如果SQL指令中有?,在JDBC操作步骤中不再使⽤Statement,⽽是从Conection对象获取PreparedStatement对SQL指令进⾏预编译 PreparedStatement preparedStatement = connection.prepareStatement(sql);
    • 预编译完成之后,通过PreparedStatement对象给预编译后的SQL指令的?赋值
      • prepareadStatement.setInt(参数占位符序号,值);
      • prepareadStatement.setString(参数占位符序号,值);
    • SQL指令中的所有?完成赋值之后,通过PreparedStatement执⾏SQL执⾏SQL时不再加载SQL语句
      • int row = prepareadStatement.executeUpdate();
      • ResultSet rs = preparedStatement.executeQuery();

    1.4 JDBC开发步骤

    • 加载驱动
     //	1.加载驱动类
    Class.forName("com.mysql.jdbc.Driver");
    
    • 1
    • 2
    • 创建连接
    //2.建立连接
    conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/javaTest", "root", "123456");
    
    • 1
    • 2
    • 编写sql指令
       //3.编写sql语句
       String sql="insert into student values(2,'李四')";
    
    • 1
    • 2
    • 创建执行体
    //4.创建执行体
     stmt = conn.createStatement();
    
    • 1
    • 2
    • 执行sql指令
    //5.执行sql语句
     int row = stmt.executeUpdate(sql);
    
    • 1
    • 2
    • 处理执行结果(集)
    //6.处理结果
                if (row>-1){
                    System.out.println("插入成功!");
                }
                else{
                    System.out.println("插入失败!");
                }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 释放占用资源
      //释放资源
      try {
          stmt.close();
          conn.close();
      } catch (SQLException throwables) {
          throwables.printStackTrace();
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    二、JDBC高级应用

    2.1 三层架构与面向结构编程

    (1)三层架构

    三层架构是指:视图层 View服务层 Service,与持久层 Dao。它们分别完成不同的功能。

    • View 层:用于接收用户提交请求。
    • Service 层:用以实现系统的业务逻辑
    • Dao 层:用以实现直接对数据库进行操作。

    (2)面向接口 (抽象) 编程

    面向接口编程:程序设计时,考虑易修改、易扩展,为Service层和DAO层设计接口,便于未来更换实现类
    在三层架构程序设计中,采用面向接口(抽象)编程。

    • 实现方式
      • 上层对下层的调用,是通过下层接口实现的
      • 而下层对上层的真正服务提供者,是下层接口的实现类
        img
    • 特点:
      • 服务标准(接口:规范相同)是相同的,服务提供者(实现类)可以更换。这就实现了层间解耦合与编程的灵活性

    2.1 封装

    (1)DAO封装 — (DAO Data Access Object 数据访问对象)

    将对数据库中同⼀张数据表的JDBC操作⽅法封装到同⼀个Java类中,这个类就是访问此数据表的 数据访问对象

    (2)DTO封装 — ( Data Transfer Object 数据传输对象(实体类))

    在Java程序中创建⼀个属性与数据库表匹配的类,通过此类的对象封装查询到的数据,我们把⽤于传递JDBC增删查改操作的数据的对象称之为 数据传输对象

    2.2 Service层的事务管理

    (1)事务的概念

    事务是指是程序中一系列严密的逻辑操作,而且所有操作必须全部成功完成,否则在每个操作中所作的所有更改都会被撤消。

    (2)事务的四大特性

    • 原子性(Atomicity):操作这些指令时,要么全部执行成功,要么全部不执行。只要其中一个指令执行失败,所有的指令都执行失败,数据进行回滚,回到执行指令前的数据状态。
    • 一致性(Consistency): 事务的执行使数据从一个状态转换为另一个状态,但是对于整个数据的完整性保持稳定。
    • 隔离性(Isolation): 隔离性是当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。
    • 持久性(Durability): 当事务正确完成后,它对于数据的改变是永久性的。

    (3)事务的隔离级别

    • 第一种隔离级别:Read uncommitted(读未提交)

      • 如果一个事务已经开始写数据,则另外一个事务不允许同时进行写操作,但允许其他事务读此行数据,该隔离级别可以通过“排他写锁”,但是不排斥读线程实现。这样就避免了更新丢失,却可能出现脏读,也就是说事务B读取到了事务A未提交的数据

      • 解决了更新丢失,但还是可能会出现脏读


      第二种隔离级别:Read committed(读提交)

      • 如果是一个读事务(线程),则允许其他事务读写,如果是写事务将会禁止其他事务访问该行数据,该隔离级别避免了脏读,但是可能出现不可重复读。事务A事先读取了数据,事务B紧接着更新了数据,并提交了事务,而事务A再次读取该数据时,数据已经发生了改变。

      • 解决了更新丢失和脏读问题


      第三种隔离级别:Repeatable read(可重复读取)

      • 可重复读取是指在一个事务内,多次读同一个数据,在这个事务还没结束时,其他事务不能访问该数据(包括了读写),这样就可以在同一个事务内两次读到的数据是一样的,因此称为是可重复读隔离级别,读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务(包括了读写),这样避免了不可重复读和脏读,但是有时可能会出现幻读。(读取数据的事务)可以通过“共享读镜”和“排他写锁”实现。

      • 解决了更新丢失、脏读、不可重复读、但是还会出现幻读


      第四种隔离级别:Serializable(序列化)

      • 提供严格的事务隔离,它要求事务序列化执行,事务只能一个接着一个地执行,但不能并发执行,如果仅仅通过“行级锁”是无法实现序列化的,必须通过其他机制保证新插入的数据不会被执行查询操作的事务访问到。序列化是最高的事务隔离级别,同时代价也是最高的,性能很低,一般很少使用,在该级别下,事务顺序执行,不仅可以避免脏读、不可重复读,还避免了幻读

      • 解决了更新丢失、脏读、不可重复读、幻读(虚读)


      隔离级别脏读不可重复度幻读
      Read uncommitted(读未提交)
      Read committed(读提交)×
      Repeatable read(可重复读取)××
      Serializable(序列化)×××
      • 脏读
        • 所谓脏读是指一个事务中访问到了另外一个事务未提交的数据
      • 幻读
        • 一个事务读取2次,得到的记录条数不一致
      • 不可重复读
        • 一个事务读取同一条记录2次,得到的结果不一致
    • MySQL事务管理:

      • start transaction (开启事务)
      • end transaction (结束事务)
      • rollback (事务回滚)
      • commit (提交事务)

    (4)JDBC事务管理

    • ⼀个事务中的多个DML操作需要基于同⼀个数据库连接
    • 创建连接之后,设置事务⼿动提交(关闭⾃动提交connection.setAutoCommit(false);
    • 当当前事务中的所有DML操作完成之后⼿动提交 connection.commit();
    • 当事务中的任何⼀个步骤出现异常,在catch代码块中执⾏事务回滚
      connection.rollback();

    (5)Service层简介

    DAO负责特定的数据库操作,业务由service层进⾏管理

    • 业务:指的是完成某一功能(软件提供的一个功能)
      • 例如:A给B转帐(其包含A账户减钱,B账户加钱),其整体为一个业务的操作
    • Servcie进⾏业务处理,Service业务处理过程如果需要数据库操作,则调⽤DAO完成
    • Service层的一个业务,可能需要调用一个或若干个DAO层对数据库进行处理

    (6)Service层事务管理

    事务管理要满⾜以下条件:

    • 多个DML操作需使⽤同⼀个数据库连接
    • 第⼀个DML操作之前设置事务⼿动提交
    • 所有DML操作执⾏完成之后提交事务
    • 出现异常则进⾏事务回滚

    1.需要解决的问题

    • Servcie层事务可能涉及多个DAO层,其中多个数据库的DML操作是相互独⽴的,如何保证所有DML要么同时成功,要么同时失败呢?

    2.解决办法:让Service事务中的多个DML使⽤同⼀个数据库连接

    方式一:在Service获取连接对象,将连接对象传递到DAO中

    • 分析: DAO类中的Connection对象需要通过Service传递给进来,这种对象传递本来也⽆可厚⾮,但是当我们通过⾯向接⼝开发时(⾯向接⼝,是为了能够灵活的定义实现类),容易造成接⼝的冗余(接⼝污染)
    • 接口污染典型示例: 不同的数据库的数据库连接对象是不同的,MySQL的连接对象是Connection 但Oracle数据库则不是

    方式二:使⽤ThreadLocal容器,实现多个DML操作使⽤相同的连接

    • 不使用自定义List集合的原因:
      • 存储Connection的容器可以使⽤List集合,使⽤List集合做容器,在多线程并发编程中会出现资源竞争问题,多个并发的线程使⽤的是同⼀个数据库连接对象我们的要求是同⼀个事务中使⽤同⼀个连接,⽽并⾮多个线程共享同一个连接
      • 为了解决并发编程的连接对象共享问题,我们可以 使⽤ThreadLocal作为数据库连接对象的容器

    2.3 ThreadLocal(本地线程变量)

    (1)ThreadLocal简介

    ThreadLocal 叫做本地线程变量,意思是说,ThreadLocal 中填充的的是当前线程的变量,该变量对其他线程而言是封闭且隔离的,ThreadLocal 为变量在每个线程中创建了一个副本,这样每个线程都可以访问自己内部的副本变量。

    (2)ThreadLocal的应用

    • 在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。
    • 线程间数据隔离
    • 进行事务操作,用于存储线程事务信息。
    • 数据库连接,Session会话管理。

    (3)ThreadLocal常用的方法

    • set()方法:
      ThreadLocal对象.set() 会为ThreadLocal对象调用set()方法所在的线程中的ThreadLocal.ThreadLocalMap threadLocals = null;进行赋值,赋值类型为一个Map,Map的键为当前的ThreadLocal对象,值为所传入的值.

    • set()的源码

    public void set(T value) {
    //获取当前ThreadLocal对象所在的线程
    Thread t = Thread.currentThread();
    //获取所在线程中存储的threadLocals(ThreadLocalMap)
    ThreadLocalMap map = getMap(t);
    //判断map是否为空
    if (map != null)
     //不为空,则替换值
     map.set(this, value);
    else
     //为空则为当前线程创建ThreadLocalMap对象并为threadLocals赋值
     createMap(t, value);
    }
    
    void createMap(Thread t, T firstValue) {
    //为线程t中的threadLocals创建一个ThreadLocalMap对象进行赋值
    t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    ThreadLocalMapThreadLocal 的一个静态内部类,里面定义了Entry 来保存数据。而且是继承的弱引用。在Entry内部使用ThreadLocal作为key,使用我们设置的value作为value

    对于每个线程内部有个ThreadLocal.ThreadLocalMap 变量,存取值的时候,也是从这个容器中来获取。


    • get()方法
    public T get() {
     //获取当前ThreadLocal对象所在的线程
      Thread t = Thread.currentThread();
      //获取所在线程中存储的threadLocals(ThreadLocalMap)
      ThreadLocalMap map = getMap(t);
     //判断线程中存储的ThreadLocalMap是否为空
      if (map != null) {
          //不为空,则以当前ThreadLocal对象为键获取Entry对象
          ThreadLocalMap.Entry e = map.getEntry(this);
          //判断Entry对象是个为空
          if (e != null) {
              //不为空则返回Entry对象的值
              @SuppressWarnings("unchecked")
              T result = (T)e.value;
              return result;
          }
      }
     //线程中ThreadLocalMap为空或者未存储以当前ThreadLocal对象为键的Entry对象时设置初始值
      return setInitialValue();
    }
    
    //设置为线程中ThreadLocalMap的初始值
    private T setInitialValue() {
     //设置当前值为null
     T value = initialValue();
      //获取当前ThreadLocal对象所在的线程
     Thread t = Thread.currentThread();
      //获取所在线程中存储的threadLocals(ThreadLocalMap)
     ThreadLocalMap map = getMap(t);
      //判读map是否为空
     if (map != null)
         //map不为空向map中添加一个以当前ThreadLocal对象为键。值为空的Entry对象
         map.set(this, value);
     else
         //map为空则为当前线程的threadLocals进行创建对象初始化
         createMap(t, value);
     return value;
    }
    
    protected T initialValue() {
     return null;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    (4)ThreadLocal的内存泄流问题

    • 内存泄漏原因
      当ThreadLocal为null时,也就是要被垃圾回收器回收了,但是此时我们的ThreadLocalMap(thread 的内部属性)生命周期和Thread的一样,它不会回收,这时候就出现了一个现象。那就是ThreadLocalMap的key没了,但是value还在,这就造成了内存泄漏。

    • 解决方法:
      ​ 用完ThreadLocal后,执行remove操作,避免出现内存溢出情况。所以 如同 lock 的操作 最后要执行解锁操作一样,ThreadLocal使用完毕一定记得执行remove 方法,清除当前线程的数值。如果不remove 当前线程对应的VALUE ,就会一直存在这个值。

    (5)强引用,弱引用,软引用

    • 强引用:普通的引用,强引用指向的对象不会被回收;
    • 软引用:仅有软引用指向的对象,只有发生gc且内存不足,才会被回收;
    • 弱引用:仅有弱引用指向的对象,只要发生gc就会被回收。

    (6)多线程环境下ThreadLocal在事务中操作可能出现的问题

    在阐述此问题时,需要简要介绍一下mysql数据库的事务默认隔离级别(Repeatable read(可重复读取))

    • 第三种隔离级别:Repeatable read(可重复读取)
      可重复读取是指在一个事务内,多次读同一个数据,在这个事务还没结束时,其他事务不能访问该数据(包括了读写),这样就可以在同一个事务内两次读到的数据是一样的,因此称为是可重复读隔离级别,读取数据的事务将会禁止写事务(但允许读事务),写事务则禁止任何其他事务(包括了读写),这样避免了不可重复读和脏读,但是有时可能会出现幻读。

    • 场景
      • 存在两个线程,因为ThreadLocal保存数据库连接变量,可以保证两个线程各自拥有自己的数据库连接,一般在操作各自线程任务的事务时不会出现冲突和干扰
      • 现在有如下场景,两个线程同时操作同一个数据中的同一张表,a线程使1号用户给3号用户转账,b线程使1号用户给2号客户转账,a、b线程同时运行抢夺cpu的执行权,此时程序中只会有一个线程执行成功,另一个线程执行失败
        • (原因:mysql默认为Repeatable read(可重复读取)隔离级别,ab两线程同时操作写,违反了mysql的事务隔离级别)
        • 根本原因:每个线程操作的数据库中表的副本,在对数据库的表操作过程中会有以下验证:当a线程拿到表的副本后,会记录拿到副本当时表内容状态T,当它修改完自己拿到的副本后准备提交给数据库时,会将自己记录的表状态T 与提交前数据库中此表状态进行比对,如果二者不一致,线程a不会提交自己的副本给数据库,并会报错(比对结果,表明在a线程修改表内容的期间,有其他线程对表进行了更改,所以会报错

    2.4 数据库连接池

    1.引入数据库连接池的原因

    • 如果每个JDBC操作需要数据库连接都重新创建,使⽤完成之后都销毁,我们的JVM会因为频繁的创建、销毁连接⽽占⽤额外的系统资源。
    • 数据库连接本质上是可被重⽤的资源(当⼀个JDBC操作完成之后,其创建的连接是可以被其他JDBC操作使⽤的),基于这个特性:
      • 我们可以创建⼀个 存放数据库连接的容器 (连接池),连接池是有最⼤容量的
      • 当我们要进⾏JDBC操作时,直接从这个容器中获取连接:
        • 如果容器中没有空闲的连接且连接池中连接的个数没有达到最⼤值,则创建新的数据库连接存⼊连接池并给这个操作使⽤,使⽤完成之后⽆需关闭连接直接归还这个容器中即可
        • 如果容器中没有空闲的连接且连接池中连接的个数达到最⼤值,当前操作就会进⾏等待,等待连接池中的某个连接被归还,归还之后再使⽤
        • 如果容器中有空闲连接,则⽆需创建新的连接,直接从容器中获取这个空闲连接进⾏使⽤

    2.概念

    • 连接池:存放数据库连接对象的容器
    • 连接池作⽤:对数据库连接进⾏管理,减少因重复创建、销毁连接导致的系统开销
      preview

    3.常用的线程池

    功能dbcpdruidc3p0tomcat-jdbcHikariCP
    是否支持PSCache
    监控jmxjmx/log/httpjmx,logjmxjmx
    扩展性
    sql拦截及解析支持
    代码简单中等复杂简单简单
    特点依赖于common-pool阿里开源,功能全面历史久远,代码逻辑复杂,且不易维护优化力度大,功能简单,起源于boneCP
    连接池管理LinkedBlockingDeque数组FairBlockingQueuethreadlocal+CopyOnWriteArrayList
    • 由于boneCP被hikariCP替代,并且已经不再更新,boneCP没有进行调研。
    • proxool网上有评测说在并发较高的情况下会出错,proxool便没有进行调研。
    • druid的功能比较全面,且扩展性较好,比较方便对jdbc接口进行监控跟踪等。
    • c3p0历史悠久,代码及其复杂,不利于维护。并且存在deadlock的潜在风险。
    • 基于连接池的性能、使⽤的便捷性、连接监控等多⽅⾯综合情况,druid是⽬前企业应⽤中使⽤最 ⼴泛的
    • Hikari在SpringBoot中默认集成,性能是诸多竞品中最好的

    4.Druid线程池的使用

    2.5 Commons Dbutils(Apache提供的dao层工具类)


    (1)原理(自定义DaoUtils实现通用)


    (2)Apache的DbUtils

    • 核心思想:反射
    • 注意事项:创建QueryRunner对象时,不能使用无参构造方法,需要传入一个连接池对象(配合线程池使用)
    • 参考我的另一篇博客: DaoUtils实现通用(增、删、改、查)
  • 相关阅读:
    2024022502-数据库绪论
    【Opencv实战】识别水果的软件叫什么?一款超好用的识别软件分享,一秒鉴定(真是活~久~见~啊)
    ABB机器人常用指令功能说明
    设计模式之外观模式
    vant_vant引入
    Eudic欧路词典 for Mac(可离线英语学习工具)
    【Linux基础】3.2 磁盘分区机制,增加一个硬盘操作,硬盘/文件夹查询操作
    【四数之和】
    由一个按键程序引发的思考(下)
    C#结合JavaScript实现上传视频到腾讯云点播平台
  • 原文地址:https://blog.csdn.net/weixin_43715360/article/details/125991453