整合營銷服務商

          電腦端+手機端+微信端=數(shù)據(jù)同步管理

          免費咨詢熱線:

          Python(4):秒搶腳本(火車票、秒殺、紅包)

          Python(4):秒搶腳本(火車票、秒殺、紅包)

          者:面包君

          鏈接:https://zhuanlan.zhihu.com/p/25214682

          來源:知乎

          著作權歸作者所有。商業(yè)轉載請聯(lián)系作者獲得授權,非商業(yè)轉載請注明出處。

          最近在寫風控方面的內容,涉及到一些怎么設置網站的用戶登陸安全、投資安全、運營安全方面的內容時,正好想起來去年的“月餅門”事件。對于碼農來說,寫個程序實現(xiàn)腳本搶標,這樣的事情其實很簡單。正好借著最近Python折騰代碼的機會,整理下怎么通過Python來實現(xiàn)搶火車票、紅包這些。

          需要的工具和組件有:

          • 瀏覽器驅動ChromeDriver http://chromedriver.storage.googleapis.com/index.html?path=2.20/

          • Python 3.5

          • Splinter 執(zhí)行:pip install splinter安裝Splinter即可

          重點介紹splinter怎么使用?

          >>> from splinter.browser import Browser

          >>> xx=Browser(driver_name="chrome")

          >>> xx.visit("http://www.zhihu.com/")

          介紹幾個常用功能:

          1. 輸入:xx.fill("wd", "dataman")即可在搜索欄搜索dataman。

          2. 輸入:button=xx.find_by_value(u"提問")

          button=xx.find_by_id(u"zu-top-add-questionSBSBSBSBSBSBSB")尋找該按鈕

          (通過快捷鍵F12查詢)

          3. 輸入:button.click() 點擊該按鍵

          下面用12306搶火車票/京東搶手機來示例下:

          #12306秒搶Python代碼from splinter.browser import Browserx=Browser(driver_name="chrome")url=“https://kyfw.12306.cn/otn/leftTicket/init”x=Browser(driver_name="chrome")x.visit(url)#填寫登陸賬戶、密碼x.find_by_text(u"登錄").click()x.fill("loginUserDTO.user_name","your login name")x.fill("userDTO.password","your password")#填寫出發(fā)點目的地x.cookies.add({"_jc_save_fromStation":"%u4E0A%u6D77%2CSHH"})x.cookies.add({"_jc_save_fromDate":"2016-01-20"})x.cookies.add({u'_jc_save_toStation':'%u6C38%u5DDE%2CAOQ'})#加載查詢x.reload()x.find_by_text(u"查詢").click()#預定x.find_by_text(u"預訂")[1].click()#選擇乘客x.find_by_text(u"數(shù)據(jù)分析俠")[1].click()
          #-*- coding:utf-8 -*-#京東搶手機腳本from splinter.browser import Browserimport time#登錄頁def login(b): #登錄京東 b.click_link_by_text("你好,請登錄") time.sleep(3) b.fill("loginname","account*****") #填寫賬戶密碼 b.fill("nloginpwd","passport*****") b.find_by_id("loginsubmit").click() time.sleep(3) return b#訂單頁def loop(b): #循環(huán)點擊 try: if b.title=="訂單結算頁 -京東商城": b.find_by_text("保存收貨人信息").click() b.find_by_text("保存支付及配送方式").click() b.find_by_id("order-submit").click() return b else: #多次搶購操作后,有可能會被轉到京東首頁,所以要再打開手機主頁 b.visit("http://item.jd.com/2707976.html") b.find_by_id("choose-btn-qiang").click() time.sleep(10) loop(b) #遞歸操作 except Exception as e: #異常情況處理,以免中斷程序 b.reload() #重新刷新當前頁面,此頁面為訂單提交頁 time.sleep(2) loop(b) #重新調用自己b=Browser(driver_name="chrome") #打開瀏覽器b.visit("http://item.jd.com/2707976.html")login(b)b.find_by_id("choose-btn-qiang").click() #找到搶購按鈕,點擊time.sleep(10) #等待10secwhile True: loop(b) if b.is_element_present_by_id("tryBtn"): #訂單提交后顯示“再次搶購”的話 b.find_by_id("tryBtn").click() #點擊再次搶購,進入讀秒5,跳轉訂單頁 time.sleep(6.5) elif b.title=="訂單結算頁 -京東商城": #如果還在訂單結算頁 b.find_by_id("order-submit").click() else: print('恭喜你,搶購成功') break
          系列文章旨在記錄和總結自己在Java Web開發(fā)之路上的知識點、經驗、問題和思考,希望能幫助更多(Java)碼農和想成為(Java)碼農的人。

          目錄

          1. 介紹
          2. JDBC規(guī)范
          3. 添加JDBC驅動的JAR包
          4. 注冊JDBC驅動器類
          5. 連接數(shù)據(jù)庫
          6. 數(shù)據(jù)庫運維
          7. 執(zhí)行SQL
          8. 總結

          介紹

          上篇文章介紹了數(shù)據(jù)庫應用的開發(fā)步驟,一般包括數(shù)據(jù)庫選型、數(shù)據(jù)庫設計、編寫數(shù)據(jù)庫訪問代碼三個步驟。而數(shù)據(jù)庫選型又因開發(fā)環(huán)境、測試環(huán)境和生產環(huán)境的不同而不同。

          所以,我們在編寫數(shù)據(jù)庫訪問代碼時,要盡量使用符合SQL標準的語法。關于SQL和SQL標準知識,大家可以自行搜索。

          因為我們的租房網應用只是用作演示,所以它的數(shù)據(jù)庫選型和數(shù)據(jù)庫設計就從簡而行,我就直接使用H2Database(簡稱H2,可以參考這篇文章)這個數(shù)據(jù)庫作為JDBC的驅動。

          JDBC規(guī)范

          要想了解JDBC如何使用,那最權威的資料莫過于JDBC規(guī)范了。

          JDBC規(guī)范的下載與Servlet等規(guī)范類似,都可以在JCP官網(https://jcp.org/en/home/index)找到,具體如何下載可以參考這篇文章。

          截止到本文成稿之日,JDBC規(guī)范的最新版本好像是4.3,對應JSR221,是在2017年完成的。

          JDBC規(guī)范的內容也不算少,足足有二百多頁,包括:

          1. 介紹
          2. 目標
          3. 新特性
          4. 概述
          5. 類和接口
          6. 兼容性
          7. 數(shù)據(jù)庫元數(shù)據(jù)
          8. 異常
          9. 數(shù)據(jù)庫連接(Connections)
          10. 事務
          11. 連接池
          12. 分布式事務
          13. SQL聲明(Statements)
          14. 批量更新
          15. 結果集
          16. 高級數(shù)據(jù)類型
          17. 自定義類型映射
          18. 與連接器的關系
          19. 包裝器(Wrapper)接口

          由于涉及內容太多,我們只能介紹JDBC最基本的用法。

          添加JDBC驅動的JAR包

          上篇文章的結尾我們提到過,JDBC僅僅是一套接口,它的使用模式與接口的普遍性使用模式是類似的。

          租房網應用作為使用JDBC的外部程序,它也需要配置一個具體的JDBC實現(xiàn)才行,這就是JDBC驅動。基本上,市面上各個數(shù)據(jù)庫(特別是關系數(shù)據(jù)庫)廠商都會提供自己的JDBC驅動,H2也不會例外。

          那如何在租房網應用中添加H2的JDBC驅動的JAR包呢?我們當然可以到H2官網(http://www.h2database.com/html/main.html)去下載它,然后在IDE中配置第三方依賴庫(可以參考這篇文章)。

          不對,我們不是學習過Maven了嗎?可以讓Maven來幫助我們管理依賴啊。我們在這篇文章中也已經把租房網應用改造成使用Maven來管理依賴了,比如Spring等依賴。我們當然也可以直接使用Maven來添加H2的JDBC驅動這個依賴啊。

          H2的所有東西都在一個JAR包里,當然也包括了它的JDBC驅動了,我們在這篇文章中下載的JAR包是 h2-1.4.200.jar 。

          現(xiàn)在,我們像添加Spring依賴那樣先到Maven倉庫中(https://mvnrepository.com/ )搜索H2這個依賴,后面的在POM文件中添加依賴等具體步驟就不再贅述了,可以參考這篇文章。

          H2的依賴在Maven中的描述是這樣的:

          <!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
          <dependency>
           <groupId>com.h2database</groupId>
           <artifactId>h2</artifactId>
           <version>1.4.200</version>
           <scope>test</scope>
          </dependency>
          

          將它復制到我們租房網應用的POM文件中,這樣,添加H2的JDBC驅動的JAR包就一切大功告成了,當然,你必須確保你的機器能夠連接上互聯(lián)網,因為Maven會自動到Maven倉庫中下載。

          其他數(shù)據(jù)庫的JDBC驅動的JAR包也可以同樣方式添加到我們的租房網應用中。

          注冊JDBC驅動器類

          在使用JDBC的外部程序(這里就是我們的租房網應用)配置一個JDBC驅動,首先要有該驅動,上面已經解決這個問題了。

          然后需要在外部程序中注冊該驅動的驅動器類。如何注冊呢?事實上,滿足 JDBC 4 規(guī)范的驅動會自動注冊到外部程序中,而我們添加的H2的JDBC驅動是符合 JDBC 4 規(guī)范的。因此,這一步就可以跳過了。

          事實上,JDBC驅動的這種自動注冊機制是使用了JAR規(guī)范的一個特性,包含 META-INF/services/java.sql.Driver 文件的JAR文件可以自動注冊驅動器類。我們可以用壓縮/解壓縮工具打開 h2.1.4.200.jar 看看是否有這么個文件,當然,在Eclipse中可以直接看到:

          不過,還是介紹一下如何解決JDBC驅動不能自動注冊的話該如何手動注冊的問題。有兩個方法:

          • 在使用JDBC的外部程序中編寫:
          Class.forName("org.h2.Driver");
          • 是在啟動外部程序的命令行參數(shù)中指定 jdbc.drivers 這個屬性:
          Java -Djdbc.drivers=org.h2.Driver 外部程序的名字

          當然,這種方式的變種是在外部程序中編寫API接口來設置這個屬性:

          System.setProperty("jdbc.drivers", "org.h2.Driver");

          假設我們的H2的JDBC驅動是不能自動注冊的,那么我們應該手動注冊它(當然,手動注冊也是沒有任何問題的,只不過是多此一舉而已)。

          那么,應該在哪個地方添加手動注冊的代碼呢?因為目前我們只有在房源服務(HouseService類)里使用到了模擬的房源數(shù)據(jù),而未來我們是要將房源數(shù)據(jù)持久化到數(shù)據(jù)庫中的,所以我們暫且把它加到這個類中吧。

          與模擬的房源數(shù)據(jù)一樣,把注冊JDBC驅動器類的代碼放到HouseService類的構造函數(shù)中:

          	public HouseService() throws ClassNotFoundException {
          		Class.forName("org.h2.Driver");
           
          		mockHouses=new ArrayList<House>();
          		mockHouses.add(new House("1", "金科嘉苑3-2-1201", "詳細信息"));
          		mockHouses.add(new House("2", "萬科橙9-1-501", "詳細信息"));
          	}

          當添加 Class.forName() 這一句代碼后,可以看到Eclipse自動提示有編譯錯誤:

          Unhandled exception type ClassNotFoundException

          這是因為Class類的forName()方法會拋出必須要我們處理的異常,如果不處理它,那就繼續(xù)拋給調用者,因此,我們在構造方法的聲明中添加了 throws ClassNotFoundException 這一部分,關于Java異常,我們以后詳細介紹。

          好,現(xiàn)在讓我們在Eclipse中發(fā)布租房網應用到Tomcat,并啟動Tomcat(可以參考這篇文章),然而在Eclipse的Console(控制臺)窗口中卻看到眾多異常,這些異常形成一條異常鏈(即最底層的異常拋到外部調用者后,外部調用者捕獲后包裝成另一個異常再拋給它的外部調用者,如此重復下去,直到最頂層異常被處理后不再往外拋出):

          可以看到,最底層的異常就是 Class.forName() 因為找不到 org.h2.Driver 這個類而拋出的異常,隨后導致 HouseService 的構造函數(shù)拋出異常,因此不能實例化 HouseService ,再導致Spring IoC框架找不到 HouseService 實例來注入到 HouseRenterController 這個Bean,最后導致 dispatcher 這個Servlet初始化失敗。

          那到底是何原因找不到H2的JDBC驅動器類 org.h2.Driver 呢?原來是POM文件中的H2依賴的配置在作怪,我們必須把 <scope> 標簽的內容設置為 compile ,或直接去掉該標簽

          <!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
          <dependency>
           <groupId>com.h2database</groupId>
           <artifactId>h2</artifactId>
           <version>1.4.200</version>
           <!-- <scope>test</scope> 必須去掉此標簽-->
          </dependency>

          從標簽的名字可以得知它是設置某種范圍的,配置為 test ,則表示該依賴只在執(zhí)行測試時有效;配置為 compile (此為默認值),則表示該依賴在執(zhí)行編譯時有效(實際上是任何時候都有效,是程度最強的依賴)。

          關于Maven的依賴的相關知識,以后再詳細介紹。

          現(xiàn)在,我們重新發(fā)布應用,啟動Tomcat,可以發(fā)現(xiàn)一切正常矣!

          連接數(shù)據(jù)庫

          接下來,我們就可以使用JDBC API來建立與數(shù)據(jù)庫的連接(數(shù)據(jù)庫往往單獨部署在獨立的數(shù)據(jù)庫服務器上,因此,需要通過網絡來訪問數(shù)據(jù)庫)。

          JDBC API主要被包含在 java.sql 和 javax.sql 這兩個包中,在JDK的 rt.jar 中可以找到它們,屬于Java的標準(運行時)庫。

          使用JDBC API中的 DriverManager 類的 getConnection() 方法即可建立與數(shù)據(jù)庫的連接。

          getConnection() 方法有多個重載版本(關于重載,可以參考這篇文章),其中一個版本就是:

          Connection getConnection(String url, String user, String password) throws SQLException
          

          它返回一個數(shù)據(jù)庫連接,但需要傳入一些參數(shù),一般情況下,連接數(shù)據(jù)庫最基本的要求是要知道如下信息:

          • 數(shù)據(jù)庫URL,它包括:數(shù)據(jù)庫服務器的種類(對應具體的數(shù)據(jù)庫廠商)、IP地址或域名、端口、數(shù)據(jù)庫名字等等;
          • 能夠訪問該數(shù)據(jù)庫的用戶名、密碼。

          用戶名和密碼都有對應的參數(shù),是字符串類型的,顯然是由DBA為我們分配的。那這個數(shù)據(jù)庫URL該怎么寫呢?

          既然我們用的是H2,那我們到它的官網上肯定能找到答案吧。否則的話,我們得到它卻不知如何使用,那H2的作者圖個啥!費過一番九牛二虎之力后,終于在其官網(http://www.h2database.com/html/main.html)的左側導航欄中的 Features 中找到了相關內容:

          它支持的URL遠比我們想象的要復雜的多,但基本格式都是 jdbc:h2: 開頭,前綴 jdbc: 實際上是JDBC規(guī)范規(guī)定的格式,后面的 h2: 當然是指H2Database這個數(shù)據(jù)庫了。

          可以看到,H2為我們提供了多種模式,我們可以選擇適合我們的模式。但對我們租房網應用來說,或者說對于在開發(fā)階段的應用來說,使用嵌入式模式或者內存模式是最簡單的,因為我們無需單獨部署數(shù)據(jù)庫服務器。我們這里就直接使用嵌入式模式吧:jdbc:h2:~/h2db/houserenter 。這里沒有數(shù)據(jù)庫服務器的IP地址和端口,有的只是一個路徑,最后的houserenter就是一個文件,它就代表一個數(shù)據(jù)庫,實際的數(shù)據(jù)就存在該文件中。當然,文件的格式我們是不知道的也不用知道。

          那用戶名和密碼是什么呢?實際上,這里可以隨便填寫。但在測試環(huán)境和生產環(huán)境中,或者數(shù)據(jù)庫是獨立部署的環(huán)境中,用戶名和密碼是由DBA負責分配的。

          現(xiàn)在,我們的HouseService類的構造函數(shù)變成:

          	public HouseService() throws ClassNotFoundException, SQLException {
          		Class.forName("org.h2.Driver");
          		String url="jdbc:h2:~/h2db/houserenter";
          		String user="sa";
          		String password="";
          		Connection conn=DriverManager.getConnection(url, user, password);
           
          		mockHouses=new ArrayList<House>();
          		mockHouses.add(new House("1", "金科嘉苑3-2-1201", "詳細信息"));
          		mockHouses.add(new House("2", "萬科橙9-1-501", "詳細信息"));
          	}

          注意,getConnection()方法也會拋出必須處理的異常 SQLException ,我這選擇直接拋給HouseService類的構造函數(shù)的調用者。

          現(xiàn)在,讓我們重新發(fā)布應用并啟動Tomcat,驗證一下是否有問題,結果正常,我們也可以到 ~/h2db (波浪線實際是指你登錄操作系統(tǒng)的用戶的家目錄,在Windows系統(tǒng)中,一般是 C:\Users\用戶名\)這個目錄下看看是否存在 houserenter 這個文件:

          數(shù)據(jù)庫運維

          數(shù)據(jù)庫的運維,即數(shù)據(jù)庫服務器的部署和搭建,數(shù)據(jù)庫、表、索引等的創(chuàng)建、授權,數(shù)據(jù)的備份和遷移,數(shù)據(jù)的分庫分表分區(qū)等工作。這些工作往往有專門人員來負責,就是數(shù)據(jù)庫管理員,即DBA。

          我們做好數(shù)據(jù)庫選型和設計之后,在測試環(huán)境和生產環(huán)境中(有的公司開發(fā)環(huán)境也需要),一般是向DBA申請服務器資源,讓他們部署和搭建數(shù)據(jù)庫服務器,建庫建表等。

          當然,你也可以自己一人包攬所有這些工作。

          最后,一定是啟動數(shù)據(jù)庫服務器,這樣我們的數(shù)據(jù)庫應用才能建立數(shù)據(jù)庫連接,當然必須保證數(shù)據(jù)庫URL是正確的。即數(shù)據(jù)庫連接能夠建立的條件必須包括:

          • 數(shù)據(jù)庫服務器在運行中;
          • URL必須正確,即數(shù)據(jù)庫服務器的IP、端口必須正確,數(shù)據(jù)庫的名字所指定的數(shù)據(jù)庫必須已經存在;
          • 訪問該數(shù)據(jù)庫的用戶名、密碼必須正確。

          而H2支持嵌入式模式,則沒有諸多限制,所以在開發(fā)環(huán)境中使用是最合適不過了!

          執(zhí)行SQL

          現(xiàn)在,連接已經建立好了,我們就可以用它來執(zhí)行SQL語句了。不過,Connection接口并沒有執(zhí)行SQL的方法,而是提供了一個創(chuàng)建Statement對象的方法:

          Statement createStatement() throws SQLException

          而Statement接口擁有眾多執(zhí)行SQL的方法,最常用的有兩個:

          int java.sql.Statement.executeUpdate(String sql) throws SQLException
          
          ResultSet java.sql.Statement.executeQuery(String sql) throws SQLException
          
          
          

          正如方法名字所示,executeUpdate()方法主要是用來執(zhí)行insert、update或者delete等數(shù)據(jù)更新(寫)操作的SQL語句的(這些語句叫數(shù)據(jù)操縱語言,即Data Manipulation Language,簡稱DML)。當然,它還可以執(zhí)行建立、刪除數(shù)據(jù)表等操作的SQL語句(這些語句叫數(shù)據(jù)定義語言,即Data Definition Language,簡稱DDL)。

          而executeQuery()方法是用來執(zhí)行select等數(shù)據(jù)查詢操作的SQL語句的(也屬于DML)。它返回查詢結果集,我們就可以使用ResultSet接口來訪問查詢結果了。

          大家可以通過IDE或者其他方式查看它們的JavaDoc。

          我們之前建立的數(shù)據(jù)庫連接中使用的是 jdbc:h2:~/h2db/houserenter (簡稱houserenter數(shù)據(jù)庫),這個庫中也是H2剛剛建立的,因此該庫中還沒有任何數(shù)據(jù)表,所以我們要先使用 create table 語句建立數(shù)據(jù)表。

          根據(jù)上篇文章我們對租房網應用的數(shù)據(jù)庫設計,房源表包含三個字段,于是執(zhí)行SQL的代碼如下:

          statement.executeUpdate("create table house(id varchar(20) primary key, name varchar(100), detail varchar(500))");

          但是,如果我們把它放到HouseService類的構造函數(shù)的話,豈不是每次啟動應用時都要建立一次house表,大家可以試試,這樣的話第二次啟動應用時會拋出house表已經存在的異常。

          那該怎么辦呢?我們可以在每次啟動應用時都使用 drop table 語句將原來的house表刪除,然后再執(zhí)行建表語句:

          statement.executeUpdate("drop table if exists house");

          接著,我們再使用 insert 語句插入模擬的房源數(shù)據(jù)(因為我們的租房網應用還沒有提供發(fā)布房源的功能):

          statement.executeUpdate("insert into house values('1', '金科嘉苑3-2-1201', '詳細信息') ");
          statement.executeUpdate("insert into house values('2', '萬科橙9-1-501', '詳細信息') ");

          現(xiàn)在,我們的HouseService類的構造方法變成這樣:

          	public HouseService() throws ClassNotFoundException, SQLException {
          		Class.forName("org.h2.Driver");
          		String url="jdbc:h2:~/h2db/houserenter";
          		String user="sa";
          		String password="";
          		Connection conn=DriverManager.getConnection(url, user, password);
          		Statement statement=conn.createStatement();
           statement.executeUpdate("drop table if exists house");
           statement.executeUpdate("create table house(id varchar(20) primary key, name varchar(100), detail varchar(500))");
           statement.executeUpdate("insert into house values('1', '金科嘉苑3-2-1201', '詳細信息') ");
           statement.executeUpdate("insert into house values('2', '萬科橙9-1-501', '詳細信息') ");
           
          		mockHouses=new ArrayList<House>();
          		mockHouses.add(new House("1", "金科嘉苑3-2-1201", "詳細信息"));
          		mockHouses.add(new House("2", "萬科橙9-1-501", "詳細信息"));
          	}

          executeUpdate()方法的返回值是一個整型值,表示執(zhí)行該SQL語句后受影響的行數(shù),大家可以在上述代碼的基礎上打印每一次executeUpdate()方法的返回值,看看返回值是多少。

          這樣,我們的數(shù)據(jù)準備已經完成,下面應該使用executeQuery()方法來改造查詢房源的方法了,原來的是這樣的:

          	public House findHouseById(String houseId) {
          		for (House house : mockHouses) {
          			if (houseId.equals(house.getId())) {
          				return house;
          			}
          		}
          		return null;
          	}

          首先,我們仍然需要建立數(shù)據(jù)庫連接,創(chuàng)建Statement對象,因為上面使用的都是局部變量。這不就有代碼重復了嗎?沒錯,我們需要消除這種重復,不過,我們暫且把這個任務放一邊,先重點看看如何查詢數(shù)據(jù)庫,如何訪問查詢結果集。

          根據(jù)房源ID查詢房源的SQL語句很簡單:

          select id,name,detail from house where id='這里是房源ID'

          查詢條件房源ID是通過方法的參數(shù)傳入的,我們可以利用Java的字符串拼接功能,拼成上述SQL語句

          ResultSet rs=statement.executeQuery("select id,name,detail from house where id='" + houseId + "'");
          

          要特別注意拼接SQL語句時用到的單引號和雙引號(使用PreparedStatement可以避免此現(xiàn)象,還可以提高性能,以后討論),很容易出錯,而且容易讓黑客執(zhí)行SQL注入攻擊,這里就不介紹了。

          當然,你必須確保house表存在,而且它有 id、name、detail 三列,這也是非常容易出錯的地方,很明顯,我們的代碼與數(shù)據(jù)庫設計緊耦合了,因此,我們需要思考一下怎么樣才能解耦!

          言歸正傳,得到結果集之后,我們就可以訪問結果集中的數(shù)據(jù)了。因為查詢結果可能是多條記錄,或者零條記錄(當然,我們這里house表是以房源ID為主鍵的,而查詢條件就是房源ID,因此查詢結果要么該房源ID的房源不存在,要么就只能有一條房源),所以必須遍歷結果集,ResultSet接口提供的是 next()方法:

           while (rs.next()) {
          			return new House(rs.getString("id"), rs.getString("name"), rs.getString("detail"));
           }

          當然,一旦訪問到一條房源記錄,那就可以直接返回了。

          使用next()方法指向結果集的下一條記錄后(若無下一條則返回false),就可以用ResultSet接口提供的getXXX()方法來訪問每一列的值了,這里的 XXX 可以是各種數(shù)據(jù)類型,比如Byte、Short、Int、Long、Float、Double、Date、String等等,傳入的參數(shù)既可以是該列在select語句中的位置,也可以是selcet語句中出現(xiàn)的列名。

          于是,我們的查詢房源的方法就變?yōu)榱耍?/p>

          	public House findHouseById(String houseId) {
          		try {
          			String url="jdbc:h2:~/h2db/houserenter";
          			String user="sa";
          			String password="";
          			Connection conn=DriverManager.getConnection(url, user, password);
          			Statement statement=conn.createStatement();
          			ResultSet rs=statement.executeQuery("select id,name,detail from house where id='" + houseId + "'");
          			
          			while (rs.next()) {
          				return new House(rs.getString("id"), rs.getString("name"), rs.getString("detail"));
          	 }
          		} catch (SQLException e) {
          			System.out.println("HouseService findHouseById SQLException: " + e.getMessage() + "! houseId=" + houseId);
          		}
          		
          		return null;
          	}

          需要注意的是,我這里采用了另外一種處理異常的方式,即使用 try-catch 語句捕獲拋出的必須處理的異常,并處理它(僅僅打印日志而已,往往要把異常的上下文信息打印出來,便于查找問題)。

          另外一個查詢用戶感興趣房源的方法,也可以做相應的修改,HouseService類的構造方法中原來的添加模擬房源數(shù)據(jù)的代碼也可以刪除了,最后的HouseService變成了這樣:

          package houserenter.service;
          import java.sql.Connection;
          import java.sql.DriverManager;
          import java.sql.ResultSet;
          import java.sql.SQLException;
          import java.sql.Statement;
          import java.util.ArrayList;
          import java.util.List;
          import org.springframework.stereotype.Service;
          import houserenter.entity.House;
          @Service
          public class HouseService {
          	public HouseService() throws ClassNotFoundException, SQLException {
          		Class.forName("org.h2.Driver");
          		String url="jdbc:h2:~/h2db/houserenter";
          		String user="sa";
          		String password="";
          		Connection conn=DriverManager.getConnection(url, user, password);
          		Statement statement=conn.createStatement();
           statement.executeUpdate("drop table if exists house");
           statement.executeUpdate("create table house(id varchar(20) primary key, name varchar(100), detail varchar(500))");
           statement.executeUpdate("insert into house values('1', '金科嘉苑3-2-1201', '詳細信息') ");
           statement.executeUpdate("insert into house values('2', '萬科橙9-1-501', '詳細信息') ");
          	}
          	public List<House> findHousesInterested(String userName) {
          		// 這里查找該用戶感興趣的房源,省略,改為用模擬數(shù)據(jù)
          		List<House> houses=new ArrayList<>();
          		try {
          			String url="jdbc:h2:~/h2db/houserenter";
          			String user="sa";
          			String password="";
          			Connection conn=DriverManager.getConnection(url, user, password);
          			Statement statement=conn.createStatement();
          			ResultSet rs=statement.executeQuery("select id,name,detail from house");
          			
          			
          			while (rs.next()) {
          				houses.add(new House(rs.getString("id"), rs.getString("name"), rs.getString("detail")));
          	 }
          			return houses;
          		} catch (SQLException e) {
          			System.out.println("HouseService findHousesInterested SQLException: " + e.getMessage() + "! userName=" + userName);
          
          		}
          		
          		return houses;
          	}
          	public House findHouseById(String houseId) {
          		try {
          			String url="jdbc:h2:~/h2db/houserenter";
          			String user="sa";
          			String password="";
          			Connection conn=DriverManager.getConnection(url, user, password);
          			Statement statement=conn.createStatement();
          			ResultSet rs=statement.executeQuery("select id,name,detail from house where id='" + houseId + "'");
          			
          			while (rs.next()) {
          				return new House(rs.getString("id"), rs.getString("name"), rs.getString("detail"));
          	 }
          		} catch (SQLException e) {
          			System.out.println("HouseService findHouseById SQLException: " + e.getMessage() + "! houseId=" + houseId);
          		}
          		
          		return null;
          	}
          }

          我們可以重新發(fā)布應用并啟動Tomcat,然后用瀏覽器訪問租房網應用進行驗證,基本上沒有問題,唯一存在功能上的問題就是,我們編輯的房源沒有存到數(shù)據(jù)庫中,所以我們需要為HouseService類增加一個更新房源的方法,具體實現(xiàn)如下:

          	public void updateHouseById(House house) {
          		try {
          			String url="jdbc:h2:~/h2db/houserenter";
          			String user="sa";
          			String password="";
          			Connection conn=DriverManager.getConnection(url, user, password);
          			Statement statement=conn.createStatement();
          			statement.executeUpdate("update house set id='" + house.getId()
          					+ "', name='" + house.getName()
          					+ "', detail='" + house.getDetail()
          					+ "' where id='" + house.getId() + "'");
          		} catch (SQLException e) {
          			System.out.println("HouseService updateHouseById SQLException: " + e.getMessage() + "! house=" + house.toString());
          		}
          	}

          要非常注意各列的類型是字符串,因此SQL語句中各列的值要加上單引號!

          而我們的HouseRenterController控制器類的處理房源編輯表單的Handler方法由原來的:

          	@PostMapping("/house-form.action")
          	public ModelAndView postHouseForm(String userName, House house) throws UnsupportedEncodingException {
          		// 這里需要驗證用戶是否登錄,省略
          		//更新指定房源的詳情
          		House target=houseService.findHouseById(house.getId());
          		target.setName(house.getName());
          		target.setDetail(house.getDetail());
          		//將請求轉發(fā)到查找房源詳情的動作
          		ModelAndView mv=new ModelAndView();
          		mv.setViewName("redirect:house-details.action?userName=" + userName + "&houseId=" + house.getId());
          		return mv;
          	}

          修改為:

          	@PostMapping("/house-form.action")
          	public ModelAndView postHouseForm(String userName, House house) throws UnsupportedEncodingException {
          		// 這里需要驗證用戶是否登錄,省略
          		//更新指定房源的詳情
          		houseService.updateHouseById(house);
          		//將請求轉發(fā)到查找房源詳情的動作
          		ModelAndView mv=new ModelAndView();
          		mv.setViewName("redirect:house-details.action?userName=" + userName + "&houseId=" + house.getId());
          		return mv;
          	}

          現(xiàn)在,再重新驗證一下,編輯房源也沒有問題了!

          總結

          • JDBC僅僅是一套接口,實際使用要配置JDBC驅動;
          • JDBC API的使用步驟依次是:注冊驅動器類、連接數(shù)據(jù)庫、獲取Statement對象、執(zhí)行SQL語句;
          • 使用Statement接口的executeUpdate()方法執(zhí)行更新操作的SQL語句(insert、update、delete和DDL等);executeQuery()方法執(zhí)行查詢操作的SQL語句(select);
          • 遍歷查詢結果集使用ResultSet接口的next()方法和getXXX()方法;

          但是,我們使用JDBC的代碼還有很多不足:

          • 有很多代碼重復的地方;
          • 訪問數(shù)據(jù)庫的代碼沒有獨立出來(事實上,應該從Service層獨立出來形成DAO層);
          • 訪問數(shù)據(jù)庫的一些資源沒有釋放,比如連接、Statement、結果集;
          • 每次訪問都要建立數(shù)據(jù)庫連接,性能低下;
          • 與數(shù)據(jù)庫設計耦合嚴重;
          • 正文代碼中仍然有用于測試的添加模擬數(shù)據(jù)的代碼;
          • 數(shù)據(jù)庫訪問的異常處理不夠;
          • 等等

          不管怎樣,我們向數(shù)據(jù)持久化邁出了一步!

          pring在2018年9月發(fā)布了Spring-Data-JDBC子項目的1.0.0.RELEASE版本(目前版本為1.0.6-RELEASE),Spring-Data-JDBC設計借鑒了DDD,提供了對DDD的支持,包括:

          • 聚合與聚合根
          • 倉儲
          • 領域事件

          在前面領域設計:聚合與聚合根一文中,通過列子介紹了聚合與聚合根;而在領域設計:領域事件一文中,通過例子介紹了領域事件。

          本文結合Spring-Data-JDBC來重寫這兩個例子,來看一下Spring-Data-JDBC如何對DDD進行支持。

          環(huán)境搭建

          Spring-Data-JDBC項目還較新,文檔并不齊全(Spring-Data-JDBC的文檔還是以Spring-Data-JPA為基礎編寫的,依賴還是Spring-Data-JPA,實際不需要Spring-Data-JPA依賴),所以這里給出搭建過程中的注意點。

          新建一個maven項目,pom.xml中配置

          <!--這里需要引入spring-boot 2.1.0以上,2.0的boot還沒有spring-data-jdbc--><parent>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-parent</artifactId>
           <version>2.1.4.RELEASE</version></parent><dependencies>
           <!--引入spring-data-jdbc-->
           <dependency>
           <groupId>org.springframework.boot</groupId>
           <artifactId>spring-boot-starter-data-jdbc</artifactId>
           </dependency>
          </dependencies>
          

          開啟jdbc支持

          @SpringBootApplication
          @EnableAutoConfiguration
          @EnableJdbcRepositories // 主要是這個注解,來啟動spring-data-jdbc支持@EnableTransactionManagement
          public class TestConfig {
          }
          

          聚合與聚合根

          領域設計:聚合與聚合根中舉了兩個列子:

          • Order與OrderDetail之間的關系
          • Product與ProductComment之間的關系
          • 我們通過Spring-Data-JDBC來實現(xiàn)這兩個例子,來看一下Spring-Data-JDBC對聚合和聚合根的支持。

          我們先看Order與OrderDetail。

          訂單與詳情

          Order與OrderDetail組成了一個聚合,其中Order是聚合根,聚合中的操作都是通過聚合根來完成的。

          在Spring-Data-JDBC中如何表示這一層關系呢?

          @Getter // 1
          @Table("order_info") // 2
          public class Order {
           @Id // 3
           private Long recId;
           private String name;
           private Set<OrderDetail> orderDetailList=new HashSet<>(); // 4
           public Order(String name) { // 5
           this.name=name;
           }
           // 其它字段略
           public void addDetail(String prodName) { // 6
           orderDetailList.add(new OrderDetail(prodName));
           }
          }
          
          @Getter // 1
          public class OrderDetail {
           @Id // 3
           private Long recId;
           private String prodName;
           // 其它字段略
           OrderDetail(String prodName) { // 7
           this.prodName=prodName;
           }
          }
          
          1. lombok注解,這里只提供了get方法,封裝操作
          2. 默認情況下,類名與表名的映射關系是
          • 類名的首字母小寫
          • 駝峰式轉下劃線
          • 這里order在數(shù)據(jù)庫中是關鍵字,所以使用Table注解進行映射,映射到order_info表
          1. 通過@Id注解,標明這個類是個實體
          2. Order中持有一個OrderDetail的Set集合,標明Order與OrderDetail組成了一個聚合,且Order是聚合根
          • 聚合關系由spring-data-jdbc默認維護
          • 如果是Set集合,則order_detail表中,需要有個order_info字段,保存訂單主鍵
          • 如果是List集合,則order_detail表中,需要有兩個字段:order_info保存訂單主鍵,order_info_key保存順序
          1. 兩個類都沒有提供set方法,通過構造方法來賦值
          2. Order是聚合根,所有操作通過聚合根來操作,這里提供addDetail方法來新增訂單詳情
          3. 因為OrderDetail的操作都是通過Order來進行的,所以設置OrderDetail構造方法包級可見,限制了外部對OrderDetail的構建

          根據(jù)上面的說明,我們的sql結構如下:

          DROP TABLE IF EXISTS `order_info`;
          CREATE TABLE `order_info` (
           `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
           `name` varchar(11) NOT NULL COMMENT '訂單名稱',
           PRIMARY KEY (`rec_id`)
          ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;
          
          DROP TABLE IF EXISTS order_detail;
          CREATE TABLE `order_detail` (
           `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
           `order_info` BIGINT(11) NOT NULL COMMENT '訂單主鍵,由spring-data-jdbc自動維護',
           `prod_name` varchar(11) NOT NULL COMMENT '產品名稱',
           PRIMARY KEY (`rec_id`)
          ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;
          

          對聚合的操作,Spring-Data-JDBC提供了Repository接口,直接實現(xiàn)即可,提供了類似RubyOnRails那樣的動態(tài)查詢方法,不過需要通過Query注解自行編寫sql,詳見下文。

          @Repository
          public interface OrderRepository extends CrudRepository<Order, Long> {
          }
          
          • 這里編寫一個接口,繼承CrudRepository接口,里面提供了基本的查詢,直接使用即可

          這就搞定了,我們編寫一個測試,來測試一下:

          @RunWith(SpringRunner.class)
          @SpringBootTest(classes=TestConfig.class)
          public class OrderTest {
           @Autowired
           private OrderRepository orderRepository;
           @Test
           public void testInit() {
           Order order=new Order("測試訂單");
           order.addDetail("產品1");
           order.addDetail("產品2");
           Order info=orderRepository.save(order); // 1
           Optional<Order> orderInfoOptional=orderRepository.findById(info.getRecId()); // 2
           assertEquals(2, orderInfoOptional.get().getOrderDetailList().size()); // 3
           }
          }
          
          1. 直接使用提供的save方法進行保存操作,自動處理聚合關系,也就是說這里自動保存了order及里面的兩個order_detail
          2. 通過提供的findById查詢出Order,這里返回的是個Optional類型
          3. 返回的Order中,自動組裝了其中的order_detail。對應的刪除操作,也會自動刪除其關聯(lián)的order_detail

          產品與評論

          產品與產品評論的關系如下:

          • 產品和產品評論沒有業(yè)務上的一致性需求,所以是兩個「聚合」
          • 產品評論通過productId與「產品聚合」進行關聯(lián)

          代碼表示就是簡單的通過id進行關聯(lián)。代碼如下:

          @Getter
          public class Product { // 1
           @Id
           private Long recId;
           private String name;
           public Product(String name) {
           this.name=name;
           }
           // 其它字段略
          }
          
          @Getter
          public class ProductComment {
           @Id
           private Long recId;
           private Long productId; // 2
           private String content;
           // 其它字段略
           public ProductComment(Long productId, String content) {
           this.productId=productId;
           this.content=content;
           }
          }
          
          1. Product中不再持有對應的集合
          2. 相應的,ProductComment中持有了產品主鍵字段

          對應的sql如下:

          DROP TABLE IF EXISTS `product`;
          CREATE TABLE `product` (
           `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
           `name` varchar(11) NOT NULL COMMENT '產品名稱',
           PRIMARY KEY (`rec_id`)
          ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;
          
          DROP TABLE IF EXISTS product_comment;
          CREATE TABLE `product_comment` (
           `rec_id` BIGINT(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
           `product_id` BIGINT(11) NOT NULL COMMENT '產品主鍵,手動賦值',
           `content` varchar(11) NOT NULL COMMENT '評論內容',
           PRIMARY KEY (`rec_id`)
          ) ENGINE=InnoDB DEFAULT CHARSET=utf8 ;
          

          產品和評論都是聚合根,所以都有各自的倉儲類:

          @Repository
          public interface ProductRepository extends CrudRepository<Product, Long> {
          }
          
          @Repository
          public interface ProductCommentRepository extends CrudRepository<ProductComment, Long> {
          
           @Query("select count(1) from product_comment where product_id=:productId") // 1
           int countByProductId(@Param("productId") Long productId); // 2
          
          }
          
          1. 通過Query注解來綁定sql與方法的關系,參數(shù)以:開頭。(Spring-Data-JDBC目前還不支持自動sql綁定)
          2. Param注解來標明參數(shù)名,或者使用jdk8的-parameters編譯方式,來根據(jù)參數(shù)名自動綁定

          熟悉Mybatis的朋友對這段代碼應該很眼熟吧!

          測試如下:

          @RunWith(SpringRunner.class)
          @SpringBootTest(classes=TestConfig.class)
          public class ProductTest {
           @Autowired
           private ProductRepository productRepository;
           @Autowired
           private ProductCommentRepository productCommentRepository;
          
           @Test
           public void testInit() {
           Product prod=new Product("產品名稱");
           Product info=productRepository.save(prod);
           ProductComment comment1=new ProductComment(info.getRecId(), "評論1"); // 1
           ProductComment comment2=new ProductComment(info.getRecId(), "評論2");
           productCommentRepository.save(comment1);
           int num=productCommentRepository.countByProductId(info.getRecId());
           assertEquals(1, num);
           productCommentRepository.save(comment2);
           num=productCommentRepository.countByProductId(info.getRecId());
           assertEquals(2, num);
           productRepository.delete(info); // 2
           num=productCommentRepository.countByProductId(info.getRecId());
           assertEquals(2, num);
           }
          }
          
          1. 產品和評論各自保存
          2. 刪除產品后,評論并不會跟著一起刪除。如果需要一并刪除,需要手動處理。

          聚合小節(jié)

          從上面的兩個例子可以看出:

          • 對于同一個聚合中的多個實體,可以通過在聚合根中引用對應的實體對象,來實現(xiàn)聚合操作。Spring-Data-JDBC會自動處理這層關系
          • 對于不同的聚合,通過id的方式進行引用,手動處理兩者的關系。這也是領域設計里推薦的做法
          • 如果實體中需要引用其他實體,但是并不想保持一致的操作,那么使用Transient注解
          • 被聚合根引用的實體對象,對應的數(shù)據(jù)庫表中需要一個與聚合根同名的字段,用于保存聚合根的id。這就可以用來區(qū)分數(shù)據(jù)表之間是聚合根與實體的關系,還是聚合根與聚合根之間的關系
          • 如果表中有一個字段,字段名與另一張數(shù)據(jù)表的表名相同,其中保存的是對應的id,那么這張表是對應字段表的實體,對應字段表是聚合根
          • 如果表中的字段是「表名+id」形式,那么兩張表都是聚合根,分屬于不同的聚合
          • 如果兩個實體之間是多對多的關系,則可以引入一個「關系值對象」,引用方持有這個「關系值對象」來維護關系。對應數(shù)據(jù)庫設計,就是引入一個mapping表,代碼如下:
          // 來自spring示例
          class Book {
           ......
           private Set<AuthorRef> authors=new HashSet<>();
          }
          
          @Table("book_author")
          class AuthorRef {
           Long authorId;
          }
          
          class Author {
           ......
           String name;
          }
          

          領域事件

          在領域設計:領域事件一文中使用Spring提供的ApplicationEvent演示了領域事件,這里通過對Order聚合根的擴展,來看看Spring-Data-JDBC對領域事件的支持。

          假設上面的Order創(chuàng)建后,需要發(fā)送一個領域事件,該如何處理呢?

          Spring-Data-JDBC默認提供了5個事件:

          • BeforeDeleteEvent:聚合根在被刪除之前觸發(fā)
          • AfterDeleteEvent:聚合根在被刪除之后觸發(fā)
          • BeforeSaveEvent:聚合根在被保存之前觸發(fā)
          • AfterSaveEvent:聚合根在被保存之后觸發(fā)
          • AfterLoadEvent:聚合根在被從倉儲恢復后觸發(fā)

          那么對于上面的需求,我們不需要創(chuàng)建什么事件,只需要創(chuàng)建一個監(jiān)聽器,來監(jiān)聽AfterSaveEvent事件就可以了。

          @Bean
          public ApplicationListener<AfterSaveEvent> afterSaveEventListener() {
           return event -> {
           Object entity=event.getEntity();
           if (entity instanceof Order) {
           Order order=(Order) entity;
           System.out.println("訂單[" + order.getName() + "]保存成功");
           }
           };
          }
          

          重新執(zhí)行上面的OrderTest的測試方法,會得到如下輸出:

          訂單[測試訂單]保存成功
          

          如果我們需要自定義事件,該如何處理呢?Spring-Data-JDBC提供了DomainEvents和AfterDomainEventPublication注解:

          • 被DomainEvents注解的無參方法,可以返回一個或多個事件
          • 被AfterDomainEventPublication注解的方法,可以用于事件發(fā)布后的后續(xù)處理工作
          • 這兩個方法在repository.save方法執(zhí)行時被調用
          @Getter
          public class OrderCreateEvent extends ApplicationEvent { // 1
           private String name;
           public OrderCreateEvent(Object source, String name) {
           super(source);
           this.name=name;
           }
          }
          
          @Getter
          @Table("order_info")
          public class Order {
           ......
           @DomainEvents
           public ApplicationEvent domainEvent() { // 2
           return new OrderCreateEvent(this, this.name);
           }
           @AfterDomainEventPublication
           public void postPublish() { // 3
           System.out.println("Event published");
           }
          }
          
          public class TestConfig {
           ......
           @Bean
           public ApplicationListener<OrderCreateEvent> orderCreateEventListener() { // 4
           return event -> {
           System.out.println("訂單[" + event.getName() + "]保存成功");
           };
           }
          }
          
          1. 自定義一個事件,具體可見領域設計:領域事件
          2. DomainEvents注解的方法,會在repository.save方法調用時創(chuàng)建一個OrderCreateEvent事件,傳入訂單名稱作為參數(shù)
          3. AfterDomainEventPublication注解的方法在事件發(fā)布完成后,進行回調,可以處理事件發(fā)布后的一些處理,這里只是簡單的打印
          4. OrderCreateEvent事件監(jiān)聽對象,監(jiān)聽事件進行處理

          再次執(zhí)行上面的OrderTest的測試方法,會得到如下輸出:

          訂單[測試訂單]保存成功 // 這是AfterSaveEvent事件觸發(fā)的
          訂單[測試訂單]保存成功 // 這是自定義事件觸發(fā)的
          Event published
          

          事件小節(jié)

          Spring-Data-JDBC在原來Spring事件的基礎上進行了增強:

          • 新增了5個聚合根操作相關的事件
          • 通過DomainEvents注解簡化了事件的發(fā)布(只在repository.save時觸發(fā))
          • 通過AfterDomainEventPublication注解處理事件發(fā)布后的回調(只在repository.save時觸發(fā))
          • 提供了AbstractAggregateRoot抽象類來進一步簡化事件處理

          總結

          Spring-Data-JDBC的設計借鑒了DDD。本文演示了Spring-Data-JDBC如何對DDD進行支持:

          • 自動處理聚合根與實體之間的關系
          • 默認倉儲接口,簡化聚合存儲
          • 通過注解來簡化領域事件的發(fā)布

          Spring-Data-JDBC還提供了如下功能:

          • MyBatis support
          • Id generation
          • Auditing
          • CustomConversions

          有興趣可自行參考文檔。

          參考資料

          • spring-data-jdbc文檔:https://docs.spring.io/spring-data/jdbc/docs/1.0.6.RELEASE/reference/html/
          • Spring Data JDBC, References, and Aggregates:https://spring.io/blog/2018/09/24/spring-data-jdbc-references-and-aggregates

          主站蜘蛛池模板: 亚洲丰满熟女一区二区哦| 波多野结衣中文一区二区免费| 日本一区二区三区免费高清在线| 亚洲一区二区三区在线观看精品中文 | 中文字幕视频一区| 久久国产三级无码一区二区| 精品一区二区三区AV天堂| 日韩视频一区二区在线观看| 国产99视频精品一区| 亚洲欧美一区二区三区日产| 亚洲午夜在线一区| 91在线精品亚洲一区二区| 日韩人妻无码一区二区三区99 | 四虎成人精品一区二区免费网站 | 亚洲一区二区精品视频| 中日av乱码一区二区三区乱码| 国产精品无圣光一区二区 | 99精品高清视频一区二区| 波多野结衣一区二区三区88| 亚洲熟女乱色一区二区三区| 精品深夜AV无码一区二区老年| 国产成人精品无码一区二区三区| 国产MD视频一区二区三区| 在线不卡一区二区三区日韩| 精品国产免费一区二区| 精品欧洲av无码一区二区| 日韩精品无码一区二区三区免费| 久久中文字幕一区二区| 丰满爆乳一区二区三区| 91在线看片一区国产| 日本一区免费电影| 中文字幕一区二区三区日韩精品| 少妇精品无码一区二区三区| 国产一区高清视频| 精品一区二区三区四区在线| 一区二区三区四区精品视频| 国产一区二区不卡老阿姨| 精品成人av一区二区三区| 97久久精品无码一区二区天美| 国产suv精品一区二区6| 精品无码人妻一区二区三区|