java streams_重构到Eclipse集合:使Java Streams更精简,更干净

java streams

重要要点

  • Eclipse Collections是一个用于Java的高性能collections框架,为本机JDK Collections添加了丰富的功能。
  • 尽管流是JDK的受欢迎的补充,但它们仍然遭受许多缺少的功能,对遗留集合实现的依赖以及冗长的API
  • Eclipse Collections提供了传统JDK数据结构的直接替代品,并支持Bag和Multimap等其他数据结构
  • 将流重构为Eclipse Collections可以帮助提高代码可读性并减少内存占用
  • 最重要的是,重构到Eclipse Collections很容易!

Java 8中引入的Java Streams很棒,它们使您能够充分利用lambda表达式,用捕获通用迭代模式的方法替换重复代码,从而获得更多功能代码。

但是,与流一样,尽管有很多改进,但它们最终只是现有收集框架的扩展,因此会带来很多负担。

我们可以进一步改善吗? 我们可以拥有更丰富的界面以及更清晰,更易读的代码吗? 与传统的收集实现相比,我们可以实现明显的内存节省吗? 我们可以对函数式编程范例提供更好,更无缝的支持吗?

答案是肯定的! Eclipse Collections (以前的GS Collections)是Java Collections框架的直接替代品,可以做到这一点。

在本文中,我们将演示几个将标准Java代码重构为Eclipse Collections数据结构和API的示例,并演示一些可以节省的内存。

这里将有很多代码示例,它们将说明如何将使用标准Java集合和流的代码更改为使用Eclipse Collection框架的代码。

但是在深入研究代码之前,我们将花费一些时间来了解什么是Eclipse Collections,为什么需要它,以及为什么您可能希望将惯用的Java重构为Eclipse Collections。

Eclipse收藏历史

Eclipse Collections最初是在高盛(Goldman Sachs)创建的,用于具有非常大的分布式缓存组件的应用程序平台。 该系统仍在生产中,将数百GB的数据存储在内存中。

实际上,缓存只是一个映射。 我们将对象存储在那里并将其取出。 这些对象可以包含其他地图和集合。 最初,高速缓存基于来自包java.util的标准数据结构。 *。 但是很快就发现,这些集合有两个明显的缺点:内存使用效率低下以及接口非常有限(导致难以读取和重复代码的综合症)。 由于问题根源于收集实现,因此无法使用实用程序库修补缓存代码。 为了立即解决这两个问题,高盛公司决定完全从头开始创建一个新的Collection框架。

当时,这似乎是一个有点激进的解决方案,但它确实起作用。 现在,该框架在Eclipse Foundation的保护下。

在本文的最后,我们共享了一些链接,这些链接将帮助您找到有关项目本身的更多信息,以及学习使用Eclipse Collections并为其做出贡献的方法。

为什么要重构到Eclipse集合?

Eclipse Collections有什么好处? 凭借其更丰富的API,有效的内存使用和更好的性能,我们认为Eclipse Collections是Java上最丰富的集合库。 它还被设计为与从JDK获得的集合完全兼容。

轻松迁移

在我们深入了解这些好处之前,需要注意的是,移至Eclipse Collections很容易,而且您不必一次完成所有操作。 Eclipse Collections包括JDK java.util。* List,Set和Map接口的完全兼容的实现。 它还与JDK中的库(例如Collectors)兼容。 我们的数据结构继承自这些类型的JDK接口,因此它们是其JDK对应对象的直接替代品(不兼容的Stack接口和不兼容的新原语和不可变集合)等价于JDK)。

更丰富的API

java.util.List,Set和Map接口的Eclipse Collections实现具有更丰富的API,我们将在稍后的代码示例中进行探讨。 JDK中还没有其他类型,例如Bag,Multimap和BiMap。 袋子是多件套; 具有重复元素的集合。 从逻辑上讲,您可以将其视为项目与其出现次数的映射。 BiMap是一个“倒置”地图,您不仅可以按键查找值,还可以按值查找键。 多图是值本身就是集合(键->列表,键->集合等)的地图。

选择渴望还是懒惰

Eclipse Collections使您可以轻松地在懒惰的和渴望的实现之间进行切换,这极大地帮助了Java中功能代码的编写,理解和调试。 与streams API不同,默认情况是急切评估。 如果您要进行惰性求值,只需在继续编写逻辑之前在数据结构上编写.asLazy()即可。

不可变的收集接口

不可变集合允许您通过在API级别上实施不可变性来开发更正确的代码。 在这种情况下,程序的正确性将由编译器保证,这将避免在执行过程中出现意外情况。 不变集合和更丰富的接口的结合使您可以用Java编写纯功能代码。

原始类型的集合

Eclipse Collections还具有完整的原始容器,并且所有原始集合类型都具有不变的等效项。 还值得注意的是,尽管JDK流提供对int,long和double流的支持,但Eclipse Collections支持所有八个基元,并允许您直接具有其原始值(与装箱的对象相反)的集合。 ,例如Eclipse Collections IntList(一个int列表)与JDK List <Integer>(一个装箱的原始值列表)。

没有“邦”方法

什么是“小费”方法? 这是Oracle Java首席架构师Brian Goetz发明的一个比喻。 一个汉堡包(两个bun头,中间夹有肉)代表了典型的流代码的结构。 使用Java流,如果您想做某事,不要紧,无论如何,您必须将方法放在两个包子之间–开头的stream()(或parallelStream())方法,最后的collection方法。 这些bun头是空热量,您并不是真正需要的热量,但是没有它们,您将无法食用肉。 在Eclipse Collections中,不需要这些方法。 这是JDK呈现中的包子方法的示例:假设我们有一个包含其姓名和年龄的人员列表,并且我们想要提取21岁以上人员的姓名:

var people = List.of(new Person("Alice", 19),
new Person("Bob", 52), new Person("Carol", 35));

var namesOver21 = people.stream()               // Bun
       .filter(person -> person.getAge() > 21)  // Meat
       .map(Person::getName)                    // Meat
       .collect(Collectors.toList());           // Bun

namesOver21.forEach(System.out::println);

这就是Eclipse Collections看起来相同的代码-不需要包子!

var people = Lists.immutable.of(new Person(“Alice”, 19),
new Person(“Bob”, 52), new Person(“Carol”, 35));

var namesOver21 = people
       .select(person -> person.getAge() > 21) // Meat, no buns
       .collect(Person::getName);              // Meat

namesOver21.forEach(System.out::println);

您需要的任何类型

在Eclipse Collections中,每种用例都有类型和方法,可以通过所需的功能轻松找到它们。 不必记住他们的个人名字,只需考虑一下您需要哪种结构。 您需要可变或不可变的集合吗? 排序? 我们要在集合中存储什么类型的数据-原语还是对象? 您需要哪种收藏? 懒惰,渴望还是平行? 按照下一部分中的图表,很容易构造我们需要的数据结构。

使用工厂实例化它们

这类似于List,Set和Map接口上的Java 9集合工厂方法,甚至还有更多选择!

方法(仅分类)

Rich API可以直接在集合类型本身上使用,这些集合类型继承自RichIterable接口(或在原始端的PrimitiveIterable)。 在接下来的示例中,我们将介绍其中一些API。

方法–还有更多…

词云–那是两年前的事,不是吗? 但是,这一点并不完全是免费的,它说明了几个要点。 首先,有很多方法可以涵盖所有可能的迭代模式,这些方法可以直接在集合类型上使用。 其次,该词云中的词长与该方法的实现数量成正比。 在针对那些特定类型进行了优化的不同集合类型上,有多种方法实现(因此此处没有最低公分母默认方法)。

程式码范例

示例:字数统计

让我们从简单的事情开始。

给定文本(在这种情况下,是童谣),计算文本中每个单词的出现次数。 结果是单词和相应出现次数的集合。

@BeforeClass
static public void loadData()
{
    words = Lists.mutable.of((
            "Bah, Bah, black sheep,\n" +           
            "Have you any wool?\n").split("[ ,\n?]+")   
    );
}

请注意,我们正在使用Eclipse Collection工厂方法来用words列表填充单词。 这等效于JDK中the Arrays.asList(...)方法,但是它返回Eclipse Collections MutableList的实例。 因为MutableList接口与JDK中的List完全兼容,所以我们可以在下面的JDK和Eclipse Collections示例中使用此类型。

首先,让我们考虑一个不使用流的简单实现:

@Test
public void countJdkNaive()
{
    Map<String, Integer> wordCount = new HashMap<>();

    words.forEach(w -> {
        int count = wordCount.getOrDefault(w, 0);
        count++;
        wordCount.put(w, count);
    });

    System.out.println(wordCount);

    Assert.assertEquals(2, wordCount.get(“Bah”).intValue());
    Assert.assertEquals(1, wordCount.get(“sheep”).intValue());
}

您可以在此处看到我们创建了一个新的String到Integer的HashMap(将每个单词映射到其出现的次数),遍历每个单词,并从映射中获取其计数,如果该单词尚未出现在列表中,则默认为0。地图。 然后,我们增加值并将其存储回地图中。 这不是一个很好的实现,因为我们只关注算法的“方法”而不是“什么”,而且性能也不好。 让我们尝试使用惯用的流代码重写它:

@Test
public void countJdkStream()
{
   Map<String, Long> wordCounts = words.stream()
           .collect(Collectors.groupingBy(w -> w, Collectors.counting()));
   Assert.assertEquals(2, wordCounts.get(“Bah”).intValue());
   Assert.assertEquals(1, wordCounts.get(“sheep”).intValue());
}

在这种情况下,代码可读性很强,但是仍然不是很有效(因为您可以确认是否在其上运行了微基准测试)。 您还需要注意Collectors类上的实用程序方法-它们不容易被发现,因为它们在流API上不直接可用。

实现真正有效的一种方法是引入一个单独的计数器类,并将其作为值存储在映射中。 假设我们有一个称为Counter的类,该类存储一个整数值,并具有一个方法递增(),该方法将该值递增1。然后,我们可以将上面的代码重写为

@Test
public void countJdkEfficient()
{
   Map<String, Counter> wordCounts = new HashMap<>();

   words.forEach(
     w -> {
        Counter counter = wordCounts.computeIfAbsent(w, x -> new Counter());
               counter.increment();
     }
   );

   Assert.assertEquals(2, wordCounts.get(“Bah”).intValue());
   Assert.assertEquals(1, wordCounts.get(“sheep”).intValue());
}

实际上,这是一个非常有效的解决方案,但是我们必须编写一个全新的类(Counter)以使其起作用。

Eclipse Collection Bag提供了针对该问题的量身定制的解决方案,并提供了针对该特定收集类型进行了优化的实现。

@Test
    public void countEc()
    {
        Bag<String> bagOfWords = wordList.toBag();
            // toBag() is a method on MutableList

        Assert.assertEquals(2, bagOfWords.occurrencesOf(“Bah”));
        Assert.assertEquals(1, bagOfWords.occurrencesOf(“sheep”));
        Assert.assertEquals(0, bagOfWords.occurrencesOf(“Cheburashka”));
            // null safe - returns a zero instead of throwing an NPE
    }

我们要做的就是获取我们的集合并在其上调用toBag() ! 通过不直接在对象上调用intValue() ,我们还能够避免在上一个断言中出现潜在的NPE。

示例:动物园

假设我们经营一个动物园。 在动物园里,我们饲养着吃不同种类食物的动物。

我们想查询有关动物及其所吃食物的一些事实:

  • 数量最多的食物
  • 动物名单及其最喜欢的食物数量
  • 独特的食物
  • 食物种类
  • 寻找肉食者和非肉食者

这些代码段已通过Java Microbenchmark Harness(JMH)框架进行了测试。 我们将遍历代码示例,然后看一下它们之间的比较。 有关性能比较,请参见下面的“ JMH基准测试结果”部分。

这是我们的领域-他们喜欢吃的动物园动物和食物(每个食物都有名称,类别和数量)。

private static final Food BANANA = new Food(“Banana”, FoodType.FRUIT, 50);
private static final Food APPLE = new Food(“Apple”, FoodType.FRUIT, 30);
private static final Food CAKE = new Food(“Cake”, FoodType.DESSERT, 22);
private static final Food CEREAL = new Food(“Cereal”, FoodType.DESSERT, 80);
private static final Food SPINACH = new Food(“Spinach”, FoodType.VEGETABLE, 26);
private static final Food CARROT = new Food(“Carrot”, FoodType.VEGETABLE, 27);
private static final Food HAMBURGER = new Food(“Hamburger”, FoodType.MEAT, 3);

private static MutableList<Animal> zooAnimals = Lists.mutable.with(
    new Animal(“ZigZag”, AnimalType.ZEBRA, Lists.mutable.with(BANANA, APPLE)),
    new Animal(“Tony”, AnimalType.TIGER, Lists.mutable.with(CEREAL, HAMBURGER)),
    new Animal(“Phil”, AnimalType.GIRAFFE, Lists.mutable.with(CAKE, CARROT)),
    new Animal(“Lil”, AnimalType.GIRAFFE, Lists.mutable.with(SPINACH)),

例如 1-确定最受欢迎的食品:我们的不同食品需求量如何?

@Benchmark
public List<Map.Entry<Food, Long>> mostPopularFoodItemJdk()
{
    //output: [Hamburger=2]
    return zooAnimals.stream()
     .flatMap(animals -> animals.getFavoriteFoods().stream())
     .collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
     .entrySet()
     .stream()
     .sorted(Map.Entry.<Food, Long>comparingByValue().reversed())
     .limit(1)
     .collect(Collectors.toList());
}

逐步操作:我们首先将zooAnimals.flatMap()zooAnimals每种动物喜欢的食物,并返回每种动物消耗的食物流。 接下来,我们要使用食品的标识作为键,将计数作为值对食品进行分组,以便确定每个食品的动物数量。 这是Collectors.counting() 。 为了对其进行排序,我们获得了此地图的.entrySet() ,对其进行流处理,并按反向值对它进行排序(请记住,该值是每种食物的数量,如果我们对最受欢迎的食物感兴趣,我们希望反向顺序) , limit(1)然后返回最高值,最后,我们将其收集到一个List中。

最受欢迎的食物是[汉堡包= 2]。

! 让我们看看如何使用Eclipse Collections实现相同的目的。

@Benchmark
public MutableList<ObjectIntPair<Food>> mostPopularFoodItemEc()
{
    //output: [Hamburger:2]
    MutableList<ObjectIntPair<Food>> intIntPairs = zooAnimals.asLazy()
            .flatCollect(Animal::getFavoriteFoods)
            .toBag()
            .topOccurrences(1);
    return intIntPairs;
}

我们通过将每只动物映射到其最喜欢的食物来开始相同的工作。 因为我们真正想要的是要计数的物品图,所以袋子是帮助我们解决问题的理想用例。 我们调用toBag ,并调用topOccurrences ,它返回最频繁出现的项目。 topOccurrences(1)返回所需的单个最受欢迎项目,作为ObjectIntPairs的列表(注意int是原始的); [汉堡包:2]。

例2-动物最喜欢的食物数量:每种动物的食物选择有多少不同-多少动物只吃一件东西? 多少人吃两个?

首先是JDK再现:

@Benchmark
public Map<Integer, String> printNumberOfFavoriteFoodItemsToAnimalsJdk()
{
    //output: {1=[Lil, GIRAFFE],[Simba, LION], 2=[ZigZag, ZEBRA],
    //         [Tony, TIGER],[Phil, GIRAFFE]}
    return zooAnimals.stream()
            .collect(Collectors.groupingBy(
                    Animal::getNumberOfFavoriteFoods,
                    Collectors.mapping(
                            Object::toString, 
                              // Animal.toString() returns [name,  type]
                            Collectors.joining(“,”))));
                              // Concatenate the list of animals for 
                              // each count into a string
}

再次使用Eclipse Collections:

@Benchmark
public Map<Integer, String> printNumberOfFavoriteFoodItemsToAnimalsEc()
{
    //output: {1=[Lil, GIRAFFE], [Simba, LION], 2=[ZigZag, ZEBRA],
    // [Tony, TIGER], [Phil, GIRAFFE]}
    return zooAnimals
            .stream()
            .collect(Collectors.groupingBy(
                    Animal::getNumberOfFavoriteFoods,
                    Collectors2.makeString()));
}

此示例重点介绍了将本机Java Collector与Eclipse Collections Collectors2结合使用的情况。 两者并不互相排斥。 在此示例中,我们希望获得每只动物的食物数量。 我们如何实现这一目标? 在本机Java中,我们首先要使用Collectors.groupingBy将每只动物按照其最喜欢的食物的数量进行分组。 然后,我们将使用Collectors.mapping函数将每个对象映射到其toString ,最后调用Collectors.joining来连接字符串,并以逗号分隔。

在Eclipse Collections中,我们也可以使用Collectors.groupingBy方法,但是相反,我们将调用Collectors2.makeString冗长程度makeString一些,并获得相同的结果( makeString将流收集为逗号分隔的字符串)。

例3-独特的食物:我们有几种不同类型的食物,它们是什么?

@Benchmark
public Set<Food> uniqueFoodsJdk()
{
    return zooAnimals.stream()
            .flatMap(each -> each.getFavoriteFoods().stream())
            .collect(Collectors.toSet());
}

@Benchmark
public Set<Food> uniqueFoodsEcWithoutTargetCollection()
{
    return zooAnimals.flatCollect(Animal::getFavoriteFoods).toSet();
}

@Benchmark
public Set<Food> uniqueFoodsEcWithTargetCollection()
{
    return zooAnimals.flatCollect(Animal::getFavoriteFoods, 
                                   Sets.mutable.empty());
}

这里我们有几种方法可以解决这个问题! 使用JDK,我们只需将zooAnimals流式传输,然后将其最喜欢的食物进行平面映射,最后将它们收集到一个集合中。 在Eclipse Collections中,我们有两个选择。 第一个与JDK版本大致相同。 展平最喜欢的食物,然后调用.toSet()将它们放到一个集合中。 第二个很有趣,因为它使用了目标集合的概念。 您会注意到flatCollect是一个重载的方法,因此我们可以使用不同的构造函数。 将集合作为第二个参数传递意味着我们将直接展平到集合中,并跳过在第一个示例中将使用的中介列表。 我们可以调用asLazy()来避免这种额外的asLazy() ; 评估将一直等到终端操作,从而避免出现中间状态。 但是,如果您希望使用更少的API调用,或者需要将结果累积到现有的集合实例中,则在从一种类型转换为另一种类型时,请考虑使用目标集合。

例4-肉类和非肉类食用者:我们有多少个肉类食用者? 有多少非肉食者?

注意,在以下两个示例中,我们选择在顶部显式声明Predicate lambda而不是对其进行内联,以强调JDK Predicate与Eclipse Collections Predicate之间的区别。 早在Eclipse Collections出现在Java 8的java.util.function包中之前,就已经对Function,Predicate和许多其他功能类型有了自己的定义。现在Eclipse Collections中的功能类型扩展了JDK的等效类型,从而保持了与JDK的互操作性。库依赖于JDK类型。

@Benchmark
public Map<Boolean, List<Animal>> getMeatAndNonMeatEatersJdk()
{
    java.util.function.Predicate<Animal> eatsMeat = animal ->
            animal.getFavoriteFoods().stream().anyMatch(
                            food -> food.getFoodType()== FoodType.MEAT);

    Map<Boolean, List<Animal>> meatAndNonMeatEaters = zooAnimals
            .stream()
            .collect(Collectors.partitioningBy(eatsMeat));
    //returns{false=[[ZigZag, ZEBRA], [Phil, GIRAFFE], [Lil, GIRAFFE]],
               true=[[Tony, TIGER], [Simba, LION]]}
    return meatAndNonMeatEaters;
}

@Benchmark
public PartitionMutableList<Animal> getMeatAndNonMeatEatersEc()
{
    org.eclipse.collections.api.block.predicate.Predicate<Animal> eatsMeat = 
           animal ->animal.getFavoriteFoods()
                   .anySatisfy(food -> food.getFoodType() == FoodType.MEAT);

    PartitionMutableList<Animal> meatAndNonMeatEaters = 
                                           zooAnimals.partition(eatsMeat);
    // meatAndNonMeatEaters.getSelected() = [[Tony, TIGER], [Simba, LION]]
    // meatAndNonMeatEaters.getRejected() = [[ZigZag, ZEBRA], [Phil, GIRAFFE], 
    //                                        [Lil, GIRAFFE]]
    return meatAndNonMeatEaters;
}

在这里,我们要按肉类和非肉类食用者划分元素。 我们构造谓词“ eatsMeat”,以查看每种动物喜欢的食物,并查看食物类型为FoodType.MEAT的条件是否为anyMatch / anySatisfy(分别为JDK和EC)。

从那里开始,在我们的JDK示例中,我们。 stream()我们的zooAnimals ,并通过.partitioningBy()收集器收集它们,并传入我们的eatsMeat谓词。 此类型的返回类型是具有真键和假键的Map。 “ true”键返回那些吃肉的动物,而“ false”键返回不吃肉的动物。

在Eclipse Collections中,我们称为。 zooAnimals上的partition() ,再次通过Predicate 。 我们剩下一个PartitionMutableList ,它有两个API点MutableLists getSelected()MutableLists getRejected() ,它们都返回MutableLists 。 所选元素是我们的食肉者,被拒绝的元素是非食肉者。

内存使用情况比较

在上面的示例中,重点主要放在集合的类型和接口上。 在一开始我们确实提到过,过渡到Eclipse Collections还可以使您优化内存使用。 效果可能非常重要,具体取决于您的特定应用程序中使用集合的程度以及它们是什么类型的集合。

在图上,您可以看到Eclipse集合与java.util中的集合之间的内存使用情况比较。 *

[点击图像放大图像]

横轴表示集合中存储的元素数,纵轴表示千字节的存储开销。 这里的开销意味着我们在减去收集有效负载的大小之后跟踪分配的内存(因此,我们仅显示数据结构本身占用的内存)。 在我们礼貌地要求System.gc()之后,我们测量的值就是totalMemory() - freeMemory() System.gc() 。 我们观察到的结果是稳定的,并且与使用Nashorn项目中的jdk.nashorn.internal.ir.debug.ObjectSizeCalculator在Java 8中的相同示例所获得的结果一致。 (该实用程序精确地测量了大小,但不幸的是与Java 9及更高版本不兼容。)

第一张图显示了与JDK Integer值列表相比,来自Eclipse Collections的int值(整数)原始列表的优势。 该图表明,对于一百万个值,实现java.util。*中的列表将使用超过15兆字节的内存(JDK约20 MB的存储开销,而Eclipse Collections约5 MB)。

Java中的地图效率极低,并且需要Map.Entry对象,这会夸大内存使用量。

但是,如果map的内存效率不高,则set简直糟透了,因为Set在底层实现中使用Map,这很浪费。 那里的Map.Entry没有任何用处,因为只需要一个属性-键,它是集合的元素。 因此,您可以看到Java中的Set和Map使用相同数量的内存,尽管Set可以做得更紧凑,这是在Eclipse Collections中完成的。 如上所示,它最终使用的内存比JDK集少得多。

最后,第四张图显示了专用集合类型的优点。 如前所述,Bag只是​​一个集合,它允许每个元素具有多个实例,并且可以考虑将元素映射到它们的出现次数。 您将使用Bag来计算项目的出现次数。 在java.util中。 *等效的数据结构是项到整数的映射,其中开发人员的任务是使总出现次数的值保持最新。 再次,请查看对专用数据结构(Bag)进行了多少优化以最大程度地减少内存使用和垃圾回收。

当然,我们建议针对每种情况进行测试。 如果将标准Java集合替换为Eclipse集合,您肯定会获得更好的结果,但是它们对程序内存整体使用的影响的大小取决于特定的情况。

JMH基准测试结果

在本节中,我们将分析之前介绍的示例的执行速度,比较使用Eclipse Collections重写代码前后的代码性能。 该图显示了每个测试的Eclipse Collection和JDK版本每秒测量的操作数。 较长的条表示更好的结果。 如您所见,提速是惊人的:

[点击图像放大图像]

我们要强调的是,与内存使用情况相反,我们显示的结果仅适用于我们的特定示例。 同样,您的特定结果将在很大程度上取决于您的特定情况,因此请确保根据对您的应用程序有意义的实际方案对其进行测试。

结论

Eclipse Collections是在过去10多年中开发的,用于优化Java代码和应用程​​序。 它很容易上手–数据结构被替换掉了,并且该API通常比传统流代码更流利。 有我们尚未解决的用例吗? 我们很高兴接受您的贡献! 随时在GitHub上查看我们。 与我们分享您的结果! 我们希望看到您重构到Eclipse Collections的经验以及它如何影响您的应用程序。 编码愉快!

有用的链接

翻译自: https://www.infoq.com/articles/Refactoring-to-Eclipse-Collections/?topicPageSponsorship=c1246725-b0a7-43a6-9ef9-68102c8d48e1

java streams

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值