DriverManager.getConnection一共有四个重载方法,前三个由public修饰,用来获取不同类型的参数,这三个getConnection实际相当于一个入口,他们最终都会return第四个私有化的getConnection方法,最终向第四个私有化方法的传入参数都是url,java.util.Properties,以及Reflection.getCallerClass(),这个方法是native的
其中Reflection.getCallerClass()是反射中的一个方法,这个方法用来返回他的调用类,也就说是哪个类调用了这个方法,Reflection类位于调用栈中的0帧位置,在JDK7以前,该方法可以传入int n返回调用栈中从0帧开始的第n帧中的类,在JDK7中,需要设置java命令行选项Djdk.reflect.allowGetCallerClass来使用该方法,到了JDK8时,再调用该方法会导致UnsupportedOperationException异常。
JDK8中getCallerClass使用方法变更为getCallerClass(),Reflection.getCallerClass()方法调用所在的方法必须用@CallerSensitive进行注解,通过此方法获取class时会跳过链路上所有的有@CallerSensitive注解的方法的类,直到遇到第一个未使用该注解的类,避免了用Reflection.getCallerClass(int n) 这个过时方法来自己做判断。
在这里每个getConnection都是用CallerSensitive修饰的,调用getCallerClass应该是获取外面使用DriverManager.getConnection()的类的名称,即在class A中调用了DriverManager.getConnection(),则返回class A。
在私有化的getConnection中涉及到了类加载器的一些相关概念,https://blog.csdn.net/briblue/article/details/54973413
线程上下文类加载器的适用场景:
1. 当高层提供了统一接口让低层去实现,同时又要是在高层加载(或实例化)低层的类时,必须通过线程上下文类加载器来帮助高层的ClassLoader找到并加载该类。
2. 当使用本类托管类加载,然而加载本类的ClassLoader未知时,为了隔离不同的调用者,可以取调用者各自的线程上下文类加载器代为托管。
java.sql.DriverManager.java
/* 该方法获取url以及存放user与password的持久化结果集,并获取请求连接的外部类,将其传入私有化的getConnection方法中进行处理 */
@CallerSensitive
public static Connection getConnection(String url,java.util.Properties info) throws SQLException {
return (getConnection(url, info, Reflection.getCallerClass()));
}
/* 该方法获取url以及string类型的user和password,并将user和password放入properties中,然后获取请求连接的外部类,将他们都传入私有化的getConnection方法 */
@CallerSensitive
public static Connection getConnection(String url,String user, String password) throws SQLException {
java.util.Properties info = new java.util.Properties();
if (user != null) {
info.put("user", user);
}
if (password != null) {
info.put("password", password);
}
return (getConnection(url, info, Reflection.getCallerClass()));
}
/* 该方法只获取url,用户名及密码信息也存放在url中,在方法中实例化一个空的properties,然后获取请求连接的外部类,将他们都传入私有化的getConnection方法 */
@CallerSensitive
public static Connection getConnection(String url)throws SQLException {
java.util.Properties info = new java.util.Properties();
return (getConnection(url, info, Reflection.getCallerClass()));
}
/* 前面三个方法最终将传入的url以及存放用户名密码或空的properties以及请求连接的外部类传参进来,在这里进行处理 */
private static Connection getConnection(
String url, java.util.Properties info, Class<?> caller) throws SQLException {
/* * When callerCl is null, we should check the application's * (which is invoking this class indirectly) * classloader, so that the JDBC driver class outside rt.jar * can be loaded from here. */
/* * caller.getClassLoader()类加载器,获得DriverManager.getConnection()调用者的classLoader, * ClassLoader主要对类的请求提供服务,当JVM需要某类时,它根据名称向ClassLoader要求这个类,然后由ClassLoader返回 这个类的class对象。 * 这里对于类加载器的检查应该是涉及到了委托模型的知识,好像是为了确保调用getConnection的类的classLoader能够加载外部的JDBC驱动类, * 这里网上博客有一篇这么说的: * ContextClassLoader可以设置,默认的都是systemClassLoader * ContextClassLoader在web容器里面用到的比较多,它可以让父classLoader访问到子的classLoader的class,如JDBC驱动程序 * 就是,在父classLoader(bootstrap)加载的类里面,访问到了子classLoader(SystemClassLoader)才能加载的类(com.mysql.jdbc.ReplicationDriver)。 */
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
synchronized(DriverManager.class) {
// synchronize loading of the correct classloader.
/* * 在这里是如果上面获取调用此方法的类的classLoader为空,就无法加载驱动类了, * 这里涉及到了双亲委派模型和委派链的知识,网上的解释是这样的: * Thread context class loader存在的目的主要是为了解决parent delegation机制下无法干净的解决的问题。假如有下述委派链: * ClassLoader A -> System class loader -> Extension class loader -> Bootstrap class loader * 那么委派链左边的ClassLoader就可以很自然的使用右边的ClassLoader所加载的类。 * 但如果情况要反过来,是右边的ClassLoader所加载的代码需要反过来去找委派链靠左边的ClassLoader去加载东西怎么办呢? * 没辙,parent delegation是单向的,没办法反过来从右边找左边。 * 这种情况下就可以把某个位于委派链左边的ClassLoader设置为线程的context class loader, * 这样就给机会让代码不受parent delegation的委派方向的限制而加载到类了。 */
/* * DriverManager在rt.jar里面,它的类加载器上启动类加载器。 * 而数据库的driver驱动类是放在classpath里面的,启动类加载器是不能加载的。 * 所以,如果严格按照双亲委派模型,是没办法解决的。 * 而这里的解决办法是:通过调用类的类加载器去加载。 * 而如果调用类的加载器是null,就设置为线程的上下文类加载器 */
/* * Thread.currentThread()方法来获取系统当前正在执行的一条线程, * getContextClassLoader方法获取classLoader * 这里获取的是线程上下文类加载器 */
if (callerCL == null) {
callerCL = Thread.currentThread().getContextClassLoader();
}
}
if(url == null) {
throw new SQLException("The url cannot be null", "08001");
}
println("DriverManager.getConnection(\"" + url + "\")");
SQLException reason = null;
/* * 在注册的驱动列表中遍历,如果顺利获取,那么在for中的return中结束driverManager负责的连接获取 */
for(DriverInfo aDriver : registeredDrivers) {
//进行验证,在列表中取出用户注册的需要获取连接的驱动类,这里的列表应该是存放的不同线程注册的驱动
if(isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
//调用driver的connect方法,并返回获得的驱动
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
// Success!
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
} else {
println(" skipping: " + aDriver.getClass().getName());
}
}
// if we got here nobody could connect.
if (reason != null) {
println("getConnection failed: " + reason);
throw reason;
}
println("getConnection: no suitable driver found for "+ url);
throw new SQLException("No suitable driver found for "+ url, "08001");
}
在这个isDriverAllowed方法中主要是判断列表里面取出的驱动类是不是用户注册的,需要获取连接的那个。
private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) {
boolean result = false;
if(driver != null) {
Class<?> aClass = null;
try {
// object.getClass() 获取对象的类型即Class类的对象,通过调用Class类提供的方法可以获取这个类的类名称(包名+类名)以及类加载器。
//这里获取的并不是这个驱动的类,只是为了获取这个驱动的名称,然后通过当前获取的类加载器加载这个名称的驱动类,目的是为了校验
//校验当前加载的这个驱动类是不是用户注册的那个
// 方法返回与给定字符串名的类或接口的Class对象,使用给定的类加载器
aClass = Class.forName(driver.getClass().getName(), true, classLoader);
} catch (Exception ex) {
result = false;
}
// 只有同一个类加载器中的Class使用==比较时才会相等,此处就是校验用户注册Driver时该Driver所属的类加载器与调用时的是否为同一个
result = ( aClass == driver.getClass() ) ? true : false;
}
return result;
}
不同厂商的Driver类都实现了java.sql.Driver接口
package org.postgresql.Driver.java
@Override
public Connection connect(String url, Properties info) throws SQLException {
Properties defaults;
//判断url前缀格式
if (!url.startsWith("jdbc:postgresql:")) {
return null;
}
/****************************这块是为了获取传入的用户名密码****************************/
try {
//获取扩大权限的properties
defaults = getDefaultProperties();
} catch (IOException ioe) {
throw new PSQLException(GT.tr("Error loading default settings from driverconfig.properties"),
PSQLState.UNEXPECTED_ERROR, ioe);
}
// override defaults with provided properties
//在这里就是用扩大权限的properties初始化当前方法的properties
Properties props = new Properties(defaults);
if (info != null) {
//将properties中的键名存入set容器中,这里存入的是user和password两个键名
Set<String> e = info.stringPropertyNames();
for (String propName : e) {
//根据键值获取info的value,即获取用户名和密码
String propValue = info.getProperty(propName);
if (propValue == null) {
throw new PSQLException(
GT.tr("Properties for the driver contains a non-string value for the key ")
+ propName,
PSQLState.UNEXPECTED_ERROR);
}
//将获取的键值对,即用户名密码从info这个properties中取出放入当前方法的properties中
props.setProperty(propName, propValue);,
}
}
/**************************************************************************************/
// parse URL and add more properties
//通过pareURL方法判断url格式并解析url并将解析出的地址端口号以及其他参数信息放入props中
//如果url格式错误,则返回null
if ((props = parseURL(url, props)) == null) {
LOGGER.log(Level.SEVERE, "Error in url: {0}", url);
return null;
}
try {
// Setup java.util.logging.Logger using connection properties.
//用properties中的属性,建日志
setupLoggerFromProperties(props);
LOGGER.log(Level.FINE, "Connecting with URL: {0}", url);
// Enforce login timeout, if specified, by running the connection
// attempt in a separate thread. If we hit the timeout without the
// connection completing, we abandon the connection attempt in
// the calling thread, but the separate thread will keep trying.
// Eventually, the separate thread will either fail or complete
// the connection; at that point we clean up the connection if
// we managed to establish one after all. See ConnectThread for
// more details.
//获取loginTimeout属性值,即等待创建连接时间,如果未设置则返回0毫秒
long timeout = timeout(props);
//如果为0则直接返回创建的连接
if (timeout <= 0) {
return makeConnection(url, props);
}
//如果等待连接时间不为0,则创建一个线程等待连接
ConnectThread ct = new ConnectThread(url, props);
Thread thread = new Thread(ct, "PostgreSQL JDBC driver connection thread");
//设置为守护线程,默认的线程都为用户线程,守护线程优先级较低,没有用户线程,都是守护线程,那么JVM结束
thread.setDaemon(true); // Don't prevent the VM from shutting down
thread.start();
return ct.getResult(timeout);
} catch (PSQLException ex1) {
LOGGER.log(Level.SEVERE, "Connection error: ", ex1);
// re-throw the exception, otherwise it will be caught next, and a
// org.postgresql.unusual error will be returned instead.
throw ex1;
} catch (java.security.AccessControlException ace) {
throw new PSQLException(
GT.tr(
"Your security policy has prevented the connection from being attempted. You probably need to grant the connect java.net.SocketPermission to the database server host and port that you wish to connect to."),
PSQLState.UNEXPECTED_ERROR, ace);
} catch (Exception ex2) {
LOGGER.log(Level.SEVERE, "Unexpected connection error: ", ex2);
throw new PSQLException(
GT.tr(
"Something unusual has occurred to cause the driver to fail. Please report this exception."),
PSQLState.UNEXPECTED_ERROR, ex2);
}
}
parseURL方法主要用于解析url
org.postgresql.Driver.java
public static Properties parseURL(String url, Properties defaults) {
Properties urlProps = new Properties(defaults);
String l_urlServer = url;
String l_urlArgs = "";
int l_qPos = url.indexOf('?');//获取'?'第一次出现的位置,若未出现则返回-1
//如果有'?'则说明开发者在url还带了其他参数中,那么就把协议与参数信息截取出来分别存放到l_urlServer和l_uerArgs中
if (l_qPos != -1) {
l_urlServer = url.substring(0, l_qPos);
l_urlArgs = url.substring(l_qPos + 1);
}
//判断协议头
if (!l_urlServer.startsWith("jdbc:postgresql:")) {
return null;
}
//截取未判断的部分以继续完成接下来的判断,此时变量为可能为【//localhost:5432/test】
l_urlServer = l_urlServer.substring("jdbc:postgresql:".length());
//如果为【//localhost:5432/test】则开头部分为//,若url格式为jdbc:postgresql:/则截取为【/】,若url格式为jdbc:postgresql:database,则截取为【datavase】
if (l_urlServer.startsWith("//")) {
//则继续截取为localhost:5432/test
l_urlServer = l_urlServer.substring(2);
//这里应该是通过有没有/来判断url是否包含了数据库信息,如果没有/则返回null,那么Driver.getConnection()就返回null
int slash = l_urlServer.indexOf('/');
if (slash == -1) {
return null;
}
//先在localhost:5432/test中截取出数据库名即test,然后通过URLDecoder.decode解码,
//URLDecoder.decode方法是为了转换空格等特殊字符,如String path2 = URLDecoder.decode(url.getPath(),”gbk”)
//在JDK8中该方法已不推荐使用
//将获取的数据库名放入properties中
urlProps.setProperty("PGDBNAME", URLDecoder.decode(l_urlServer.substring(slash + 1)));
//截取地址与端口,并通过split方法分成数组,如jdbc:postgresql://host1:port1,host2:port2/database
String[] addresses = l_urlServer.substring(0, slash).split(",");
StringBuilder hosts = new StringBuilder();
StringBuilder ports = new StringBuilder();
for (String address : addresses) {
//获得localhost:5432中':'最后出现的位置
int portIdx = address.lastIndexOf(':');
//判断url格式是否正确,这里的']'是为了判断ipv6地址如jdbc:postgresql://[::1]:5740/accounting
if (portIdx != -1 && address.lastIndexOf(']') < portIdx) {
//截取端口号放入portStr中
String portStr = address.substring(portIdx + 1);
//未知操作,没有找到相关信息
try {
// squid:S2201 The return value of "parseInt" must be used.
// The side effect is NumberFormatException, thus ignore sonar error here
Integer.parseInt(portStr); //NOSONAR
} catch (NumberFormatException ex) {
return null;
}
//将获取的端口号和地址分别存入字符串并在下面用','分割
ports.append(portStr);
hosts.append(address.subSequence(0, portIdx));
} else {
//如果没有找到':'则说明url中并没有端口号,那么将address中的地址存放如hosts字符串,端口号字符串中存放配置默认端口号
ports.append("/*$mvn.project.property.template.default.pg.port$*/");
hosts.append(address);
}
ports.append(',');
hosts.append(',');
}
//设置stringbuffer的长度为现在长度-1,用于截掉末尾的','
ports.setLength(ports.length() - 1);
hosts.setLength(hosts.length() - 1);
//将得到的地址和端口号存放入properties中
urlProps.setProperty("PGPORT", ports.toString());
urlProps.setProperty("PGHOST", hosts.toString());
} else {
//如果url格式是jdbc:postgresql:database或者jdbc:postgresql:/
//则判断若properties也为空,则使用默认端口号和本地地址,与url中填写的数据库
if (defaults == null || !defaults.containsKey("PGPORT")) {
urlProps.setProperty("PGPORT", "/*$mvn.project.property.template.default.pg.port$*/");
}
if (defaults == null || !defaults.containsKey("PGHOST")) {
urlProps.setProperty("PGHOST", "localhost");
}
if (defaults == null || !defaults.containsKey("PGDBNAME")) {
urlProps.setProperty("PGDBNAME", URLDecoder.decode(l_urlServer));
}
}
// 解析url的参数,并将解析出的键值对放入urlProps中
String[] args = l_urlArgs.split("&");
for (String token : args) {
if (token.isEmpty()) {
continue;
}
int l_pos = token.indexOf('=');
if (l_pos == -1) {
urlProps.setProperty(token, "");
} else {
urlProps.setProperty(token.substring(0, l_pos), URLDecoder.decode(token.substring(l_pos + 1)));
}
}
//将解析出完成的properties返回
return urlProps;
}
connect方法中调用到此方法得到loginTimeout属性值
org.postgresql.Driver.java
private static long timeout(Properties props) {
//调用枚举类,获得loginTimeout值即设置的等待连接时间属性,如果开发者没有在properties中规定该属性
//则返回默认值0,该默认值在枚举类中定义
String timeout = PGProperty.LOGIN_TIMEOUT.get(props);
if (timeout != null) {
try {
//返回long类型的毫秒数
return (long) (Float.parseFloat(timeout) * 1000);
} catch (NumberFormatException e) {
LOGGER.log(Level.WARNING, "Couldn't parse loginTimeout value: {0}", timeout);
}
}
/* * 如果timeout为null,则调用DriverManager的方法 * 该方法单纯将成员变量private static volatile int loginTimeout = 0返回 * 如果未使用setLoginTimeout方法设置该值则返回默认值0 * 但是我并没有看懂在什么情况下会调用该方法,因为如果props为null或者props中没有loginTimeout属性,应该返回了枚举类中默认的0值 * 而如果抛出异常也不会执行此处的return */
return (long) DriverManager.getLoginTimeout() * 1000;
}
如果connect方法中得到的等待连接时间不为0那么将建立一个线程用来等待获取连接
org.postgresql.Driver.java
private static class ConnectThread implements Runnable {
ConnectThread(String url, Properties props) {
this.url = url;
this.props = props;
}
public void run() {
Connection conn;
Throwable error;
//error最终会赋值给该类的私有异常类型变量resultException
try {
//通过makeConnection获取连接,并赋值error为null
conn = makeConnection(url, props);
error = null;
} catch (Throwable t) {
//如果出现运行异常则赋值error为t并将从conn为null
conn = null;
error = t;
}
synchronized (this) {
//这个布尔值未初始化,其赋值在getResult方法中
//在getResult方法的线程中进行判断,如果等待时间超时,或在执行wait()方法时出现异常,则将abandoned赋值为true
if (abandoned) {
//关闭连接
if (conn != null) {
try {
conn.close();
} catch (SQLException e) {
}
}
} else {
//Connection result,Throwable resultException;为ConnectThread类的私有成员变量
//如果在允许等待时间内获取到了连接,则将连接引用到result,
//同时将resultException异常变量引用到上面的error异常变量,该异常变量用在getResult方法中进行判断
result = conn;
resultException = error;
//执行到此调用notify方法唤醒等待的getResult方法所在的线程
notify();
}
}
}
public Connection getResult(long timeout) throws SQLException {
//该变量记录运行时间,用于判断连接是否超时
long expiry = System.currentTimeMillis() + timeout;
/* * 在这个同步代码块里面检查连接是否获取与连接是否超时 * 连接的获取在线程run()方法中进行,主线程执行start方法后run方法与getResult方法进入多线程执行场景 */
synchronized (this) {
//循环检查连接是否获取,若获取则返回Connection result
while (true) {
if (result != null) {
return result;
}
//Throwable resultException 变量在run()线程中定义
//如果resultException为null则说明成功获取到了连接或尚未获取连接,未初始化的类默认为null
//如果不为null则说明在获取连接过程中出现了运行异常Throwable
if (resultException != null) {
//判断是否是SQLException异常,如果是抛出此异常
if (resultException instanceof SQLException) {
resultException.fillInStackTrace();
throw (SQLException) resultException;
} else {
//如果不是SQLException异常,则抛出自定义异常
throw new PSQLException(
GT.tr(
"Something unusual has occurred to cause the driver to fail. Please report this exception."),
PSQLState.UNEXPECTED_ERROR, resultException);
}
}
//下面的abandoned变量在出现异常或超时时定义为true,run方法中对其进行判断,如果abandoned为true,则终止尝试连接
//计算得到剩余时间判断是否超时
long delay = expiry - System.currentTimeMillis();
//如果超时则abandoned赋值为true并抛出异常
if (delay <= 0) {
abandoned = true;
throw new PSQLException(GT.tr("Connection attempt timed out."),
PSQLState.CONNECTION_UNABLE_TO_CONNECT);
}
try {
//该线程进入等待状态,时间是允许连接时间的剩余时间,期间可能会被run方法所在线程的notify方法唤醒
wait(delay);
} catch (InterruptedException ie) {
// reset the interrupt flag
//出现异常则调用Thread.currentThread().interrupt()中断线程并将abandoned标记为true
Thread.currentThread().interrupt();
abandoned = true;
// throw an unchecked exception which will hopefully not be ignored by the calling code
throw new RuntimeException(GT.tr("Interrupted while attempting to connect."));
}
}
}
}
private final String url;
private final Properties props;
private Connection result;
private Throwable resultException;
private boolean abandoned;
}
connect方法中调用到的makeConnection方法用于返回连接对象,其中还调用了hostSpecs/user/database方法
这些方法都是简单的对properties中属性的处理,到此为止,获取连接的过程全部结束,
真正的与数据库的直接的连接交互在厂商的PgConnection类中完成,该类实际实现了Connection接口
org.postgresql.Driver.java
private static Connection makeConnection(String url, Properties props) throws SQLException {
return new PgConnection(hostSpecs(props), user(props), database(props), props, url);
}
private static HostSpec[] hostSpecs(Properties props) {
String[] hosts = props.getProperty("PGHOST").split(",");
String[] ports = props.getProperty("PGPORT").split(",");
HostSpec[] hostSpecs = new HostSpec[hosts.length];
for (int i = 0; i < hostSpecs.length; ++i) {
hostSpecs[i] = new HostSpec(hosts[i], Integer.parseInt(ports[i]));
}
return hostSpecs;
}
private static String user(Properties props) {
return props.getProperty("user", "");
}
private static String database(Properties props) {
return props.getProperty("PGDBNAME", "");
}
Driver类中自己定义了一个properties,并且在getDefaultProperties方法中为其赋权,简单来说就是保证其有足够权限读取资源
AccessController.doPrivileged是一个在AccessController类中的静态方法,允许在一个类实例中的代码通知这个AccessController:
它的代码主体是享受”privileged(特权的)”,它单独负责对它的可得的资源的访问请求,而不管这个请求是由什么代码所引发的。
这就是说,一个调用者在调用doPrivileged方法时,可被标识为 “特权”。
在做访问控制决策时,如果checkPermission方法遇到一个通过doPrivileged调用而被表示为“特权”的调用者,
并且没有上下文自变量,checkPermission方法则将终止检查。
如果那个调用者的域具有特定的许可,则不做进一步检查,checkPermission安静地返回,表示那个访问请求是被允许的;
如果那个域没有特定的许可,则象通常一样,一个异常被抛出。
private Properties defaultProperties;
private synchronized Properties getDefaultProperties() throws IOException {
if (defaultProperties != null) {
return defaultProperties;
}
// Make sure we load properties with the maximum possible privileges.
try {
defaultProperties =
AccessController.doPrivileged(new PrivilegedExceptionAction<Properties>() {
public Properties run() throws IOException {
return loadDefaultProperties();
}
});
} catch (PrivilegedActionException e) {
throw (IOException) e.getException();
}
return defaultProperties;
}
这个枚举类在这里的用处主要是返回loginTimeout值
org.postgresql.PGProperty
public enum PGProperty {
//LOGIN_TIMEOUT三个值分别为键名,默认值和描述,他是设置等待建立连接数据库时间的,默认为0
LOGIN_TIMEOUT("loginTimeout", "0",
"Specify how long to wait for establishment of a database connection."),
//规定枚举中的成员变量
private String _name;
private String _defaultValue;
private boolean _required;
private String _description;
private String[] _choices;
//构造方法
PGProperty(String name, String defaultValue, String description) {
this(name, defaultValue, description, false);
}
PGProperty(String name, String defaultValue, String description, boolean required) {
this(name, defaultValue, description, required, (String[]) null);
}
PGProperty(String name, String defaultValue, String description, boolean required,
String... choices) {
_name = name;
_defaultValue = defaultValue;
_required = required;
_description = description;
_choices = choices;
}
//调用getProperty方法并将键名和默认值放入,如果键名在properties中存在则返回value值,如果键名为null则返回默认值
public String get(Properties properties) {
return properties.getProperty(_name, _defaultValue);
}
今天的文章DriverManager.getConnection()方法涉及到的源码详解分享到此就结束了,感谢您的阅读,如果确实帮到您,您可以动动手指转发给其他人。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/28255.html