MyBatis 源碼分析 - 內置數據源

1.簡介

本篇文章將向大家介紹 MyBatis 內置數據源的實現邏輯。搞懂這些數據源的實現,可使大家對數據源有更深入的認識。同時在配置這些數據源時,也會更清楚每種屬性的意義和用途。因此,如果大家想知其然,也知其所以然。那麼接下來就讓我們一起去探索 MyBatis 內置數據源的源碼吧。

MyBatis 支持三種數據源配置,分別爲 UNPOOLED、POOLED 和 JNDI。並提供了兩種數據源實現,分別是 UnpooledDataSource 和 PooledDataSource。在三種數據源配置中,UNPOOLED 和 POOLED 是我們最常用的兩種配置。至於 JNDI,MyBatis 提供這種數據源的目的是爲了讓其能夠運行在 EJB 或應用服務器等容器中,這一點官方文檔中有所說明。由於 JNDI 數據源在日常開發中使用甚少,因此,本篇文章不打算分析 JNDI 數據源相關實現。大家若有興趣,可自行分析。接下來,本文將重點分析 UNPOOLED 和 POOLED 兩種數據源。其他的就不多說了,進入正題吧。

2.內置數據源初始化過程

在詳細分析 UnpooledDataSource 和 PooledDataSource 兩種數據源實現之前,我們先來了解一下數據源的配置與初始化過程。現在看數據源是如何配置的,如下:

<dataSource type="UNPOOLED|POOLED">
    <property name="driver" value="com.mysql.cj.jdbc.Driver"/>
    <property name="url" value="jdbc:mysql..."/>
    <property name="username" value="root"/>
    <property name="password" value="1234"/>
</dataSource>

數據源的配置是內嵌在 <environment> 節點中的,MyBatis 在解析 <environment> 節點時,會一併解析數據源的配置。MyBatis 會根據具體的配置信息,爲不同的數據源創建相應工廠類,通過工廠類即可創建數據源實例。關於數據源配置的解析以及數據源工廠類的創建過程,我在 MyBatis 配置文件解析過程一文中分析過,這裏就不贅述了。下面我們來看一下數據源工廠類的實現邏輯。

public class UnpooledDataSourceFactory implements DataSourceFactory {
    
    private static final String DRIVER_PROPERTY_PREFIX = "driver.";
    private static final int DRIVER_PROPERTY_PREFIX_LENGTH = DRIVER_PROPERTY_PREFIX.length();

    protected DataSource dataSource;

    public UnpooledDataSourceFactory() {
        // 創建 UnpooledDataSource 對象
        this.dataSource = new UnpooledDataSource();
    }

    @Override
    public void setProperties(Properties properties) {
        Properties driverProperties = new Properties();
        // 爲 dataSource 創建元信息對象
        MetaObject metaDataSource = SystemMetaObject.forObject(dataSource);
        // 遍歷 properties 鍵列表,properties 由配置文件解析器傳入
        for (Object key : properties.keySet()) {
            String propertyName = (String) key;
            // 檢測 propertyName 是否以 "driver." 開頭
            if (propertyName.startsWith(DRIVER_PROPERTY_PREFIX)) {
                String value = properties.getProperty(propertyName);
                // 存儲配置信息到 driverProperties 中
                driverProperties.setProperty(propertyName.substring(DRIVER_PROPERTY_PREFIX_LENGTH), value);
            } else if (metaDataSource.hasSetter(propertyName)) {
                String value = (String) properties.get(propertyName);
                // 按需轉換 value 類型
                Object convertedValue = convertValue(metaDataSource, propertyName, value);
                // 設置轉換後的值到 UnpooledDataSourceFactory 指定屬性中
                metaDataSource.setValue(propertyName, convertedValue);
            } else {
                throw new DataSourceException("Unknown DataSource property: " + propertyName);
            }
        }
        if (driverProperties.size() > 0) {
            // 設置 driverProperties 到 UnpooledDataSourceFactory 的 driverProperties 屬性中
            metaDataSource.setValue("driverProperties", driverProperties);
        }
    }
    
    private Object convertValue(MetaObject metaDataSource, String propertyName, String value) {
        Object convertedValue = value;
        // 獲取屬性對應的 setter 方法的參數類型
        Class<?> targetType = metaDataSource.getSetterType(propertyName);
        // 按照 setter 方法的參數類型進行類型轉換
        if (targetType == Integer.class || targetType == int.class) {
            convertedValue = Integer.valueOf(value);
        } else if (targetType == Long.class || targetType == long.class) {
            convertedValue = Long.valueOf(value);
        } else if (targetType == Boolean.class || targetType == boolean.class) {
            convertedValue = Boolean.valueOf(value);
        }
        return convertedValue;
    }

    @Override
    public DataSource getDataSource() {
        return dataSource;
    }
}

以上是 UnpooledDataSourceFactory 的源碼分析,除了 setProperties 方法稍複雜一點,其他的都比較簡單,就不多說了。下面看看 PooledDataSourceFactory 的源碼。

public class PooledDataSourceFactory extends UnpooledDataSourceFactory {

    public PooledDataSourceFactory() {
        // 創建 PooledDataSource
        this.dataSource = new PooledDataSource();
    }
}

以上就是 PooledDataSource 類的所有源碼,PooledDataSourceFactory 繼承自 UnpooledDataSourceFactory,複用了父類的邏輯,因此它的實現很簡單。

關於兩種數據源的創建過程就先分析到這,接下來,我們去探究一下兩種數據源是怎樣實現的。

3.UnpooledDataSource

UnpooledDataSource,從名稱上即可知道,該種數據源不具有池化特性。該種數據源每次會返回一個新的數據庫連接,而非複用舊的連接。由於 UnpooledDataSource 無需提供連接池功能,因此它的實現非常簡單。核心的方法有三個,分別如下:

  1. initializeDriver - 初始化數據庫驅動
  2. doGetConnection - 獲取數據連接
  3. configureConnection - 配置數據庫連接

下面我將按照順序分節對相關方法進行分析,由於 configureConnection 方法比較簡單,因此我把它和 doGetConnection 放在一節中進行分析。下面先來分析 initializeDriver 方法。

3.1 初始化數據庫驅動

回顧我們一開始學習使用 JDBC 訪問數據庫時的情景,在執行 SQL 之前,通常都是先獲取數據庫連接。一般步驟都是加載數據庫驅動,然後通過 DriverManager 獲取數據庫連接。UnpooledDataSource 也是使用 JDBC 訪問數據庫的,因此它獲取數據庫連接的過程也大致如此,只不過會稍有不同。下面我們一起來看一下。

// -☆- UnpooledDataSource
private synchronized void initializeDriver() throws SQLException {
    // 檢測緩存中是否包含了與 driver 對應的驅動實例
    if (!registeredDrivers.containsKey(driver)) {
        Class<?> driverType;
        try {
            // 加載驅動類型
            if (driverClassLoader != null) {
                // 使用 driverClassLoader 加載驅動
                driverType = Class.forName(driver, true, driverClassLoader);
            } else {
                // 通過其他 ClassLoader 加載驅動
                driverType = Resources.classForName(driver);
            }

            // 通過反射創建驅動實例
            Driver driverInstance = (Driver) driverType.newInstance();
            /*
             * 註冊驅動,注意這裏是將 Driver 代理類 DriverProxy 對象註冊到 DriverManager 中的,
             * 而非 Driver 對象本身。DriverProxy 中並沒什麼特別的邏輯,就不分析。
             */
            DriverManager.registerDriver(new DriverProxy(driverInstance));
            // 緩存驅動類名和實例
            registeredDrivers.put(driver, driverInstance);
        } catch (Exception e) {
            throw new SQLException("Error setting driver on UnpooledDataSource. Cause: " + e);
        }
    }
}

如上,initializeDriver 方法主要包含三步操作,分別如下:

  1. 加載驅動
  2. 通過反射創建驅動實例
  3. 註冊驅動實例

這三步都是都是常規操作,比較容易理解。上面代碼中出現了緩存相關的邏輯,這個是用於避免重複註冊驅動。因爲 initializeDriver 放阿飛並不是在 UnpooledDataSource 初始化時被調用的,而是在獲取數據庫連接時被調用的。因此這裏需要做個檢測,避免每次獲取數據庫連接時都重新註冊驅動。這個是一個比較小的點,大家看代碼時注意一下即可。下面看一下獲取數據庫連接的邏輯。

3.2 獲取數據庫連接

在使用 JDBC 時,我們都是通過 DriverManager 的接口方法獲取數據庫連接。本節所要分析的源碼也不例外,一起看一下吧。

// -☆- UnpooledDataSource
public Connection getConnection() throws SQLException {
    return doGetConnection(username, password);
}
    
private Connection doGetConnection(String username, String password) throws SQLException {
    Properties props = new Properties();
    if (driverProperties != null) {
        props.putAll(driverProperties);
    }
    if (username != null) {
        // 存儲 user 配置
        props.setProperty("user", username);
    }
    if (password != null) {
        // 存儲 password 配置
        props.setProperty("password", password);
    }
    // 調用重載方法
    return doGetConnection(props);
}

private Connection doGetConnection(Properties properties) throws SQLException {
    // 初始化驅動
    initializeDriver();
    // 獲取連接
    Connection connection = DriverManager.getConnection(url, properties);
    // 配置連接,包括自動提交以及事務等級
    configureConnection(connection);
    return connection;
}

private void configureConnection(Connection conn) throws SQLException {
    if (autoCommit != null && autoCommit != conn.getAutoCommit()) {
        // 設置自動提交
        conn.setAutoCommit(autoCommit);
    }
    if (defaultTransactionIsolationLevel != null) {
        // 設置事務隔離級別
        conn.setTransactionIsolation(defaultTransactionIsolationLevel);
    }
}

如上,上面方法將一些配置信息放入到 Properties 對象中,然後將數據庫連接和 Properties 對象傳給 DriverManager 的 getConnection 方法即可獲取到數據庫連接。

好了,關於 UnpooledDataSource 就先說到這。下面分析一下 PooledDataSource,它的實現要複雜一些。

4.PooledDataSource

PooledDataSource 內部實現了連接池功能,用於複用數據庫連接。因此,從效率上來說,PooledDataSource 要高於 UnpooledDataSource。PooledDataSource 需要藉助一些輔助類幫助它完成連接池的功能,所以接下來,我們先來認識一下相關的輔助類。

4.1 輔助類介紹

PooledDataSource 需要藉助兩個輔助類幫其完成功能,這兩個輔助類分別是 PoolState 和 PooledConnection。PoolState 用於記錄連接池運行時的狀態,比如連接獲取次數,無效連接數量等。同時 PoolState 內部定義了兩個 PooledConnection 集合,用於存儲空閒連接和活躍連接。PooledConnection 內部定義了一個 Connection 類型的變量,用於指向真實的數據庫連接。以及一個 Connection 的代理類,用於對部分方法調用進行攔截。至於爲什麼要攔截,隨後將進行分析。除此之外,PooledConnection 內部也定義了一些字段,用於記錄數據庫連接的一些運行時狀態。接下來,我們來看一下 PooledConnection 的定義。

class PooledConnection implements InvocationHandler {

    private static final String CLOSE = "close";
    private static final Class<?>[] IFACES = new Class<?>[]{Connection.class};

    private final int hashCode;
    private final PooledDataSource dataSource;
    // 真實的數據庫連接
    private final Connection realConnection;
    // 數據庫連接代理
    private final Connection proxyConnection;
    
    // 從連接池中取出連接時的時間戳
    private long checkoutTimestamp;
    // 數據庫連接創建時間
    private long createdTimestamp;
    // 數據庫連接最後使用時間
    private long lastUsedTimestamp;
    // connectionTypeCode = (url + username + password).hashCode()
    private int connectionTypeCode;
    // 表示連接是否有效
    private boolean valid;

    public PooledConnection(Connection connection, PooledDataSource dataSource) {
        this.hashCode = connection.hashCode();
        this.realConnection = connection;
        this.dataSource = dataSource;
        this.createdTimestamp = System.currentTimeMillis();
        this.lastUsedTimestamp = System.currentTimeMillis();
        this.valid = true;
        // 創建 Connection 的代理類對象
        this.proxyConnection = (Connection) Proxy.newProxyInstance(Connection.class.getClassLoader(), IFACES, this);
    }
    
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {...}
    
    // 省略部分代碼
}

下面再來看看 PoolState 的定義。

public class PoolState {

    protected PooledDataSource dataSource;

    // 空閒連接列表
    protected final List<PooledConnection> idleConnections = new ArrayList<PooledConnection>();
    // 活躍連接列表
    protected final List<PooledConnection> activeConnections = new ArrayList<PooledConnection>();
    // 從連接池中獲取連接的次數
    protected long requestCount = 0;
    // 請求連接總耗時(單位:毫秒)
    protected long accumulatedRequestTime = 0;
    // 連接執行時間總耗時
    protected long accumulatedCheckoutTime = 0;
    // 執行時間超時的連接數
    protected long claimedOverdueConnectionCount = 0;
    // 超時時間累加值
    protected long accumulatedCheckoutTimeOfOverdueConnections = 0;
    // 等待時間累加值
    protected long accumulatedWaitTime = 0;
    // 等待次數
    protected long hadToWaitCount = 0;
    // 無效連接數
    protected long badConnectionCount = 0;
}

上面對 PooledConnection 和 PoolState 的定義進行了一些註釋,這兩個類中有很多字段用來記錄運行時狀態。但在這些字段並非核心,因此大家知道每個字段的用途就行了。關於這兩個輔助類的介紹就先到這

4.2 獲取連接

前面已經說過,PooledDataSource 會將用過的連接進行回收,以便可以複用連接。因此從 PooledDataSource 獲取連接時,如果空閒鏈接列表裏有連接時,可直接取用。那如果沒有空閒連接怎麼辦呢?此時有兩種解決辦法,要麼創建新連接,要麼等待其他連接完成任務。具體怎麼做,需視情況而定。下面我們深入到源碼中一探究竟。

public Connection getConnection() throws SQLException {
    // 返回 Connection 的代理對象
    return popConnection(dataSource.getUsername(), dataSource.getPassword()).getProxyConnection();
}

private PooledConnection popConnection(String username, String password) throws SQLException {
    boolean countedWait = false;
    PooledConnection conn = null;
    long t = System.currentTimeMillis();
    int localBadConnectionCount = 0;

    while (conn == null) {
        synchronized (state) {
            // 檢測空閒連接集合(idleConnections)是否爲空
            if (!state.idleConnections.isEmpty()) {
                // idleConnections 不爲空,表示有空閒連接可以使用
                conn = state.idleConnections.remove(0);
            } else {
                /*
                 * 暫無空閒連接可用,但如果活躍連接數還未超出限制
                 *(poolMaximumActiveConnections),則可創建新的連接
                 */
                if (state.activeConnections.size() < poolMaximumActiveConnections) {
                    // 創建新連接
                    conn = new PooledConnection(dataSource.getConnection(), this);
                    
                } else {    // 連接池已滿,不能創建新連接
                    // 取出運行時間最長的連接
                    PooledConnection oldestActiveConnection = state.activeConnections.get(0);
                    // 獲取運行時長
                    long longestCheckoutTime = oldestActiveConnection.getCheckoutTime();
                    // 檢測運行時長是否超出限制,即超時
                    if (longestCheckoutTime > poolMaximumCheckoutTime) {
                        // 累加超時相關的統計字段
                        state.claimedOverdueConnectionCount++;
                        state.accumulatedCheckoutTimeOfOverdueConnections += longestCheckoutTime;
                        state.accumulatedCheckoutTime += longestCheckoutTime;

                        // 從活躍連接集合中移除超時連接
                        state.activeConnections.remove(oldestActiveConnection);
                        // 若連接未設置自動提交,此處進行回滾操作
                        if (!oldestActiveConnection.getRealConnection().getAutoCommit()) {
                            try {
                                oldestActiveConnection.getRealConnection().rollback();
                            } catch (SQLException e) {...}
                        }
                        /*
                         * 創建一個新的 PooledConnection,注意,
                         * 此處複用 oldestActiveConnection 的 realConnection 變量
                         */
                        conn = new PooledConnection(oldestActiveConnection.getRealConnection(), this);
                        /*
                         * 複用 oldestActiveConnection 的一些信息,注意 PooledConnection 中的 
                         * createdTimestamp 用於記錄 Connection 的創建時間,而非 PooledConnection 
                         * 的創建時間。所以這裏要複用原連接的時間信息。
                         */
                        conn.setCreatedTimestamp(oldestActiveConnection.getCreatedTimestamp());
                        conn.setLastUsedTimestamp(oldestActiveConnection.getLastUsedTimestamp());

                        // 設置連接爲無效狀態
                        oldestActiveConnection.invalidate();
                        
                    } else {    // 運行時間最長的連接並未超時
                        try {
                            if (!countedWait) {
                                state.hadToWaitCount++;
                                countedWait = true;
                            }
                            long wt = System.currentTimeMillis();
                            // 當前線程進入等待狀態
                            state.wait(poolTimeToWait);
                            state.accumulatedWaitTime += System.currentTimeMillis() - wt;
                        } catch (InterruptedException e) {
                            break;
                        }
                    }
                }
            }
            if (conn != null) {
                /*
                 * 檢測連接是否有效,isValid 方法除了會檢測 valid 是否爲 true,
                 * 還會通過 PooledConnection 的 pingConnection 方法執行 SQL 語句,
                 * 檢測連接是否可用。pingConnection 方法的邏輯不復雜,大家可以自行分析。
                 * 另外,官方文檔在介紹 POOLED 類型數據源時,也介紹了連接有效性檢測方面的
                 * 屬性,有三個:poolPingQuery,poolPingEnabled 和 
                 * poolPingConnectionsNotUsedFor。關於這三個屬性,大家可以查閱官方文檔
                 */
                if (conn.isValid()) {
                    if (!conn.getRealConnection().getAutoCommit()) {
                        // 進行回滾操作
                        conn.getRealConnection().rollback();
                    }
                    conn.setConnectionTypeCode(assembleConnectionTypeCode(dataSource.getUrl(), username, password));
                    // 設置統計字段
                    conn.setCheckoutTimestamp(System.currentTimeMillis());
                    conn.setLastUsedTimestamp(System.currentTimeMillis());
                    state.activeConnections.add(conn);
                    state.requestCount++;
                    state.accumulatedRequestTime += System.currentTimeMillis() - t;
                } else {
                    // 連接無效,此時累加無效連接相關的統計字段
                    state.badConnectionCount++;
                    localBadConnectionCount++;
                    conn = null;
                    if (localBadConnectionCount > (poolMaximumIdleConnections
                        + poolMaximumLocalBadConnectionTolerance)) {
                        throw new SQLException(...);
                    }
                }
            }
        }

    }

    if (conn == null) {
        throw new SQLException(...);
    }

    return conn;
}

上面代碼冗長,過程比較複雜,下面把代碼邏輯梳理一下。從連接池中獲取連接首先會遇到兩種情況:

  1. 連接池中有空閒連接
  2. 連接池中無空閒連接

對於第一種情況,處理措施就很簡單了,把連接取出返回即可。對於第二種情況,則要進行細分,會有如下的情況。

  1. 活躍連接數沒有超出最大活躍連接數
  2. 活躍連接數超出最大活躍連接數

對於上面兩種情況,第一種情況比較好處理,直接創建新的連接即可。至於第二種情況,需要再次進行細分。

  1. 活躍連接的運行時間超出限制,即超時了
  2. 活躍連接未超時

對於第一種情況,我們直接將超時連接強行中斷,並進行回滾,然後複用部分字段重新創建 PooledConnection 即可。對於第二種情況,目前沒有更好的處理方式了,只能等待了。下面用一段僞代碼演示各種情況及相應的處理措施,如下:

if (連接池中有空閒連接) {
    1. 將連接從空閒連接集合中移除
} else {
    if (活躍連接數未超出限制) {
        1. 創建新連接
    } else {
        1. 從活躍連接集合中取出第一個元素
        2. 獲取連接運行時長
        
        if (連接超時) {
            1. 將連接從活躍集合中移除
            2. 複用原連接的成員變量,並創建新的 PooledConnection 對象
        } else {
            1. 線程進入等待狀態
            2. 線程被喚醒後,重新執行以上邏輯
        }
    }
}

1. 將連接添加到活躍連接集合中
2. 返回連接

最後用一個流程圖大致描繪 popConnection 的邏輯,如下:

4.3 回收連接

相比於獲取連接,回收連接的邏輯要簡單的多。回收連接成功與否只取決於空閒連接集合的狀態,所需處理情況很少,因此比較簡單。下面看一下相關的邏輯。

protected void pushConnection(PooledConnection conn) throws SQLException {
    synchronized (state) {
        // 從活躍連接池中移除連接
        state.activeConnections.remove(conn);
        if (conn.isValid()) {
            // 空閒連接集合未滿
            if (state.idleConnections.size() < poolMaximumIdleConnections
                && conn.getConnectionTypeCode() == expectedConnectionTypeCode) {
                state.accumulatedCheckoutTime += conn.getCheckoutTime();

                // 回滾未提交的事務
                if (!conn.getRealConnection().getAutoCommit()) {
                    conn.getRealConnection().rollback();
                }

                // 創建新的 PooledConnection
                PooledConnection newConn = new PooledConnection(conn.getRealConnection(), this);
                state.idleConnections.add(newConn);
                // 複用時間信息
                newConn.setCreatedTimestamp(conn.getCreatedTimestamp());
                newConn.setLastUsedTimestamp(conn.getLastUsedTimestamp());

                // 將原連接置爲無效狀態
                conn.invalidate();

                // 通知等待的線程
                state.notifyAll();
                
            } else {    // 空閒連接集合已滿
                state.accumulatedCheckoutTime += conn.getCheckoutTime();
                // 回滾未提交的事務
                if (!conn.getRealConnection().getAutoCommit()) {
                    conn.getRealConnection().rollback();
                }

                // 關閉數據庫連接
                conn.getRealConnection().close();
                conn.invalidate();
            }
        } else {
            state.badConnectionCount++;
        }
    }
}

上面代碼首先將連接從活躍連接集合中移除,然後再根據空閒集合是否有空閒空間進行後續處理。如果空閒集合未滿,此時複用原連接的字段信息創建新的連接,並將其放入空閒集合中即可。若空閒集合已滿,此時無需回收連接,直接關閉即可。pushConnection 方法的邏輯並不複雜,就不多說了。

我們知道獲取連接的方法 popConnection 是由 getConnection 方法調用的,那回收連接的方法 pushConnection 是由誰調用的呢?答案是 PooledConnection 中的代理邏輯。相關代碼如下:

// -☆- PooledConnection
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    String methodName = method.getName();
    // 檢測 close 方法是否被調用,若被調用則攔截之
    if (CLOSE.hashCode() == methodName.hashCode() && CLOSE.equals(methodName)) {
        // 將回收連接中,而不是直接將連接關閉
        dataSource.pushConnection(this);
        return null;
    } else {
        try {
            if (!Object.class.equals(method.getDeclaringClass())) {
                checkConnection();
            }

            // 調用真實連接的目標方法
            return method.invoke(realConnection, args);
        } catch (Throwable t) {
            throw ExceptionUtil.unwrapThrowable(t);
        }
    }
}

在上一節中,getConnection 方法返回的是 Connection 代理對象,不知道大家有沒有注意到。代理對象中的方法被調用時,會被上面的代理邏輯所攔截。如果代理對象的 close 方法被調用,MyBatis 並不會直接調用真實連接的 close 方法關閉連接,而是調用 pushConnection 方法回收連接。同時會喚醒處於睡眠中的線程,使其恢復運行。整個過程並不複雜,就不多說了。

4.4 小節

本章分析了 PooledDataSource 的部分源碼及一些輔助類的源碼,除此之外,PooledDataSource 中還有部分源碼沒有分析,大家若有興趣,可自行分析。好了,關於 PooledDataSource 的分析就先到這。

5.總結

本篇文章對 MyBatis 兩種內置數據源進行了較爲詳細的分析,總的來說,這兩種數據源的源碼都不是很難理解。大家在閱讀源碼的過程中,首先應搞懂源碼的主要邏輯,然後再去分析一些邊邊角角的邏輯。不要一開始就陷入各種細節中,容易迷失方向。

好了,到此本文就結束了。若文章有錯誤不妥之處,希望大家指明。最後,感謝大家閱讀我的文章。

附錄:MyBatis 源碼分析系列文章列表

更新時間

標題

2018-07-16

MyBatis 源碼分析系列文章導讀

2018-07-20

MyBatis 源碼分析 - 配置文件解析過程

2018-07-30

MyBatis 源碼分析 - 映射文件解析過程

2018-08-17

MyBatis 源碼分析 - SQL 的執行過程

2018-08-19

MyBatis 源碼分析 - 內置數據源

本文在知識共享許可協議 4.0 下發布,轉載需在明顯位置處註明出處 作者:田小波 本文同步發佈在我的個人博客:https://www.tianxiaobo.com 本作品採用知識共享署名-非商業性使用-禁止演繹 4.0 國際許可協議進行許可。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章