成人免费xxxxx在线视频软件_久久精品久久久_亚洲国产精品久久久_天天色天天色_亚洲人成一区_欧美一级欧美三级在线观看

基于Mybatis手擼一個分表插件

運維 數據庫運維
事情是醬紫的,阿星的上級leader負責記錄信息的業務,每日預估數據量是15萬左右,所以引入sharding-jdbc做分表。

 [[399518]]

本文轉載自微信公眾號「程序猿阿星」,作者程序猿阿星。轉載本文請聯系程序猿阿星公眾號。

背景

事情是醬紫的,阿星的上級leader負責記錄信息的業務,每日預估數據量是15萬左右,所以引入sharding-jdbc做分表。

上級leader完成業務的開發后,走了一波自測,git push后,就忙其他的事情去了。

項目的框架是SpringBoot+Mybaits

出問題了

阿星負責的業務也開發完了,熟練的git pull,準備自測,單元測試run一下,上個廁所回來收工,就是這么自信。

回來后,看下控制臺,人都傻了,一片紅,內心不禁感嘆“如果這是股票基金該多好”。

出了問題就要解決,隨著排查深入,我的眉頭一皺發現事情并不簡單,怎么以前的一些代碼都報錯了?

隨著排查深入,最后跟到了Mybatis源碼,發現罪魁禍首是sharding-jdbc引起的,因為數據源是sharding-jdbc的,導致后續執行sql的是ShardingPreparedStatement。

這就意味著,sharding-jdbc影響項目的所有業務表,因為最終數據庫交互都由ShardingPreparedStatement去做了,歷史的一些sql語句因為sql函數或者其他寫法,使得ShardingPreparedStatement無法處理而出現異常。

關鍵代碼如下

發現問題后,阿星馬上就反饋給leader了。

唉,本來還想摸魚的,看來摸魚的時間是沒了,還多了一項任務。

分析

竟然交給阿星來做了,就擼起袖子開干吧,先看看分表功能的需求

  • 支持自定義分表策略
  • 能控制影響范圍
  • 通用性

分表會提前建立好,所以不需要考慮表不存在的問題,核心邏輯實現,通過分表策略得到分表名,再把分表名動態替換到sql。

分表策略

為了支持分表策略,我們需要先定義分表策略抽象接口,定義如下

  1. /** 
  2.  * @Author 程序猿阿星 
  3.  * @Description 分表策略接口 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. public interface ITableShardStrategy { 
  7.  
  8.  
  9.     /** 
  10.      * @author: 程序猿阿星 
  11.      * @description: 生成分表名 
  12.      * @param tableNamePrefix 表前綴名 
  13.      * @param value 值 
  14.      * @date: 2021/5/9 
  15.      * @return: java.lang.String 
  16.      */ 
  17.     String generateTableName(String tableNamePrefix,Object value); 
  18.  
  19.     /** 
  20.      * 驗證tableNamePrefix 
  21.      */ 
  22.     default void verificationTableNamePrefix(String tableNamePrefix){ 
  23.         if (StrUtil.isBlank(tableNamePrefix)) { 
  24.             throw new RuntimeException("tableNamePrefix is null"); 
  25.         } 
  26.     } 

generateTableName函數的任務就是生成分表名,入參有tableNamePrefix、value,tableNamePrefix為分表前綴,value作為生成分表名的邏輯參數。

verificationTableNamePrefix函數驗證tableNamePrefix必填,提供給實現類使用。

為了方便理解,下面是id取模策略代碼,取模兩張表

  1. /** 
  2.  * @Author 程序猿阿星 
  3.  * @Description 分表策略id 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. @Component 
  7. public class TableShardStrategyId implements ITableShardStrategy { 
  8.     @Override 
  9.     public String generateTableName(String tableNamePrefix, Object value) { 
  10.         verificationTableNamePrefix(tableNamePrefix); 
  11.         if (value == null || StrUtil.isBlank(value.toString())) { 
  12.             throw new RuntimeException("value is null"); 
  13.         } 
  14.         long id = Long.parseLong(value.toString()); 
  15.         //此處可以緩存優化 
  16.         return tableNamePrefix + "_" + (id % 2); 
  17.     } 

傳入進來的value是id值,用tableNamePrefix拼接id取模后的值,得到分表名返回。

控制影響范圍

分表策略已經抽象出來,下面要考慮控制影響范圍,我們都知道Mybatis規范中每個Mapper類對應一張業務主體表,Mapper類的函數對應業務主體表的相關sql。

阿星想著,可以給Mapper類打上注解,代表該Mpaaer類對應的業務主體表有分表需求,從規范來說Mapper類的每個函數對應的主體表都是正確的,但是有些同學可能不會按規范來寫。

假設Mpaaer類對應的是B表,Mpaaer類的某個函數寫著A表的sql,甚至是歷史遺留問題,所以注解不僅僅可以打在Mapper類上,同時還可以打在Mapper類的任意一個函數上,并且保證小粒度覆蓋粗粒度。

阿星這里自定義分表注解,代碼如下

 

  1. /** 
  2.  * @Author 程序猿阿星 
  3.  * @Description 分表注解 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. @Target(value = {ElementType.TYPE,ElementType.METHOD}) 
  7. @Retention(RetentionPolicy.RUNTIME) 
  8. public @interface TableShard { 
  9.  
  10.     // 表前綴名 
  11.     String tableNamePrefix(); 
  12.  
  13.     //值 
  14.     String value() default ""
  15.  
  16.     //是否是字段名,如果是需要解析請求參數改字段名的值(默認否) 
  17.     boolean fieldFlag() default false
  18.  
  19.     // 對應的分表策略類 
  20.     Class<? extends ITableShardStrategy> shardStrategy(); 
  21.  
  22.  

注解的作用范圍是類、接口、函數,運行時生效。

tableNamePrefix與shardStrategy屬性都好理解,表前綴名和分表策略,剩下的value與fieldFlag要怎么理解,分表策略分兩類,第一類依賴表中某個字段值,第二類則不依賴。

根據企業id取模,屬于第一類,此處的value設置企業id入參字段名,fieldFlag為true,意味著,會去解析獲取企業id字段名對應的值。

根據日期分表,屬于第二類,直接在分表策略實現類里面寫就行了,不依賴表字段值,value與fieldFlag無需填寫,當然你value也可以設置時間格式,具體看分表策略實現類的邏輯。

通用性

抽象分表策略與分表注解都搞定了,最后一步就是根據分表注解信息,去執行分表策略得到分表名,再把分表名動態替換到sql中,同時具有通用性。

Mybatis框架中,有攔截器機制做擴展,我們只需要攔截StatementHandler#prepare函數,即StatementHandle創建Statement之前,先把sql里面的表名動態替換成分表名。

Mybatis分表攔截器流程圖如下

Mybatis分表攔截器代碼如下,有點長哈,主流程看intercept函數就好了。

  1. /** 
  2.  * @Author 程序員阿星 
  3.  * @Description 分表攔截器 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. @Intercepts({ 
  7.         @Signature( 
  8.                 type = StatementHandler.class, 
  9.                 method = "prepare"
  10.                 args = {Connection.class, Integer.class} 
  11.         ) 
  12. }) 
  13. public class TableShardInterceptor implements Interceptor { 
  14.  
  15.     private static final ReflectorFactory defaultReflectorFactory = new DefaultReflectorFactory(); 
  16.  
  17.     @Override 
  18.     public Object intercept(Invocation invocation) throws Throwable { 
  19.  
  20.         // MetaObject是mybatis里面提供的一個工具類,類似反射的效果 
  21.         MetaObject metaObject = getMetaObject(invocation); 
  22.         BoundSql boundSql = (BoundSql) metaObject.getValue("delegate.boundSql"); 
  23.         MappedStatement mappedStatement = (MappedStatement) 
  24.                 metaObject.getValue("delegate.mappedStatement"); 
  25.  
  26.         //獲取Mapper執行方法 
  27.         Method method = invocation.getMethod(); 
  28.  
  29.         //獲取分表注解 
  30.         TableShard tableShard = getTableShard(method,mappedStatement); 
  31.  
  32.         // 如果method與class都沒有TableShard注解或執行方法不存在,執行下一個插件邏輯 
  33.         if (tableShard == null) { 
  34.             return invocation.proceed(); 
  35.         } 
  36.  
  37.         //獲取值 
  38.         String value = tableShard.value(); 
  39.         //value是否字段名,如果是,需要解析請求參數字段名的值 
  40.         boolean fieldFlag = tableShard.fieldFlag(); 
  41.  
  42.         if (fieldFlag) { 
  43.             //獲取請求參數 
  44.             Object parameterObject = boundSql.getParameterObject(); 
  45.  
  46.             if (parameterObject instanceof MapperMethod.ParamMap) { //ParamMap類型邏輯處理 
  47.  
  48.                 MapperMethod.ParamMap parameterMap = (MapperMethod.ParamMap) parameterObject; 
  49.                 //根據字段名獲取參數值 
  50.                 Object valueObject = parameterMap.get(value); 
  51.                 if (valueObject == null) { 
  52.                     throw new RuntimeException(String.format("入參字段%s無匹配", value)); 
  53.                 } 
  54.                 //替換sql 
  55.                 replaceSql(tableShard, valueObject, metaObject, boundSql); 
  56.  
  57.             } else { //單參數邏輯 
  58.  
  59.                 //如果是基礎類型拋出異常 
  60.                 if (isBaseType(parameterObject)) { 
  61.                     throw new RuntimeException("單參數非法,請使用@Param注解"); 
  62.                 } 
  63.  
  64.                 if (parameterObject instanceof Map){ 
  65.                     Map<String,Object>  parameterMap =  (Map<String,Object>)parameterObject; 
  66.                     Object valueObject = parameterMap.get(value); 
  67.                     //替換sql 
  68.                     replaceSql(tableShard, valueObject, metaObject, boundSql); 
  69.                 } else { 
  70.                     //非基礎類型對象 
  71.                     Class<?> parameterObjectClass = parameterObject.getClass(); 
  72.                     Field declaredField = parameterObjectClass.getDeclaredField(value); 
  73.                     declaredField.setAccessible(true); 
  74.                     Object valueObject = declaredField.get(parameterObject); 
  75.                     //替換sql 
  76.                     replaceSql(tableShard, valueObject, metaObject, boundSql); 
  77.                 } 
  78.             } 
  79.  
  80.         } else {//無需處理parameterField 
  81.             //替換sql 
  82.             replaceSql(tableShard, value, metaObject, boundSql); 
  83.         } 
  84.         //執行下一個插件邏輯 
  85.         return invocation.proceed(); 
  86.     } 
  87.  
  88.  
  89.     @Override 
  90.     public Object plugin(Object target) { 
  91.         // 當目標類是StatementHandler類型時,才包裝目標類,否者直接返回目標本身, 減少目標被代理的次數 
  92.         if (target instanceof StatementHandler) { 
  93.             return Plugin.wrap(target, this); 
  94.         } else { 
  95.             return target; 
  96.         } 
  97.     } 
  98.  
  99.  
  100.     /** 
  101.      * @param object 
  102.      * @methodName: isBaseType 
  103.      * @author: 程序員阿星 
  104.      * @description: 基本數據類型驗證,true是,false否 
  105.      * @date: 2021/5/9 
  106.      * @return: boolean 
  107.      */ 
  108.     private boolean isBaseType(Object object) { 
  109.         if (object.getClass().isPrimitive() 
  110.                 || object instanceof String 
  111.                 || object instanceof Integer 
  112.                 || object instanceof Double 
  113.                 || object instanceof Float 
  114.                 || object instanceof Long 
  115.                 || object instanceof Boolean 
  116.                 || object instanceof Byte 
  117.                 || object instanceof Short) { 
  118.             return true
  119.         } else { 
  120.             return false
  121.         } 
  122.     } 
  123.  
  124.     /** 
  125.      * @param tableShard 分表注解 
  126.      * @param value      值 
  127.      * @param metaObject mybatis反射對象 
  128.      * @param boundSql   sql信息對象 
  129.      * @author: 程序猿阿星 
  130.      * @description: 替換sql 
  131.      * @date: 2021/5/9 
  132.      * @return: void 
  133.      */ 
  134.     private void replaceSql(TableShard tableShard, Object value, MetaObject metaObject, BoundSql boundSql) { 
  135.         String tableNamePrefix = tableShard.tableNamePrefix(); 
  136.         //獲取策略class 
  137.         Class<? extends ITableShardStrategy> strategyClazz = tableShard.shardStrategy(); 
  138.         //從spring ioc容器獲取策略類 
  139.  
  140.         ITableShardStrategy tableShardStrategy = SpringUtil.getBean(strategyClazz); 
  141.         //生成分表名 
  142.         String shardTableName = tableShardStrategy.generateTableName(tableNamePrefix, value); 
  143.         // 獲取sql 
  144.         String sql = boundSql.getSql(); 
  145.         // 完成表名替換 
  146.         metaObject.setValue("delegate.boundSql.sql", sql.replaceAll(tableNamePrefix, shardTableName)); 
  147.     } 
  148.  
  149.     /** 
  150.      * @param invocation 
  151.      * @author: 程序猿阿星 
  152.      * @description: 獲取MetaObject對象-mybatis里面提供的一個工具類,類似反射的效果 
  153.      * @date: 2021/5/9 
  154.      * @return: org.apache.ibatis.reflection.MetaObject 
  155.      */ 
  156.     private MetaObject getMetaObject(Invocation invocation) { 
  157.         StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); 
  158.         // MetaObject是mybatis里面提供的一個工具類,類似反射的效果 
  159.         MetaObject metaObject = MetaObject.forObject(statementHandler, 
  160.                 SystemMetaObject.DEFAULT_OBJECT_FACTORY, 
  161.                 SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY, 
  162.                 defaultReflectorFactory 
  163.         ); 
  164.  
  165.         return metaObject; 
  166.     } 
  167.  
  168.     /** 
  169.      * @author: 程序猿阿星 
  170.      * @description: 獲取分表注解 
  171.      * @param method 
  172.      * @param mappedStatement 
  173.      * @date: 2021/5/9 
  174.      * @return: com.xing.shard.interceptor.TableShard 
  175.      */ 
  176.     private TableShard getTableShard(Method method, MappedStatement mappedStatement) throws ClassNotFoundException { 
  177.         String id = mappedStatement.getId(); 
  178.         //獲取Class 
  179.         final String className = id.substring(0, id.lastIndexOf(".")); 
  180.         //分表注解 
  181.         TableShard tableShard = null
  182.         //獲取Mapper執行方法的TableShard注解 
  183.         tableShard = method.getAnnotation(TableShard.class); 
  184.         //如果方法沒有設置注解,從Mapper接口上面獲取TableShard注解 
  185.         if (tableShard == null) { 
  186.             // 獲取TableShard注解 
  187.             tableShard = Class.forName(className).getAnnotation(TableShard.class); 
  188.         } 
  189.         return tableShard; 
  190.     } 
  191.  

到了這里,其實分表功能就已經完成了,我們只需要把分表策略抽象接口、分表注解、分表攔截器抽成一個通用jar包,需要使用的項目引入這個jar,然后注冊分表攔截器,自己根據業務需求實現分表策略,在給對應的Mpaaer加上分表注解就好了。

實踐跑起來

這里阿星單獨寫了一套demo,場景是有兩個分表策略,表也提前建立好了

  • 根據id分表
    • tb_log_id_0
    • tb_log_id_1
  • 根據日期分表
    • tb_log_date_202105
    • tb_log_date_202106

預警:后面都是代碼實操環節,請各位讀者大大耐心看完(非Java開發除外)。

TableShardStrategy定義

  1. /** 
  2.  * @Author wx 
  3.  * @Description 分表策略日期 
  4.  * @Date 2021/5/9 
  5.  */ 
  6. @Component 
  7. public class TableShardStrategyDate implements ITableShardStrategy { 
  8.  
  9.     private static final String DATE_PATTERN = "yyyyMM"
  10.  
  11.     @Override 
  12.     public String generateTableName(String tableNamePrefix, Object value) { 
  13.         verificationTableNamePrefix(tableNamePrefix); 
  14.         if (value == null || StrUtil.isBlank(value.toString())) { 
  15.             return tableNamePrefix + "_" +DateUtil.format(new Date(), DATE_PATTERN); 
  16.         } else { 
  17.             return tableNamePrefix + "_" +DateUtil.format(new Date(), value.toString()); 
  18.         } 
  19.     } 
  20.  
  21.  
  22.  
  23. ** 
  24.  * @Author 程序猿阿星 
  25.  * @Description 分表策略id 
  26.  * @Date 2021/5/9 
  27.  */ 
  28. @Component 
  29. public class TableShardStrategyId implements ITableShardStrategy { 
  30.     @Override 
  31.     public String generateTableName(String tableNamePrefix, Object value) { 
  32.         verificationTableNamePrefix(tableNamePrefix); 
  33.         if (value == null || StrUtil.isBlank(value.toString())) { 
  34.             throw new RuntimeException("value is null"); 
  35.         } 
  36.         long id = Long.parseLong(value.toString()); 
  37.         //可以加入本地緩存優化 
  38.         return tableNamePrefix + "_" + (id % 2); 
  39.     } 

Mapper定義

Mapper接口

  1. /** 
  2.  * @Author 程序猿阿星 
  3.  * @Description 
  4.  * @Date 2021/5/8 
  5.  */ 
  6. @TableShard(tableNamePrefix = "tb_log_date",shardStrategy = TableShardStrategyDate.class) 
  7. public interface LogDateMapper { 
  8.  
  9.     /** 
  10.      * 查詢列表-根據日期分表 
  11.      */ 
  12.     List<LogDate> queryList(); 
  13.  
  14.     /** 
  15.      * 單插入-根據日期分表 
  16.      */ 
  17.     void  save(LogDate logDate); 
  18.  
  19.  
  20.  
  21. ------------------------------------------------------------------------------------------------- 
  22.  
  23.  
  24. /** 
  25.  * @Author 程序猿阿星 
  26.  * @Description 
  27.  * @Date 2021/5/8 
  28.  */ 
  29. @TableShard(tableNamePrefix = "tb_log_id",value = "id",fieldFlag = true,shardStrategy = TableShardStrategyId.class) 
  30. public interface LogIdMapper { 
  31.  
  32.     /** 
  33.      * 根據id查詢-根據id分片 
  34.      */ 
  35.     LogId queryOne(@Param("id") long id); 
  36.  
  37.     /** 
  38.      * 單插入-根據id分片 
  39.      */ 
  40.     void save(LogId logId); 
  41.  
  42.  

Mapper.xml

  1. <?xml version="1.0" encoding="UTF-8" ?> 
  2. <!DOCTYPE mapper 
  3.         PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
  4.         "http://mybatis.org/dtd/mybatis-3-mapper.dtd"
  5. <mapper namespace="com.xing.shard.mapper.LogDateMapper"
  6.      
  7.     //對應LogDateMapper#queryList函數 
  8.     <select id="queryList" resultType="com.xing.shard.entity.LogDate"
  9.         select 
  10.         id as id, 
  11.         comment as comment, 
  12.         create_date as createDate 
  13.         from 
  14.         tb_log_date 
  15.     </select
  16.      
  17.     //對應LogDateMapper#save函數 
  18.     <insert id="save" > 
  19.         insert into tb_log_date(id, comment,create_date) 
  20.         values (#{id}, #{comment},#{createDate}) 
  21.     </insert
  22. </mapper> 
  23.  
  24. ------------------------------------------------------------------------------------------------- 
  25.  
  26. <?xml version="1.0" encoding="UTF-8" ?> 
  27. <!DOCTYPE mapper 
  28.         PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" 
  29.         "http://mybatis.org/dtd/mybatis-3-mapper.dtd"
  30. <mapper namespace="com.xing.shard.mapper.LogIdMapper"
  31.      
  32.     //對應LogIdMapper#queryOne函數 
  33.     <select id="queryOne" resultType="com.xing.shard.entity.LogId"
  34.         select 
  35.         id as id, 
  36.         comment as comment, 
  37.         create_date as createDate 
  38.         from 
  39.         tb_log_id 
  40.         where 
  41.         id = #{id} 
  42.     </select
  43.      
  44.     //對應save函數 
  45.     <insert id="save" > 
  46.         insert into tb_log_id(id, comment,create_date) 
  47.         values (#{id}, #{comment},#{createDate}) 
  48.     </insert
  49.  
  50. </mapper> 

執行下單元測試

日期分表單元測試執行

  1. @Test 
  2.   void test() { 
  3.       LogDate logDate = new LogDate(); 
  4.       logDate.setId(snowflake.nextId()); 
  5.       logDate.setComment("測試內容"); 
  6.       logDate.setCreateDate(new Date()); 
  7.       //插入 
  8.       logDateMapper.save(logDate); 
  9.       //查詢 
  10.       List<LogDate> logDates = logDateMapper.queryList(); 
  11.       System.out.println(JSONUtil.toJsonPrettyStr(logDates)); 
  12.   } 

輸出結果

id分表單元測試執行

  1. @Test 
  2. void test() { 
  3.     LogId logId = new LogId(); 
  4.     long id = snowflake.nextId(); 
  5.     logId.setId(id); 
  6.     logId.setComment("測試"); 
  7.     logId.setCreateDate(new Date()); 
  8.     //插入 
  9.     logIdMapper.save(logId); 
  10.     //查詢 
  11.     LogId logIdObject = logIdMapper.queryOne(id); 
  12.     System.out.println(JSONUtil.toJsonPrettyStr(logIdObject)); 

輸出結果

小結一下

本文可以當做對Mybatis進階的使用教程,通過Mybatis攔截器實現分表的功能,滿足基本的業務需求,雖然比較簡陋,但是Mybatis這種擴展機制與設計值得學習思考。

有興趣的讀者也可以自己寫一個,或基于阿星的做改造,畢竟是簡陋版本,還是有很多場景沒有考慮到。

 

另外分表的demo項目,阿星放到了Gitee和公眾號,大家按需自取

 

責任編輯:武曉燕 來源: 程序猿阿星
相關推薦

2021-10-27 06:49:34

線程池Core函數

2021-10-04 09:29:41

對象池線程池

2022-03-01 11:38:51

RPC框架后端

2022-02-08 09:09:45

智能指針C++

2021-11-02 14:19:15

插件MavengroupId

2021-11-04 17:23:03

Java對象 immutable

2022-01-21 07:35:06

LRU緩存java

2022-04-22 08:22:50

MVCCMySQLC++

2021-10-12 09:24:02

Java線程池源碼

2022-05-20 12:24:45

分庫分表Java依賴

2020-12-09 08:34:24

css生成器設計師

2022-03-01 08:21:32

工具類代碼封裝網絡請求

2022-02-14 07:34:23

工具類GET、POST

2021-05-31 07:22:46

ORM框架程序

2022-11-08 15:14:17

MyBatis插件

2020-11-04 07:56:19

工具Linux 翻譯

2021-02-22 11:13:17

VS Code代碼編程

2021-06-07 12:08:06

iOS Python API

2024-02-19 00:00:00

Redis分布式

2020-08-03 08:10:52

UDPTCP通信
點贊
收藏

51CTO技術棧公眾號

主站蜘蛛池模板: 欧美日韩高清免费 | 91精品亚洲| 激情国产在线 | 成人免费视频 | 一区二区高清不卡 | 欧美2区| 国产精品视频一区二区三区 | 91在线一区 | 欧美精品一区二区三区四区 在线 | 国产精品成人在线播放 | 中文字幕亚洲一区二区三区 | 久久久国产视频 | 亚洲精品天堂 | 一级毛片视频在线 | 五月婷婷丁香婷婷 | 日韩国产一区 | 国产精品国产精品 | 一区二区三区国产 | 欧美日韩久久精品 | av国产精品毛片一区二区小说 | 日韩视频在线观看中文字幕 | 亚洲一区二区三区免费观看 | 亚洲欧美日韩网站 | 欧美成人高清视频 | 国产九九精品视频 | 一本一道久久a久久精品综合蜜臀 | 一区二区免费看 | 欧美亚洲视频 | 毛片一级片 | 日韩一区在线播放 | 亚洲精品久久久久久久不卡四虎 | 精品一区二区视频 | 日本在线视频一区二区 | 欧美精品影院 | 国产一区二区精品在线 | 午夜男人免费视频 | 日本天堂视频在线观看 | 精品久久久久久久久久久久久久久久久 | 日韩欧美国产精品综合嫩v 一区中文字幕 | 午夜精品久久久久久久久久久久久 | 福利二区 |