BT

如何利用碎片时间提升技术认知与能力? 点击获取答案

Eclipse Collections:让Java Streams更上一层楼

| 作者 Vladimir Zakharov 关注 0 他的粉丝 , Kristen O'Leary 关注 3 他的粉丝 ,译者 无明 关注 3 他的粉丝 发布于 2018年6月22日. 估计阅读时间: 46 分钟 | QCon上海2018 关注大数据平台技术选型、搭建、系统迁移和优化的经验。

关键要点

  • Eclipse Collections是一个高性能的Java集合框架,为原生JDK集合增加了丰富的功能。
  • Streams是JDK的一个非常受欢迎的功能,但它缺少了一些特性,严重依赖旧版的集合实现和冗长的API。
  • Eclipse Collections为传统JDK数据结构提供了替代品,并支持Bag和Multimap等数据结构。
  • 将Streams重构为Eclipse Collections有助于提高代码可读性并减少内存占用。
  • 最重要的是,使用Eclipse Collections来重构Streams非常简单!

在Java 8中引入的Java Streams非常棒——让我们可以充分利用lambda表达式来替换循环迭代代码,让代码更加接近于函数式编程风格。

然而,尽管Streams带来了改进,但它最终只是对现有集合框架的扩展,仍然背着很多包袱。

我们可以进一步改进吗?我们能否拥有更丰富的接口和更清晰、更易读的代码?与传统的集合相比,我们能否节省更多内存?我们能否更好、更无缝地支持函数式编程?

答案是肯定的!Eclipse Collections(以前叫作GS Collections)是Java Collections框架的一个替代品,我们可以用它来实现我们的目的。

在本文中,我们将演示几个例子,将标准的Java代码重构成Eclipse Collections数据结构和API,以及如何节省内存。

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

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

Eclipse Collections的历史

Eclipse Collections最初是由高盛公司创建的,他们的应用平台有一个大型的分布式缓存组件。该系统将数百GB的数据存储在内存中(现在仍在生产环境运行)。

事实上,缓存就是一个Map,我们在Map里保存和读取对象。这些对象可以包含其他Map和集合。最初,缓存基于java.util.*包中的标准数据结构而构建。但很明显,这些集合有两个明显的缺点:内存使用效率低下,而且接口非常有限(导致重复且难以阅读的代码)。由于问题源于集合的实现,因此无法通过额外的代码库来解决这些问题。为了同时解决这两个问题,高盛公司决定从头开始创建一个新的集合框架。

在当时,它似乎是一个激进的解决方案,不过它确实可行。现在,这个框架托管给了Eclipse基金会。

在文章的最后,我们分享了一些链接,这些链接将帮助你了解有关这个项目本身的更多信息、学习如何使用Eclipse Collections以及如何成为这个项目的代码贡献者。

为什么要重构为Eclipse Collections?

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,我们将在后面的代码示例中探讨这些API。JDK中缺少了一些类型,例如Bag、Multimap和BiMap。Bag是一种多重集,可以包含重复元素。从逻辑上讲,我们可以将其视为元素到它们出现次数的映射。BiMap是一种“倒置”的Map,不仅可以通过按键来查找值,也可以通过值来查找键。Multimap是一种Map,它的值就是集合(如Key->List、Key->Set等)。

eager还是lazy?

在使用Eclipse Collections时,我们可以非常容易地在lazy和eager两种实现模式间切换,有助于编写、理解和调试函数式代码。与Streams API不同的是,eager是默认的模式。如果你想要使用lazy模式,只需要在开始你的逻辑代码之前,在你数据结构上调用.asLazy()。

不可变集合接口

有了不可变集合,你可以在API层面通过不可变性写出更加正确的代码。在这种情况下,程序的正确性将由编译器来保证,避免在执行过程中出现意外。借助不可变集合和更丰富的接口,你可以在Java中写出纯函数式代码。

原始类型集合

Eclipse Collections也提供了原始类型的容器,所有原始集合类型都有不可变的对等物。值得一提的是,JDK的Streams支持int、long和double,而Eclipse Collections支持所有八个原始类型,并且可以定义用于直接保存原始值的集合(与它们的装箱对象不同,例如Eclipse Collections IntList是一个int列表,而JDK中的List<Integer>是一个装箱的原始值列表)。

没有“bun”方法

什么是“bun”方法?这是由Oracle Java首席设计师Brian Goetz发明的一个比喻说法。一个汉堡包(两片圆面包中间夹着肉)代表典型的流式代码结构。在使用Java Streams时,如果你想做点什么,必须把你的方法放在两块“面包”之间——前面是stream()(或parallelStream())方法,后面是collect()方法。这些面包其实没有什么营养,但如果没有它们,你就无法吃到肉。在Eclipse Collections中,这些方法不是必需的。下面的例子演示了JDK中的bun方法:假设我们有一个名单,上面有他们的姓名和年龄,我们想要取出年龄超过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的代码——不需要bun方法!

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中,每种情况都有相应的类型和方法,你可以根据你的需求找到它们。没有必要记住它们的名字——只要想想你需要什么样的数据结构。你需要一个可变或不可变的集合吗?排序的?你想要在集合中存储什么类型的数据——原始值还是对象?你需要什么样的结合?lazy的、eager的还是parallel的?后面将给出一张图表,按照这张图表中所列的方法,就可以轻松构建我们所需的数据结构。

通过工厂方法来实例化它们

这与Java 9中List、Set和Map接口的工厂方法类似,而且提供了更多选项!

部分按类别分组的方法

集合类型本身就提供了丰富的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 Collections工厂方法来计算单词。这相当于JDK中的Arrays.asList(…)方法,不过它返回的是MutableList的一个实例。由于MutableList接口与JDK的List完全兼容,因此我们可以在下面的JDK和Eclipse Collections示例中使用此类型。

首先,让我们来看看一个不使用Streams的实现:

@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(将每个单词映射到它的出现次数),遍历每个单词,并从Map中获得它的出现次数,如果单词不存在则默认为零。然后,我们增加该值并将其存回Map中。这不是一个很好的实现,因为我们关注的是“如何”而不是“什么”,并且性能也不是很好。让我们尝试使用Streams来重写它:

@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类的方法——这些方法不容易被找到,因为它们不属于Streams API。

高效的实现方法是引入一个单独的计数器类,并将其作为值保存在Map中。比方说,我们有一个名为Counter的类,用于保存一个整数值,并提供increment()方法,用于将该值递增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()到它最喜欢的食物,返回一个流。接下来,我们使用食物的标识作为关键字、数量作为值对食物进行分组,这样就可以确定每个食物对应的动物的数量。这是Collectors.counting()要做的工作。为了对它进行排序,我们调用Map的entrySet()方法,对它进行流式化,并通过反向值对它进行排序(这个值是每种食物的计数,如果我们想知道最受欢迎的食物,就需要按照逆序排序),然后调用limit(1)返回最大值,最后,我们将它收集到一个List中。

结果最受欢迎的食物是[Hamburger = 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;
}

我们也从将每只动物flatMap到它最喜欢的食物开始。因为我们真正想要的是食物到数量的Map,所以Bag可以完美解决我们的问题。我们先调用toBag(),再调用topOccurrences(),它返回最频繁出现的食物项目。topOccurrences(1)返回最受欢迎的食物,并作为ObjectIntPairs列表返回(注意int是原始类型),结果是[Hamberger: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 Collectors和Eclipse Collections Collector2,两者并不相互排斥。在这个例子中,我们想要获得每只动物的食物数量。那么如何实现这一目的?在原生Java中,我们首先使用Collectors.groupingBy将每只动物按照其最喜欢的食物数量分组。然后,我们使用Collectors.mapping函数将每个对象映射到它的toString方法,最后调用Collectors.joining将字符串连接起来,并用逗号分隔。

在Eclipse Collections中,我们也可以使用Collectors.groupingBy方法,不过也会调用更简洁的Collectors2.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进行流式化,然后对它们最喜欢的食物进行flatMap,最后将它们收集到一个集合中。如果使用Eclipse Collections,我们有两种处理方式。第一种与JDK版本大致相同,flat食物,然后调用toSet()将它们放入一个集合中。第二种方式很有趣,因为它使用了目标集合的概念。flatCollect是一个重载的方法,所以提供了几种不同的使用方式。如果传入一个集合作为第二个参数,意味着我们将直接将食物flat到集合中,并跳过第一个示例中使用的中间列表。我们可以调用asLazy()来避免这种中间结果,运算会一直等待最终操作结束,从而避免出现中间状态。不过,如果你喜欢较少的API调用,或者需要将结果累加到现有的集合中,那么在从一种类型转换为另一种类型时请考虑使用目标集合。

示例4——肉食和非肉食动物:有多少肉食动物?多少非肉食动物?

请注意,在以下的两个示例中,我们选择在顶部显式(而不是通过内联的方式)声明Predicate lambda,用以强调JDK Predicate和Eclipse Collections Predicate之间的区别。 Eclipse Collections早在Java 8的java.util.function包出现之前,就已经有了Function、Predicate和其他函数类型的定义。现在,Eclipse Collections中的函数类型扩展了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;
}

我们想要通过肉食和非肉食动物来分隔元素。我们构建了一个Predicate “eatsMeat”,它检查每只动物喜欢的食物,看看是否anyMatch(JDK)或anySatisfy(Eclipse Collections),条件为食物类型为FoodType.MEAT。

在JDK示例中,我们对动物进行stream(),并调用partitioningBy(),传入eatsMeat Predicate。返回的是一个带有true或false作为键的Map。“true”将返回肉食动物,而“false”则返回非肉食动物。

在Eclipse Collections中,我们在zooAnimals上调用partition(),同时传入Predicate。我们会得到一个PartitionMutableList,它提供了两个方法——getSelected()和getRejected(),它们都返回MutableLists。被选定的元素就是肉食动物,被拒绝的元素就是非肉食动物。

内存使用比较

在上面的例子中,重点主要集中在集合的类型和接口上。我们在开始的时候提到了使用Eclipse Collections将会带来内存方面的优化。效果可能会非常显着,具体取决于特定应用程序中使用了多大的集合以及什么类型的集合。

从图中可以看到Eclipse Collections和java.util.*集合之间的内存使用情况比较。

横轴表示存储在集合中的元素的数量,纵轴表示以千字节为单位的存储开销。这里的开销表示减去集合有效载荷之后所使用的内存(因此我们只显示数据结构本身占用的内存)。在调用System.gc()之后,我们使用totalMemory()-freeMemory()来得出内存使用量。我们观察到的结果是稳定的,并且与Java 8使用jdk.nashorn.internal.ir.debug.ObjectSizeCalculator的示例获得的结果是一致的(这个程序可以精确计算对象大小,可惜的是与Java 9及更高版本不兼容)。

第一张图显示了Eclipse Collections int列表与JDK Integer列表对比的优势。该图显示,对于一百万个值,java.util.*中的列表将多用15MB内存(对于JDK约为20MB的内存开销,对于Eclipse Collections约为5MB)。

Java中的Map效率非常低,因为需要用到Map.Entry对象,这会扩大内存使用量。

如果说Map内存效率不高,那么Set的效率就是糟糕透顶,因为Set的底层实现使用了Map,这太浪费内存了。Map.Entry没有多大用处,因为它只有一个属性是有用的——键,也就是集合的元素。因此,你会发现,Java中的Set和Map使用相同数量的内存,但Set可以变得更加紧凑,Eclipse Collections就做到了这一点。它最终使用的内存比JDK集合少得多,如上图所示。

最后,第四张图显示了特定结合类型的优点。如前所述,Bag只是​​一个集合,它允许每个元素存在多个实例,并且可以将元素与其出现的次数映射起来。你可以使用Bag来统计元素的出现次数。java.util.*中的等效数据结构是元素到其次数的Map,开发人员需要负责更新元素出现的次数。可以看到,特定数据结构(Bag)已经被优化到可以最大限度地减少内存使用和垃圾收集。

当然,我们建议对每个个案进行测试。如果用Eclipse Collections替换标准Java集合,那么结果肯定会得到改进,但是它们对内存整体使用的影响程度取决于具体情况。

JMH基准测试结果

在本节中,我们将分析之前那些示例的运行速度,对比改用Eclipse Collections重写之前和之后代码的性能差别。该图显示了每个示例中Eclipse Collections和JDK的每秒操作数量。较长的条表示更好的结果。正如你所看到的,速度的提升是非常明显的:

有必要强调的是,我们展示的结果仅适用于上述的具体示例。具体结果将取决于你们的特定情况,因此请务必针对你们的真实场景进行测试。

结论

Eclipse Collections在过去的10多年中一直在演化,用以优化Java代码和应用程​​序。它简单易用——现成的数据结构,并提供了比传统流式代码更流畅的API。还有我们没有解决的用例?我们希望你们能够加入到贡献者行列中!欢迎从GitHub上拉取我们的代码,一起分享你们的结果!我们很乐意看到你们分享使用Eclipse Collections的体验以及它如何影响你们的应用程序。祝编码愉快!

有用的链接

关于作者

Kristen O'Leary 是高盛服务工程小组的高级副总裁。她为Eclipse Collections带来了多个容器、API和性能增强功能,并且还在公司内部和外部教授有关该框架的课程。

 

Vladimir Zakharov 在软件开发方面有超过二十年的经验。他目前是高盛平台业务部门的董事总经理。他在过去的18年中一直使用Java进行开发,在此之前他还使用了Smalltalk和其他一些比较晦涩的编程语言。

查看英文原文Refactoring to Eclipse Collections: Making Your Java Streams Leaner, Meaner, and Cleaner


 

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

排版有问题啊 by guo rui

排版有问题啊

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

1 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT