1. 程式人生 > 其它 >通過原始碼理解手寫簡單版本MyBatis框架(十)

通過原始碼理解手寫簡單版本MyBatis框架(十)

一、需求分析

1.1專案需求

通過原始的JDBC程式碼來操作資料庫非常的麻煩,裡面存在著太多的重複程式碼和低下的開發效率,針對這種情況需要提供一個更加高效的持久層框架。

1.2 核心功能

首先來看下JDBC操作查詢的程式碼。

public class JdbcTest {

    public static void main(String[] args) {
        new JdbcTest().queryUser();
         new JdbcTest().addUser();
    }

    /**
     *
     * 通過JDBC查詢使用者資訊
     
*/ public void queryUser(){ Connection conn = null; Statement stmt = null; User user = new User(); try { // 註冊 JDBC 驅動 // Class.forName("com.mysql.cj.jdbc.Driver"); // 開啟連線 conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8&serverTimezone=UTC
", "root", "root"); // 執行查詢 stmt = conn.createStatement(); String sql = "SELECT id,user_name,real_name,password,age,d_id from t_user where id = 1"; ResultSet rs = stmt.executeQuery(sql); // 獲取結果集 while (rs.next()) { Integer id
= rs.getInt("id"); String userName = rs.getString("user_name"); String realName = rs.getString("real_name"); String password = rs.getString("password"); Integer did = rs.getInt("d_id"); user.setId(id); user.setUserName(userName); user.setRealName(realName); user.setPassword(password); user.setDId(did); System.out.println(user); } rs.close(); stmt.close(); conn.close(); } catch (SQLException se) { se.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } finally { try { if (stmt != null) stmt.close(); } catch (SQLException se2) { } try { if (conn != null) conn.close(); } catch (SQLException se) { se.printStackTrace(); } } } /** * 通過JDBC實現新增使用者資訊的操作 */ public void addUser(){ Connection conn = null; Statement stmt = null; try { // 註冊 JDBC 驅動 // Class.forName("com.mysql.cj.jdbc.Driver"); // 開啟連線 conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8&serverTimezone=UTC", "root", "root"); // 執行查詢 stmt = conn.createStatement(); String sql = "INSERT INTO T_USER(user_name,real_name,password,age,d_id)values('ww','王五','111',22,1001)"; int i = stmt.executeUpdate(sql); System.out.println("影響的行數:" + i); stmt.close(); conn.close(); } catch (SQLException se) { se.printStackTrace(); } catch (Exception e) { e.printStackTrace(); } finally { try { if (stmt != null) stmt.close(); } catch (SQLException se2) { } try { if (conn != null) conn.close(); } catch (SQLException se) { se.printStackTrace(); } } } }

通過上面的程式碼,可以發現問題還是比較多的。

1.2.1 資源管理

它需要實現對連線資源的自動管理,也就是把建立Connection、建立Statement、關閉Connection、關閉Statement這些操作封裝到底層的物件中,不需要在應用層手動呼叫。

rs.close();
stmt.close();
conn.close();

1.2.2 SQL語句

在程式碼中我們直接將SQL語句和業務程式碼寫在了一起,耦合性太高了,我們需要把SQL語句抽離出來實現集中管理,開發人員不用在業務程式碼裡面寫SQL語句

1.2.3 結果集對映

在上面的程式碼中我們需要根據欄位取出值,然後把值設定到對應物件的屬性中,這個操作也是很繁瑣的,所以我們也希望框架能夠自動幫助我們實現結果集的轉換,也就是我們指定了對映規則之後,這個框架會自動幫我們把ResultSet對映成實體類物件。

            while (rs.next()) {
                Integer id = rs.getInt("id");
                String userName = rs.getString("user_name");
                String realName = rs.getString("real_name");
                String password = rs.getString("password");
                Integer did = rs.getInt("d_id");
                user.setId(id);
                user.setUserName(userName);
                user.setRealName(realName);
                user.setPassword(password);
                user.setDId(did);

                System.out.println(user);
            }

1.2.4 對外API

實現了上面的功能以後,這個框架需要提供一個API來給我們操作資料庫,這裡面定義了對資料庫的操作的常用的方法。

1.3 功能分解

專案的需求我們也已經清楚了,那麼我們應該要怎麼來解決這些問題呢?,我們先來分析下需要哪些核心物件

1.3.1 核心物件

1、存放參數和結果對映關係、存放SQL語句,我們需要定義一個配置類;
2、執行對資料庫的操作,處理引數和結果集的對映,建立和釋放資源,需要定義一個執行器;
3、有了這個執行器以後,我們不能直接呼叫它,而是定義一個給應用層使用的API,它可以根據SQL的id找到SQL語句,交給執行器執行;
4、如果由使用者直接使用id查詢SQL語句太麻煩了,乾脆把存放SQL的名稱空間定義成一個介面,把SQL的id定義成方法,這樣只要呼叫介面方法就可以找到要執行的SQL。剛好動態代理可以實現這個功能。這個時候需要引入一個代理類。
核心物件有了,第二個:分析一下這個框架操作資料庫的主要流程,先從單條查詢入手。

1.3.2 操作流程

1、定義配置類物件Configuration。裡面要存放SQL語句,還有查詢方法和結果對映的關係。
2、定義應用層的API SqlSession。在SqlSession裡面封裝增刪改查和操作事務的方法(selectOne())。
3、如果直接把Statement ID傳給SqlSession去執行SQL,會出現硬編碼,所以決定把SQL語句的標識設計成一個介面+方法名(Mapper介面),呼叫介面的方法就能找到SQL語句。
4、這個需要代理模式實現,所以要建立一個實現了InvocationHandler的觸發管理類MapperProxy。代理類在Configuration中通過JDK動態代理建立。
5、有了代理物件之後,呼叫介面方法,就是呼叫觸發管理器MapperProxy的invoke()方法。
6、代理物件的invoke()方法呼叫了SqlSession的selectOne()。
7、SqlSession只是一個API,還不是真正的SQL執行者,所以接下來會呼叫執行器Executor的query()方法。
8、執行器Executor的query()方法裡面就是對JDBC底層的Statement的封裝,最終實現對資料庫的操作,和結果的返回。

二、程式碼實現

2.1 SqlSession

針對不同使用者的請求操作可以通過SqlSession來處理,在SqlSession中可以提供基礎的操作API,我定義的名稱為GhySqlSession,暫時不需要考慮其他的實現,所以先不用建立介面,直接寫類。

根據剛才總結的流程圖,SqlSession需要有一個獲取代理物件的方法,那麼這個代理物件是從哪裡獲取到的呢?是從我們的配置類裡面獲取到的,因為配置類裡面有介面和它要產生的代理類的對應關係。
所以,要先持有一個Configuration物件,叫GhyConfiguration,我們也建立這個類。除了獲取代理物件之外,它裡面還儲存了介面方法(也就是statementId)和SQL語句的繫結關係。

在SqlSession中定義的對外的API,最後都會呼叫Executor去操作資料庫,所以還要持有一個Executor物件,叫GhyExecutor,我們也建立它

public class GhySqlSession {
    private GhyConfiguration configuration;
    private GhyExecutor executor;
}

除了這兩個屬性之外,我們還要定義SqlSession的行為,也就是它的主要的方法。

第一個方法是查詢方法,selectOne(),由於它可以返回任意型別(List、Map、物件型別),我們把返回值定義成 T泛型。selectOne()有兩個引數,一個是String型別的statementId,我們會根據它找到SQL語句。一個是Object型別的parameter引數(可以是Integer也可以是String等等,任意型別),用來填充SQL裡面的佔位符。

    /**
     * 對外提供的查詢的方法
     * @param <T>
     * @return
     */
    public <T> T selectOne(String statementId,Object parameter){
        String sql=statementId; //先用statementId代替SQL
        
        return executor.query(sql,parameter);
    }

它會呼叫Executor的query()方法,所以建立Executor類,傳入這兩個引數,一樣返回一個泛型。Executor裡面要傳入SQL,但是我們還沒拿到,先用statementId代替。

/**
 * SQL語句的執行器
 */
public class GhyExecutor {

    public <T> T query(String sql ,Object parameter) {
        return null;
    }
}

SqlSession的第二個方法是獲取代理物件的方法,通過這種方式去避免了statementId的硬編碼。我們在SqlSession中建立一個getMapper()的方法,由於可以返回任意型別的代理類,所以我們把返回值也定義成泛型 T。我們是根據介面型別獲取到代理物件的,所以傳入引數要用型別Class。

    //獲取代理物件
    public <T> T getMapper(Class clazz){
        return configuration.getMapper(clazz );
    }

2.2 Configuration

代理物件我們不是在SqlSession裡面獲取到的,要進一步呼叫Configuration的getMapper()方法。返回值需要強轉成(T)。

/**
 * 用來儲存相關的配置資訊
 */

public class GhyConfiguration {

    
    public <T> T getMapper(Class clazz){
        return null;
    }

}

2.3 MapperProxy

我們要在Configuration中通過getMapper()方法拿到這個代理物件,必須要有一個實現了InvocationHandler的代理類(觸發管理器)。我們來建立它:GhyMapperProxy。實現invoke()方法。

public class GhyMapperProxy  implements InvocationHandler {
 
    @Override
    public Object invoke(Object proxy,Method method,Object[] args) throws Throwable {
      return null;
    }
}

invoke()的實現我們先留著。MapperProxy已經有了,回到Configuration.getMapper()完成獲取代理物件的邏輯。返回代理物件,直接使用JDK的動態代理:第一個引數是類載入器,第二個引數是被代理類實現的介面(這裡沒有被代理類),第三個引數是H(觸發管理器)。把返回結果強轉為(T):

    public <T> T getMapper(Class clazz,GhySqlSession sqlSession){
        return (T) Proxy.newProxyInstance(this.getClass().getClassLoader(),
                new Class[]{clazz},
                new GhyMapperProxy());
    }

獲取代理類的邏輯已經實現完了,我們可以在SqlSession中通過getMapper()拿到代理物件了,也就是可以呼叫invoke()方法了。接下來去完成MapperProxy 的invoke()方法。在MapperProxy的invoke()方法裡面又呼叫了SqlSession的selectOne()方法。一個問題出現了:在MapperProxy裡面根本沒有SqlSession物件?這兩個物件的關係怎麼建立起來?MapperProxy怎麼拿到一個SqlSession物件?很簡單,我們可通過建構函式傳入它。先定義一個屬性,然後在MapperProxy的建構函式裡面賦值

public class GhyMapperProxy  implements InvocationHandler {

    private GhySqlSession sqlSession;

    public GhyMapperProxy(GhySqlSession sqlSession) {
        this.sqlSession = sqlSession;
    }

    @Override
    public Object invoke(Object proxy,Method method,Object[] args) throws Throwable {
        return null;
    }
}

因為修改了代理類的建構函式,這個時候Configuration建立代理類的方法getMapper()也要修改。問題:Configuration也沒有SqlSession,沒辦法傳入MapperProxy的建構函式。怎麼拿到SqlSession呢?是直接new一個嗎?不需要,可以在SqlSession呼叫它的時候直接把自己傳進來(修改的地方:MapperProxy的建構函式添加了sqlSession,getMapper()方法也添加了SqlSession):

/**
 * 用來儲存相關的配置資訊
 */

public class GhyConfiguration {

   
    public <T> T getMapper(Class clazz,GhySqlSession sqlSession){
        return (T) Proxy.newProxyInstance(this.getClass().getClassLoader(),
                new Class[]{clazz},
                new GhyMapperProxy(sqlSession));
    }

}

那麼SqlSession的getMapper()方法也要修改(紅色是修改的地方):

    //獲取代理物件
    public <T> T getMapper(Class clazz){
        return configuration.getMapper(clazz,this);
    }

現在在MapperProxy裡面已經就可以拿到SqlSession物件了,在invoke()方法裡面會呼叫SqlSession的selectOne()方法。我們繼續來完成invoke()方法。selectOne()方法有兩個引數, statementId和paramater,這兩個怎麼拿到呢?statementId其實就是介面的全路徑+方法名,中間加一個英文的點。paramater可以從方法引數中拿到(args[0])。因為我們定義的是String,還要把拿到的Object強轉一下。把statementId和parameter傳給SqlSession:

public class GhyMapperProxy  implements InvocationHandler {

    private GhySqlSession sqlSession;

    public GhyMapperProxy(GhySqlSession sqlSession) {
        this.sqlSession = sqlSession;
    }

    @Override
    public Object invoke(Object proxy,Method method,Object[] args) throws Throwable {
        String mapperInterface = method.getDeclaringClass().getName();
        String methodName = method.getName();
        String statementId = mapperInterface +"." +methodName;

        return sqlSession.selectOne(statementId,args[0]);
    }
}

到了sqlSession的selectOne()方法,這裡要去呼叫Executor的query方法,這個時候必須傳入SQL語句和parameter(根據statementId獲取)。怎麼根據StatementId找到我們要執行的SQL語句呢?他們之間的繫結關係我們配置在哪裡?為了簡便,免去讀取檔案流和解析XML標籤的麻煩,我把SQL語句放在Properties檔案裡面。在resources目錄下建立一個sql.properties檔案。key就是介面全路徑+方法名稱,SQL是我們的查詢SQL。引數這裡,因為我們要傳入一個整數,所以先用一個%d的佔位符代替:

com.ghy.versionsone.mapper.UserMapper.selectOne=select * from t_user where id = %d

在sqlSession的selectOne()方法裡面,我們要根據StatementId獲取到SQL,然後傳給Executor。這個繫結關係是放在Configuration裡面的。怎麼快速地解析Properties檔案?為了避免重複解析,我們在Configuration建立一個靜態屬性和靜態方法,直接解析sql.properties檔案裡面的所有KV鍵值對:

/**
 * 用來儲存相關的配置資訊
 */

public class GhyConfiguration {
    // 儲存屬性檔案的資訊
    public static final ResourceBundle sqlMappings;

    static {
        sqlMappings = ResourceBundle.getBundle("sql");
    }


    public <T> T getMapper(Class clazz,GhySqlSession sqlSession){
        return (T) Proxy.newProxyInstance(this.getClass().getClassLoader(),
                new Class[]{clazz},
                new GhyMapperProxy(sqlSession));
    }

}
/**
* 對外提供的查詢的方法
* @param <T>
* @return
*/
public <T> T selectOne(String statementId,Object parameter){
String sql = GhyConfiguration.sqlMappings.getString(statementId);
// String sql=statementId; //先用statementId代替SQL
System.out.println ("sql:"+sql);
if (null !=sql && !"".equals ( sql )){
return executor.query(sql,parameter);
}
return null;
}

在SqlSession中,SQL語句已經拿到了,接下來就是Executor類的query()方法,Executor是資料庫操作的真正執行者。乾脆直接把以前文章中寫的JDBC的程式碼全部複製過來,職責先不用細分。引數用傳入的引數替換%d佔位符,需要format一下。

2.4 Executor

在Executor中我們就可以直接來執行SQL的執行了

/**
 * SQL語句的執行器
 */
public class GhyExecutor {

    public <T> T query(String sql,Object parameter){
        Connection conn = null;
        Statement stmt = null;
        User user = new User();

        try {
            // Class.forName("com.mysql.jdbc.Driver");

            // 開啟連線
            conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8&serverTimezone=UTC", "root", "root");

            // 執行查詢
            stmt = conn.createStatement();
            ResultSet rs = stmt.executeQuery(String.format(sql,parameter));

            // 獲取結果集
            while (rs.next()) {
                user.setId(rs.getInt("id"));
                user.setUserName(rs.getString("user_name"));
                user.setPassword(rs.getString("password"));
                user.setRealName(rs.getString("real_name"));
            }
            System.out.println(user);

            rs.close();
            stmt.close();
            conn.close();
        } catch (SQLException se) {
            se.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (stmt != null) {
                    stmt.close();
                }
            } catch (SQLException se2) {
            }
            try {
                if (conn != null) {
                    conn.close();
                }
            } catch (SQLException se) {
                se.printStackTrace();
            }
        }

        return (T) user;
    }
}

到這兒我們就可以來寫個測試類來跑下程式了

public class Test {
public static void main(String[] args) {
GhySqlSession sqlSession = new GhySqlSession();
// sqlSession.selectOne("com.ghy.versionsone.entity.User.selectOne",1);
UserMapper mapper = sqlSession.getMapper(UserMapper.class);
User user = mapper.selectOne(1);
System.out.println(user);
}
}

三、不足總結

1、在Executor中,對引數、語句和結果集的處理是耦合的,沒有實現職責分離;
2、引數:沒有實現對語句的預編譯,只有簡單的格式化,效率不高,還存在SQL注入的風險;
3、語句執行:資料庫連線硬編碼;
4、結果集:還只能處理Blog型別,沒有實現根據實體類自動對映。


git原始碼:https://gitee.com/TongHuaShuShuoWoDeJieJu/ljx-my-baits.git

這短短的一生我們最終都會失去,不妨大膽一點,愛一個人,攀一座山,追一個夢