谢谢你留下时光匆匆
算法类Java服务开发经验小结

本文总结了自己在算法工作中,Java服务开发的一些经验。算法工程师日常开发的内容主要是:从一个或多个源获取数据,在这些数据上做一些业务逻辑操作,返回一个列表给下游。例如:我们从推荐模型获取某用户的新闻推荐列表,从kv数据库获取某用户最近浏览过的新闻列表,将推荐列表中用户已经浏览过的新闻过滤掉,如果过滤后的列表有用户经常浏览类别的新闻,选2个放在返回结果的开头,剩下的按照新闻时间由旧到新排序。

这篇总结主要包括,常见需求的代码优化实践,简单的代码结构设计以及工程细节。

使用stream处理集合

在很多关于集合处理的任务上,使用Stream可以提高我们的代码质量,增强代码可读性。举下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public static List<String> returnNews1(String userId) {
    List<News> newsFromModel = getNewsFromModel(userId);
    List<String> newsIdAlreadyRead = getNewsIdAlreadyRead(userId);

    // 1 过滤推荐列表中,用户已经阅读过的新闻
    List<News> finalResult = new ArrayList<>();
    for (News news : newsFromModel) {
        if (newsIdAlreadyRead.contains(news.getId())) {
            finalResult.add(news);
        }
    }

    // 2 按照新闻时间由新到旧排序
    finalResult.sort(Comparator.comparing(News::getPublishTimestamp).reversed());

    // 3 最多返回3条
    finalResult = finalResult.subList(0, Math.min(3, finalResult.size()));

    // 4 最后返回新闻id
    List<String> finalResultId = new ArrayList<>();
    for (News news : finalResult) {
        finalResultId.add(news.getId());
    }

    return finalResultId;
}

public static List<String> returnNews2(String userId) {
    List<News> newsFromModel = getNewsFromModel(userId);
    List<String> newsIdAlreadyRead = getNewsIdAlreadyRead(userId);

    return newsFromModel.stream()
            .filter(news -> !newsIdAlreadyRead.contains(news.getId()))  // 1 过滤推荐列表中,用户已经阅读过的新闻
            .sorted(Comparator.comparing(News::getPublishTimestamp).reversed())  // 2 按照新闻时间由新到旧排序
            .limit(3) // 3 最多返回3条
            .map(News::getId) // 4 最后返回新闻id
            .collect(Collectors.toList());
}

可以看到相同的需求,用Stream api实现可以减少代码量,使代码简洁清晰,提高开发效率的同时也便于后期维护。

Stream api 提供了开发中常见的集合操作,例如:集合元素的转换map,列表的截断skiplimit,列表排序sort,集合去重 distinct ,取最大最小值minmax 等等。Stream流式的写法、边角情况下的接口设计(例如,空元素下findFirst方法返回Optional),可以帮助我们很流畅地开发集合相关的操作。

关于Stream入门可以参考这一篇文章The Java 8 Stream API Tutorial,并且强烈建议阅读stream的官方文档 https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html,Stream api中每一个方法都基本上能在实际开发中给我们带来便利。

Collection集合运算

Java原生的Collections类,org.apache.commons.lang3中的CollectionsUtils类、IterableUtils类和ListUtils提供了很多关于集合、列表的静态方法, 熟悉这些静态方法,可以提升我们的代码质量。

自己在实际开发工作中用到的有

  • 用于增加可读性
    • Collections.emptyList():返回空列表。
    • Collections.singletonList(T o):返回单元素列表(注意该列表是不可变的)。
    • ListUtils.emptyIfNull() :null转换为空列表。
    • IterableUtils.first(Iterable<T> iterable):返回第一个元素。
    • CollectionUtils.isEmpty(Collection<?> coll) :判断一个集合是否为空集合,如果coll为null,也返回true。
  • 单个集合上面的操作
    • IterableUtils.matchesAll(Iterable<E> iterable, Predicate<? super E> predicate):用于判断列表里所有元素是否符合某个条件。predicate 一个一元函数,返回boolean,当列表每一个元素作为入参返回true时,matchesAll返回true。
    • IterableUtils.matchesAny(Iterable<E> iterable, Predicate<? super E> predicate) :用于判断列表里所有元素是否符合某个条件。predicate 一个一元函数,返回boolean,当列表每一个元素作为入参返回true时,matchesAll返回true。
    • IterableUtils.countMatches(Iterable<E> input, Predicate<? super E> predicate):用于判断符合条件元素的的数量。
    • IterableUtils.uniqueIterable(Iterable<E> iterable) :返回只包含唯一元素的列表的视图。
    • IterableUtils.reversedIterable(Iterable<E> iterable):返回逆序的列表的视图。
    • IterableUtils.filteredIterable(Iterable<E> iterable, Predicate<? super E> predicate):返回元素符合条件的列表的视图。
    • ListUtils.unmodifiableList(List<? extends E> list):返回不可变列表的视图。
    • CollectionUtils.max(Collection<? extends T> coll)max(Collection<? extends T> coll, Comparator<? super T> comp) :返回集合中值最大的元素。
    • CollectionUtils.min(Collection<? extends T> coll)min(Collection<? extends T> coll, Comparator<? super T> comp) :返回集合中值最小的元素。
  • 集合与集合运算
    • 逻辑运算
      • Collections.disjoint(Collection<?> c1, Collection<?> c2) :判断两个集合是否分离(没有共同元素)。
      • CollectionUtils.containsAll(Collection<?> coll1, Collection<?> coll2) :判断coll1是否包含coll2所有的元素。
      • CollectionUtils.containsAny(Collection<?> coll1, Collection<?> coll2) :判断coll1与coll2的交集是否为空。
    • 集合元素运算
      • CollectionUtils.intersection(Iterable<? extends O> a, Iterable<? extends O> b):返回两集合交集。
      • CollectionUtils.union(Iterable<? extends O> a, Iterable<? extends O> b):返回两集合并集。
      • CollectionUtils.subtract(Iterable<? extends O> a, Iterable<? extends O> b):返回两集合相减。
      • CollectionUtils.disjunction(Iterable<? extends O> a, Iterable<? extends O> b): 返回两集合对称差(只存在其中一个集合的元素)。

相关文档

注1:上述函数大部分也可以由Java Stream api实现。

注2:Apache Commons集合相关的utils大部分都是null safe的,即参数允许传入null值。

Pojo类

有时候我们需要定义一些class类,来描述相关的数据,比如说新闻News类型、商品Item类。这些数据类下各个field字段记录相关信息,比如News类有publishTime新闻发布时间字段,author新闻作者字段等等。在Java中,这样的数据类一般被称为Pojo类(Plain Old Java Object),定义Pojo类,可以让我们的开发更加顺利,也能增加代码可读性。

以新闻为例,下面给出一个Pojo类的例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class News {
    private String id;
    private String title;
    private long publishTime;

    public News() {
    }

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public long getPublishTime() {
        return publishTime;
    }

    public void setPublishTime(long publishTime) {
        this.publishTime = publishTime;
    }
}

当声明的pojo类字段比较多时,会让代码看起来比较冗杂。我们可以利用lombok这个Java包来减少我们的代码量,只需要在Pojo类上方添加修饰符@Data,在Java编译时,lombok会为我们自动添加相应的get,set方法,另外lombok还会重写toString方法,方便日志打印时显示内部变量的值。

此外,我们还可以添加@Accessors(chain = true)修饰符,在调用set方法时,方法会返回变量本身,可以减少进行连续赋值时的代码量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// @Data
// public class Pojo1 {
//     private int field1;
//     private int field2;
// }

// @Data @Accessors(chain = true)
// public class Pojo2 {
//     private int field1;
//     private int field2;
// }

Pojo1 pojo1 = new Pojo1();
pojo1.setField1("field1");
pojo1.setField2("field2");

Pojo1 pojo2 = new Pojo2()
    .setField1("field1")
    .setField2("field2");

最后,建议大家加入@NoArgsConstructor修饰符,会在编译时自动生成一个空入参的constructor构建方法,也即相当于public NewsPojo()。这样做主要是为了在反序列化使用,以fastjson为例,反序列化首先会用空入参的构建方法生成一个对象,如果一个类没有空入参的构建方法,会报异常。

汇总上面的几点,下面是给出的pojo类创建的一个模版,供大家参考。

1
2
3
4
5
6
@Data @Accessors(chain = true) @NoArgsConstructor
public class NewsPojo {
    private String id;
    private String title;
    private long publishTime;
}

注1:序列化是指,存在内存中的对象转化为可以存储传输的流形式,如字符串,字节流等。反序列化是指流形式转化为内存中的对象。我们可以用阿里推出的fastjson2进行序列化反序列化操作String text = JSON.toJSONString(pojo)Pojo pojo = JSON.parseObject(text, Pojo.class)

注2:Java中如果没有声明任何constructor方法,会自动创建一个入参的constructor方法。如果声明了一个有参数的constructor方法,则不会创建入参的constructor方法。为了避免忘记声明入参的构造方法,建议添加上@NoArgsConstructor修饰符。

Comparator 类

实际工作中,列表排序也是常见的需求之一,Java 中 Comparator 类为列表排序提供了很好的支持,可以帮助我们实现各种排序逻辑。下面的例子以新闻推荐排序为例,将新闻列表按照推荐分数从高到底排序,当分数相同时,发布时间较新的新闻优先排在前面。对比两种写法,可以明显看出 Comparator 类提供的相关方法可以很好帮助我们提高代码质量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// List<News> newsList = ... ;

// 第一种实现:用匿名类
newsList.sort((news1, news2) -> {
    if (news1.getRecommendScore() > news2.getRecommendScore()) {
        return -1;
    } else if (news1.getRecommendScore() < news2.getRecommendScore()) {
        return 1;
    } else {
        if (news1.getPublishTime().isAfter(news2.getPublishTime())) {
            return -1;
        } else if (news1.getPublishTime().isBefore(news2.getPublishTime())) {
            return 1;
        } else {
            return 0;
        }
    }
});

// 第二种实现:用 Comparator API
Comparator<News> newsComparator = Comparator.comparing(News::getRecommendScore)
    .reversed()
    .thenComparing(
        News::getPublishTime,
        Comparator.<LocalDateTime>naturalOrder().reversed()
);
newsList.sort(newsComparator);

上面这段代码中,Comparator.comparing方法用来生成比较某个字段的 Comparator 对象,方法的入参是一个 Function 类,用来提取进行比较的字段(如这里的News::getRecommendScore来提取News的recommendScore推荐分数字段)。reversed方法是将Comparator对象的比较方向颠倒,一般来说,比较排序默认是小值在前,大值在后,reversed方法会生成一个相反的由大到小顺序比较的Comparator对象。

thenComparing在已有的Comparator对象上附加一个新的比较方法,用于在原comparator对象比较出现相等结果时,用该比较方法继续进行比较。在这个例子中,thenComparing第一个参数用来提取比较的字段,第二个参数用来指定在这个字段上如何比较,Comparator.<LocalDateTime>naturalOrder().reversed() 是指用LocalDateTime类自身定义的compare方法按从大到小的顺序进行比较。

更多Comparator方法以及相应用法可以参考官方api文档Comparator (Java Platform SE 8)

注:一个类可以重写compare方法来实现comparable接口,这个compare方法定义出的比较顺序被称之为natrual order。关于 comparable 和 comparator 相关的概念,可以参考Comparator and Comparable in Java

equals重写

equals重写主要用于列表去重,以及判断列表中是否存在某一元素,例如,返回的新闻推荐列表不能有重复id的新闻,判断强插推荐的新闻是否在已有返回列表中存在。

重写equals方法可以参考如下模板

1
2
3
4
5
6
7
@Override
public boolean equals(Object o) {
    if (this == o) { return true; }
    if (o == null || getClass() != o.getClass()) { return false; }
    YourObject that = (YourObject) o;
    return Objects.equals(this.getField1(), that.getField1()) && Objects.equals(this.getField2(), that.getField2());
}

需要注意的是,改写一个类的equals方法,需要同时改写其hashcode方法,可以参考如下方式进行改写

1
2
3
4
@Override 
public int hashCode() {
     return Objects.hash(this.field1, this.field2);
}

服务代码组织debugInfo

接下来介绍一下服务代码组织方面的经验。

在算法Java服务中,有一部分是针对获取到的数据,制定一系列规则逻辑,来返回符合业务需求、贴合用户体验的结果。这个过程中,常常会遇到查bad case的情况,比如为什么一些想要看到的新闻没有出现在返回列表中,或者为什么一条类别不对的新闻会出现在列表顶端。

数据结果的debug实际上是比较麻烦的。为了提升算法效果,算法制定的规则往往会越写越多,越写越复杂,进而出现更多意想不到的情况,同时也会导致查case更加困难。因此,我们最好设计一套方案,将整个算法链路中间结果汇总起来。

除了算法数据结果的debug之外,还需要考虑整个服务稳定性,进行一些容错设计,保证服务在错误情况下也有合理的返回结果。

通常,我会采用下面的结构去组织代码,以Java为例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class UserRequestHandler {
    public JSONObject doJob(JSONObject inputParam) {
        RecommendProcessor recommendProcessor = new RecommendProcessor();
        try {
            JSONObject result = recommendProcessor.process(inputParam);
            exportDebugInfo(recommendProcessor.getDebugInfo());
            return result;
        } catch (Exception e) {
            log.error("System error", e);
            return resultWhenError();
        }
    }

    private static JSONObject resultWhenError() {
        // ...
        return result;
    }
}

public class RecommendProcessor {
    private JSONObject debugInfo = new JSONObject();

    public JSONObject process(JSONObject inputParam) {
        RecallProcessor recallProcessor = new RecallProcessor(debugInfo);
        Recallresult recallResult = recallProcessor.process(inputParam);

        RankProcessor rankProcessor = new RankProcessor(debugInfo);
        JSONObject rankResult = rankProcessor.process(recallResult);

        return rankResult;
    }

    public JSONObject getDebugInfo() {
        return debugInfo;
    }
}

public class RecallProcessor {
    private JSONObject debugInfo;

    public RecallProcessor(JSONObject debugInfo) {
        this.debugInfo = debugInfo;
    }

    public RecallResult process(JSONObject inputParam) {
        // ...
        return recallResult;
    }
}

public class RankProcessor {
    private JSONObject debugInfo;

    public RankProcessor(JSONObject debugInfo) {
        this.debugInfo = debugInfo;
    }

    public JSONObject process(JSONObject inputParam) {
        // ...
        return rankResult;
    }
}

创建RecommendProcessor类,用于处理请求进入到结果返回的整个链路,RecommendProcessor对象有一个 field debugInfo 用于业务中间结果的记录,所有逻辑的开发代码放入process方法里。

在请求接口返回的最外层,创建一个新的RecommendProcessor实例,将入参传入process方法,并将出参从接口返回出去。process的调用用try-catch包住,避免意料之外的错误影响了整个服务。catch里面的处理,可以需要视情况返回一个兜底数据(比如热门新闻列表),并将错误信息通过日志上报,方便后期修正。

为了提高代码可读性、方便后期维护,process方法里面的代码可以划分成若干个部分,比如召回部分、排序部分,将其抽象出来,建立相应的子Processor类,比如上面的RecallProcessorRankProcessor,并将划分出来的代码放入对应子Processor类的process方法中。与RecommendProcessor类一样,这些子类也有一个debugInfo的field,用于记录算法中间的信息。需要注意的是,与主Processor类不同,这些子Processor类的debugInfo不是新创建的,而是与主Processor类的debugInfo共用同一个对象,可以将其通过子Processor的construtor方法的入参传入给子Processor实例。

关于debugInfo里应该记录什么信息,我个人的习惯是:

  1. 中间结果:算法链路的中间结果是非常关键的信息,可以用来直接判断算法是否符合逻辑,并迅速定位到不合理的环节。如果合适,中间结果的记录,除了每个环节返回的id之外(如新闻推荐场景下的新闻id),还应该包括算法策略所用到的相关属性(如新闻热度等),方便后期debug。
  2. 耗时:可以将算法各个步骤的耗时一并记录,用于分析性能。
  3. 命中的策略:我们可能会用比较特殊的逻辑处理一些极个别的例子,但这些逻辑有时可能会影响其它case,而且在debug算法时可能会忘记这些“小众”的逻辑,这给分析带来了一些麻烦。当命中这些特殊逻辑时候,可以将其记录下来,为后期定位问题给到帮助。

其它

除了上述与算法开发直接相关的经验,Java服务开发一些基本经验也建议了解,比如空指针NPE的防范(可以参考我的这篇博文如何避免 Java NPE(NullPointerException) 空指针问题);Java的数据结构,包括原生类和第三方常用的(如MultiSet,MultiMap等);代码风格与规范;版本控制与分支管理等等。