• Guava:好用的java类库 学习小记


    基础功能

    google guava中定义的String操作

    在google guava中为字符串操作提供了很大的便利,有老牌的判断字符串是否为空字符串或者为null,用指定字符填充字符串,以及拆分合并字符串,字符串匹配的判断等等。

    1. 使用com.google.common.base.Strings类的isNullOrEmpty(input)方法判断字符串是否为空

    1        //Strings.isNullOrEmpty(input) demo
    2         String input = "";
    3         boolean isNullOrEmpty = Strings.isNullOrEmpty(input);
    4         System.out.println("input " + (isNullOrEmpty?"is":"is not") + " null or empty.");

    2. 获得两个字符串相同的前缀或者后缀

     1        //Strings.commonPrefix(a,b) demo
     2         String a = "com.jd.coo.Hello";
     3         String b = "com.jd.coo.Hi";
     4         String ourCommonPrefix = Strings.commonPrefix(a,b);
     5         System.out.println("a,b common prefix is " + ourCommonPrefix);
     6 
     7         //Strings.commonSuffix(a,b) demo
     8         String c = "com.google.Hello";
     9         String d = "com.jd.Hello";
    10         String ourSuffix = Strings.commonSuffix(c,d);
    11         System.out.println("c,d common suffix is " + ourSuffix);

    3. Strings的padStart和padEnd方法来补全字符串

    1         int minLength = 4;
    2         String padEndResult = Strings.padEnd("123", minLength, '0');
    3         System.out.println("padEndResult is " + padEndResult);
    4 
    5         String padStartResult = Strings.padStart("1", 2, '0');
    6         System.out.println("padStartResult is " + padStartResult);

    4. 使用Splitter类来拆分字符串

    Splitter类可以方便的根据正则表达式来拆分字符串,可以去掉拆分结果中的空串,可以对拆分后的字串做trim操作,还可以做二次拆分。

    我们先看一个基本的拆分例子:

            Iterable<String> splitResults = Splitter.onPattern("[,,]{1,}")
                    .trimResults()
                    .omitEmptyStrings()
                    .split("hello,word,,世界,水平");
    
            for (String item : splitResults) {
                System.out.println(item);
            }

    Splitter的onPattern方法传入的是一个正则表达式,其后紧跟的trimResults()方法表示要对结果做trim,omitEmptyStrings()表示忽略空字符串,split方法会执行拆分操作。

    split返回的结果为Iterable<String>,我们可以使用for循环语句来逐个打印拆分字符串的结果。

    Splitter还有更强大的功能,做二次拆分,这里二次拆分的意思是拆分两次,例如我们可以将a=b;c=d这样的字符串拆分成一个Map<String,String>。

    1         String toSplitString = "a=b;c=d,e=f";
    2         Map<String,String> kvs = Splitter.onPattern("[,;]{1,}").withKeyValueSeparator('=').split(toSplitString);
    3         for (Map.Entry<String,String> entry : kvs.entrySet()) {
    4             System.out.println(String.format("%s=%s", entry.getKey(),entry.getValue()));
    5         }

    二次拆分首先是使用onPattern做第一次的拆分,然后再通过withKeyValueSeperator('')方法做第二次的拆分。

    5. 有拆分字符串必然就有合并字符串,guava为我们提供了Joiner类来做字符串的合并

    我们先看一个简单的示例:

    1         String joinResult = Joiner.on(" ").join(new String[]{"hello","world"});
    2         System.out.println(joinResult);

    面例子中我们使用Joiner.on(" ").join(xx)来合并字符串。很简单也很有效。

    Splitter方法可以对字符串做二次的拆分,对应的Joiner也可以逆向操作,将Map<String,String>做合并。我们看下下面的例子:

    1         Map<String,String> map = new HashMap<String,String>();
    2         map.put("a", "b");
    3         map.put("c", "d");
    4         String mapJoinResult = Joiner.on(",").withKeyValueSeparator("=").join(map);
    5         System.out.println(mapJoinResult);

    使用withKeyValueSeparator方法可以对map做合并。合并的结果是:a=b,c=d

    guava库中还可以对字符串做大小写转换(CaseFormat枚举),可以对字符串做模式匹配。使用起来都很方便。

    guava中的对象操作封装

    在开发中经常会需要比较两个对象是否相等,这时候我们需要考虑比较的两个对象是否为null,然后再调用equals方法来比较是否相等,google guava库的com.google.common.base.Objects类提供了一个静态方法equals可以避免我们自己做是否为空的判断,示例如下:

    1  Object a = null;
    2  Object b = new Object();
    3 boolean aEqualsB = Objects.equal(a, b);

    Objects.equals的实现是很完美的,其实现代码如下:

    1 public static boolean equal(@Nullable Object a, @Nullable Object b) {
    2     return a == b || (a != null && a.equals(b));
    3   }

    首先判断a b是否是同一个对象,如果是同一对象,那么直接返回相等,如果不是同一对象再判断a不为null并且a.equals(b). 这样做既考虑了性能也考虑了null空指针的问题。

    另外Objects类中还为我们提供了方便的重写toString()方法的机制,我们通过例子来了解一下吧:

     1 import com.google.common.base.Objects;
     2 
     3 public class ObjectsDemo {
     4     public static void main(String [] args) {
     5       Student jim = new Student();
     6         jim.setId(1);
     7         jim.setName("Jim");
     8         jim.setAge(13);
     9         System.out.println(jim.toString());
    10     }
    11 
    12     public static class Student {
    13         private int id;
    14         private String name;
    15         private int age;
    16 
    17         public int getId() {
    18             return id;
    19         }
    20         public void setId(int id) {
    21             this.id = id;
    22         }
    23 
    24         public String getName() {
    25             return name;
    26         }
    27         public void setName(String name) {
    28             this.name = name;
    29         }
    30 
    31         public int getAge() {
    32             return age;
    33         }
    34         public void setAge(int age) {
    35             this.age = age;
    36         }
    37 
    38         public String toString() {
    39             return Objects.toStringHelper(this.getClass())
    40                     .add("id", id)
    41                     .add("name", name)
    42                     .add("age", age)
    43                     .omitNullValues().toString();
    44         }
    45     }
    46 }

    我们定义了一个Student类,该类有三个属性,分别为id,name,age,我们重写了toString()方法,在这个方法中我们使用了Objects.toStringHelper方法,首先指定toString的类,然后依次add属性名称和属性值,可以使用omitNullValues()方法来指定忽略空值,最后调用其toString()方法,就可以得到一个格式很好的toString实现了。

    上面代码输出的结果是:

    Student{id=1, name=Jim, age=13}

    这种方式写起来很简单,可读性也很好,所以用Guava吧。

    guava的Preconditions使用

    guava的base包中提供的Preconditions类用来方便的做参数的校验,他主要提供如下方法:

    1. checkArgument 接受一个boolean类型的参数和一个可选的errorMsg参数,这个方法用来判断参数是否符合某种条件,符合什么条件google guava不关心,在不符合条件时会抛出IllegalArgumentException异常
    2. checkState 和checkArgument参数和实现基本相同,从字面意思上我们也可以知道这个方法是用来判断状态是否正确的,如果状态不正确会抛出IllegalStateException异常
    3. checkNotNull方法用来判断参数是否不是null,如果为null则会抛出NullPointerException空指针异常
    4. checkElementIndex方法用来判断用户传入的数组下标或者list索引位置,是否是合法的,如果不合法会抛出IndexOutOfBoundsException
    5. checkPositionIndexes方法的作用和checkElementIndex方法相似,只是此方法的索引范围是从0到size包括size,而上面的方法不包括size。

    下面我们看一个具体的使用示例:

     1 import com.google.common.base.Preconditions;
     2 
     3 public class PreconditionsDemo {
     4     public static void main(String[] args) {
     5         PreconditionsDemo demo = new PreconditionsDemo();
     6         demo.doSomething("Jim", 19, "hello world, hello java");
     7     }
     8 
     9     public void doSomething(String name, int age, String desc) {
    10         Preconditions.checkNotNull(name, "name may not be null");
    11         Preconditions.checkArgument(age >= 18 && age < 99, "age must in range (18,99)");
    12         Preconditions.checkArgument(desc !=null && desc.length() < 10, "desc too long, max length is ", 10);
    13 
    14         //do things
    15     }
    16 }

    上面例子中的doSomething()方法调用了三次Preconditions的方法,来对参数做校验。

    看似Preconditions实现很简单,他的意义在于为我们提供了同一的参数校验,并对不同的异常情况抛出合适类型的异常,并对异常信息做格式化。

    使用google guava的Optional接口来避免空指针错误

    null会带来很多问题,从开始有null开始有无数程序栽在null的手里,null的含义是不清晰的,检查null在大多数情况下是不得不做的,而我们又在很多时候忘记了对null做检查,在我们的产品真正投入使用的时候,空指针异常出现了,这是一种讨厌的情况。

    鉴于此google的guava库中提供了Optional接口来使null快速失败,即在可能为null的对象上做了一层封装,在使用Optional静态方法of时,如果传入的参数为null就抛出NullPointerException异常。

    我们看一个实际的例子:

    1 import com.google.common.base.Optional;
    2 
    3 public class OptionalDemo {
    4     public static void main(String[] args) {
    5         Optional<Student> possibleNull = Optional.of(null);
    6         possibleNull.get();
    7     }
    8     public static class Student { }
    9 }

    上面的程序,我们使用Optional.of(null)方法,这时候程序会第一时间抛出空指针异常,这可以帮助我们尽早发现问题。

    我们再看另外一个例子,我们使用Optional.absent方法来初始化posibleNull实例,然后我们get此对象,看看会是什么情况。

    1 public class OptionalDemo {
    2     public static void main(String[] args) {
    3         Optional<Student> possibleNull = Optional.absent();
    4         Student jim = possibleNull.get();
    5     }
    6     public static class Student { }
    7 }

    运行上面的程序,发现出现了:Exception in thread "main" java.lang.IllegalStateException: Optional.get() cannot be called on an absent value。

    这样使用也会有异常出来,那Optional到底有什么意义呢?

    使用Optional除了赋予null语义,增加了可读性,最大的优点在于它是一种傻瓜式的防护。Optional迫使你积极思考引用缺失的情况,因为你必须显式地从Optional获取引用。直接使用null很容易让人忘掉某些情形,尽管FindBugs可以帮助查找null相关的问题,但是我们还是认为它并不能准确地定位问题根源。

    如同输入参数,方法的返回值也可能是null。和其他人一样,你绝对很可能会忘记别人写的方法method(a,b)会返回一个null,就好像当你实现method(a,b)时,也很可能忘记输入参数a可以为null。将方法的返回类型指定为Optional,也可以迫使调用者思考返回的引用缺失的情形。

    google guava Throwables帮你抛出异常,处理异常

    guava类库中的Throwables提供了一些异常处理的静态方法,这些方法的从功能上分为两类,一类是帮你抛出异常,另外一类是帮你处理异常。

    也许你会想:为什么要帮我们处理异常呢?我们自己不会抛出异常吗?

    假定下面的方法是我们要调用的方法。

    1  public void doSomething() throws Throwable {
    2         //ignore method body
    3     }
    4 
    5     public void doSomethingElse() throws Exception {
    6         //ignore method body
    7     }

    这两个方法的签名一个throws出了Throwable另外一个throws出了Exception,他们没有定义具体会抛出什么异常,也就是说他们什么异常都有可能抛出来,如果我们要调用这样的方法,就需要对他们的异常做一些处理了,我们需要判断什么样的异常需要抛出去,什么样的异常需要封装成RuntimeException。而这些事情就是Throwables类要帮我们做的事情。

    假定我们要实现一个doIt的方法,该方法要调用doSomething方法,而doIt的定义中只允许抛出SQLException,我们可以这样做:

    1     public void doIt() throws SQLException {
    2         try {
    3             doSomething();
    4         } catch (Throwable throwable) {
    5             Throwables.propagateIfInstanceOf(throwable, SQLException.class);
    6             Throwables.propagateIfPossible(throwable);
    7         }
    8     }

    请注意doIt的catch块,下面这行代码的意思是如果异常的类型是SQLException,那么抛出这个异常

    Throwables.propagateIfInstanceOf(throwable, SQLException.class);

    第二行表示如果异常是Error类型,那么抛出这个类型,否则将抛出RuntimeException,我们知道RuntimeException是不需要在throws中声明的。

    Throwables.propagateIfPossible(throwable);

    Throwables类还为我们提供了一些方便的异常处理帮助方法:

    1. 我们可以通过Throwables.getRooCause(Throwable)获得根异常
    2. 可以使用getCausalChain方法获得异常的列表
    3. 可以通过getStackTraceAsString获得异常堆栈的字符串

    集合增强

    guava的不可变集合

    不可变集合的意义

    不可变对象有很多优点,包括:

    • 当对象被不可信的库调用时,不可变形式是安全的;
    • 不可变对象被多个线程调用时,不存在竞态条件问题
    • 不可变集合不需要考虑变化,因此可以节省时间和空间。所有不可变的集合都比它们的可变形式有更好的内存利用率(分析和测试细节);
    • 不可变对象因为有固定不变,可以作为常量来安全使用。

    创建对象的不可变拷贝是一项很好的防御性编程技巧。Guava为所有JDK标准集合类型和Guava新集合类型都提供了简单易用的不可变版本。
     JDK也提供了Collections.unmodifiableXXX方法把集合包装为不可变形式,但我们认为不够好:

    • 笨重而且累赘:不能舒适地用在所有想做防御性拷贝的场景;
    • 不安全:要保证没人通过原集合的引用进行修改,返回的集合才是事实上不可变的;
    • 低效:包装过的集合仍然保有可变集合的开销,比如并发修改的检查、散列表的额外空间,等等。

    如果你没有修改某个集合的需求,或者希望某个集合保持不变时,把它防御性地拷贝到不可变集合是个很好的实践。

    重要提示:所有Guava不可变集合的实现都不接受null值。我们对Google内部的代码库做过详细研究,发现只有5%的情况需要在集合中允许null元素,剩下的95%场景都是遇到null值就快速失败。如果你需要在不可变集合中使用null,请使用JDK中的Collections.unmodifiableXXX方法。更多细节建议请参考“使用和避免null”

    如何使用guava的不可变集合

    1. 如何创建不可变集合

    第一种方法使用builder创建:

     1 public class ImmutableDemo {
     2     public static void main(String[] args) {
     3         Set<String> immutableNamedColors = ImmutableSet.<String>builder()
     4                 .add("red", "green","black","white","grey")
     5                 .build();
     6         //immutableNamedColors.add("abc");
     7         for (String color : immutableNamedColors) {
     8             System.out.println(color);
     9         }
    10     }
    11 }

    第二种方法使用of静态方法创建:

            ImmutableSet.of("red","green","black","white","grey");

    第三种方法使用copyOf静态方法创建:

            ImmutableSet.copyOf(new String[]{"red","green","black","white","grey"});

    2. 使用asList()获得不可变集合的list视图

    asList方法是在ImmutableCollection中定义,而所有的不可变集合都会从ImmutableCollection继承,所以所有的不可变集合都会有asList()方法返回当前不可变集合的list视图,这个视图也是不可变的。

    3. 不可变集合的使用

    不可变集合的使用和普通集合一样,只是不能使用他们的add,remove等修改集合的方法。

    guava集合之Multiset

    Multiset看似是一个Set,但是实质上它不是一个Set,它没有继承Set接口,它继承的是Collection<E>接口,你可以向Multiset中添加重复的元素,Multiset会对添加的元素做一个计数。

    它本质上是一个Set加一个元素计数器。

     1 import com.google.common.base.Splitter;
     2 import com.google.common.collect.HashMultiset;
     3 import com.google.common.collect.Multiset;
     4 
     5 public class MultisetDemo {
     6     public static void main(String[] args) {
     7         Multiset multiset = HashMultiset.create();
     8         String sentences = "this is a story, there is a good girl in the story.";
     9         Iterable<String> words = Splitter.onPattern("[^a-z]{1,}").omitEmptyStrings().trimResults().split(sentences);
    10         for (String word : words) {
    11             multiset.add(word);
    12         }
    13 
    14         for (Object element : multiset.elementSet()) {
    15             System.out.println((String)element + ":" + multiset.count(element));
    16         }
    17     }
    18 }

    在上面的示例中我们对一段文字拆分成一个一个的单词,然后依次放入到multiset中,注意这段文字中有多个重复的单词,然后我们通过for循环遍历multiset中的每一个元素,并输出他们的计数。输出内容如下:

    story:2
    is:2
    girl:1
    there:1
    a:2
    good:1
    the:1
    in:1
    this:1

    显然计数不是问题,Multiset还提供了add和remove的重载方法,可以在add或这remove的同时指定计数的值。

    常用实现 Multiset 接口的类有:

    • HashMultiset: 元素存放于 HashMap
    • LinkedHashMultiset: 元素存放于 LinkedHashMap,即元素的排列顺序由第一次放入的顺序决定
    • TreeMultiset:元素被排序存放于TreeMap
    • EnumMultiset: 元素必须是 enum 类型
    • ImmutableMultiset: 不可修改的 Mutiset

    看到这里你可能已经发现 Guava Collections 都是以 create 或是 of 这样的静态方法来构造对象。这是因为这些集合类大多有多个参数的私有构造方法,由于参数数目很多,客户代码程序员使用起来就很不方便。而且以这种方式可以返回原类型的子类型对象。另外,对于创建范型对象来讲,这种方式更加简洁。

    google guava的BiMap:双向Map

    我们知道Map是一种键值对映射,这个映射是键到值的映射,而BiMap首先也是一种Map,他的特别之处在于,既提供键到值的映射,也提供值到键的映射,所以它是双向Map.

    想象这么一个场景,我们需要做一个星期几的中英文表示的相互映射,例如Monday对应的中文表示是星期一,同样星期一对应的英文表示是Monday。这是一个绝好的使用BiMap的场景。

     1 mport com.google.common.collect.BiMap;
     2 import com.google.common.collect.HashBiMap;
     3 
     4 public class BiMapDemo {
     5     public static void main(String[] args) {
     6         BiMap<String,String> weekNameMap = HashBiMap.create();
     7         weekNameMap.put("星期一","Monday");
     8         weekNameMap.put("星期二","Tuesday");
     9         weekNameMap.put("星期三","Wednesday");
    10         weekNameMap.put("星期四","Thursday");
    11         weekNameMap.put("星期五","Friday");
    12         weekNameMap.put("星期六","Saturday");
    13         weekNameMap.put("星期日","Sunday");
    14 
    15         System.out.println("星期日的英文名是" + weekNameMap.get("星期日"));
    16         System.out.println("Sunday的中文是" + weekNameMap.inverse().get("Sunday"));
    17     }
    18 }

    BiMap的值键对的Map可以通过inverse()方法得到。

    BiMap的常用实现有:

    1. HashBiMap: key 集合与 value 集合都有 HashMap 实现
    2. EnumBiMap: key 与 value 都必须是 enum 类型
    3. ImmutableBiMap: 不可修改的 BiMap

    google guava的Multimaps:一键多值的Map

    有时候我们需要这样的数据类型Map<String,Collection<String>>,guava中的Multimap就是为了解决这类问题的。

    Multimap的实现

    Multimap提供了丰富的实现,所以你可以用它来替代程序里的Map<K, Collection<V>>,具体的实现如下:

    实现 Key实现 Value实现
    ArrayListMultimap HashMap ArrayList
    HashMultimap HashMap HashSet
    LinkedListMultimap LinkedHashMap LinkedList
    LinkedHashMultimap LinkedHashMap LinkedHashSet
    TreeMultimap TreeMap TreeSet
    ImmutableListMultimap ImmutableMap ImmutableList
    ImmutableSetMultimap ImmutableMap ImmutableSet

    我们通过一个示例来了解Multimap的使用方法:

     1 public class MutliMapTest {  
     2     public static void main(String... args) {  
     3   Multimap<String, String> myMultimap = ArrayListMultimap.create();  
     4     
     5   // 添加键值对
     6   myMultimap.put("Fruits", "Bannana"); 
     7  //给Fruits元素添加另一个元素 
     8   myMultimap.put("Fruits", "Apple");  
     9   myMultimap.put("Fruits", "Pear");  
    10   myMultimap.put("Vegetables", "Carrot");  
    11     
    12   // 获得multimap的size
    13   int size = myMultimap.size();  
    14   System.out.println(size);  // 4  
    15     
    16   // 获得Fruits对应的所有的值
    17   Collection<string> fruits = myMultimap.get("Fruits");  
    18   System.out.println(fruits); // [Bannana, Apple, Pear]  
    19     
    20   Collection<string> vegetables = myMultimap.get("Vegetables");  
    21   System.out.println(vegetables); // [Carrot]  
    22     
    23   //遍历Mutlimap  
    24   for(String value : myMultimap.values()) {  
    25    System.out.println(value);  
    26   }  
    27     
    28   // Removing a single value  
    29   myMultimap.remove("Fruits","Pear");  
    30   System.out.println(myMultimap.get("Fruits")); // [Bannana, Pear]  
    31     
    32   // Remove all values for a key  
    33   myMultimap.removeAll("Fruits");  
    34   System.out.println(myMultimap.get("Fruits")); // [] (Empty Collection!)  
    35 }  
    36 }  

    google guava集合之Table

    在guava库中还提供了一种二维表结构:Table。使用Table可以实现二维矩阵的数据结构,可以是稀溜矩阵。

    我们看一个使用示例:

     1 public class TableDemo {
     2     public static void main(String[] args) {
     3         Table<Integer, Integer, String> table = HashBasedTable.create();
     4         for (int row = 0; row < 10; row++) {
     5             for (int column = 0; column < 5; column++) {
     6                 table.put(row, column, "value of cell (" + row + "," + column + ")");
     7             }
     8         }
     9         for (int row=0;row<table.rowMap().size();row++) {
    10             Map<Integer,String> rowData = table.row(row);
    11             for (int column =0;column < rowData.size(); column ++) {
    12                 System.out.println("cell(" + row + "," + column + ") value is:" + rowData.get(column));
    13             }
    14         }
    15     }
    16 }

    在上面示例中我们通过HashBasedTable创建了一个行类型为Integer,列类型也为Integer,值为String的Table。然后我们使用put方法向Table中添加了一些值,然后显示这些值

    Guava集合:使用Iterators简化Iterator操作

    Iterators是Guava中对Iterator迭代器操作的帮助类,这个类提供了很多有用的方法来简化Iterator的操作。

    1. 判断迭代器中的元素是否都满足某个条件 all 方法

     1         List<String> list = Lists.newArrayList("Apple","Pear","Peach","Banana");
     2 
     3         Predicate<String> condition = new Predicate<String>() {
     4             @Override
     5             public boolean apply(String input) {
     6                 return ((String)input).startsWith("P");
     7             }
     8         };
     9         boolean allIsStartsWithP = Iterators.all(list.iterator(), condition);
    10         System.out.println("all result == " + allIsStartsWithP);

    all方法的第一个参数是Iterator,第二个参数是Predicate<String>的实现,这个方法的意义是不需要我们自己去写while循环了,他的内部实现中帮我们做了循环,把循环体中的条件判断抽象出来了。

    2. 通过any判断迭代器中是否有一个满足条件的记录,any方法的参数和all方法一样,就不再具体举例了

    3. get方法获得迭代器中的第x个元素

     String secondElement = Iterators.get(list.iterator(), 1);

    4. filter方法过滤符合条件的项

    1        Iterator<String> startPElements = Iterators.filter(list.iterator(), new Predicate<String>() {
    2             @Override
    3             public boolean apply(String input) {
    4                 return input.startsWith("P");
    5             }
    6         });

    filter方法的第一个参数是源迭代器,第二个参数是Predicate的实现,其apply方法会返回当前元素是否符合条件。

    5. find方法返回符合条件的第一个元素

    1         String length5Element = Iterators.find(list.iterator(), new Predicate<String>() {
    2             @Override
    3             public boolean apply(String input) {
    4                 return input.length() == 5;
    5             }
    6         });

    6. transform方法,对迭代器元素做转换

    1         Iterator<Integer> countIterator = Iterators.transform(list.iterator(), new Function<String, Integer>() {
    2             @Override
    3             public Integer apply(String input) {
    4                 return input.length();
    5             }
    6         });

    上面的例子中我们将字符串转换成了其长度,transform方法输出的是另外一个Iterator.

  • 相关阅读:
    PNG 格式小图标的 CSS 任意颜色赋色技术
    CSS导航栏下划线跟随效果
    canvas 画表格、填数据、连线、拖拽、鼠标滚轮缩放
    【IE浏览器】GET请求防止读取缓存数据的解决方法
    JavaWeb——使用JavaBean
    数字签名——确保数据来源的可认证(鉴别)性和数据发送行为的不可否认性
    信息完整性验证技术——散列函数提供的消息认证技术
    OSI安全体系结构
    (持续更新)JavaScript
    (持续更新)SVN使用总结
  • 原文地址:https://www.cnblogs.com/wihainan/p/7091775.html
Copyright © 2020-2023  润新知