本文总结了自己在算法工作中,Java服务开发的一些经验。算法工程师日常开发的内容主要是:从一个或多个源获取数据,在这些数据上做一些业务逻辑操作,返回一个列表给下游。例如:我们从推荐模型获取某用户的新闻推荐列表,从kv数据库获取某用户最近浏览过的新闻列表,将推荐列表中用户已经浏览过的新闻过滤掉,如果过滤后的列表有用户经常浏览类别的新闻,选2个放在返回结果的开头,剩下的按照新闻时间由旧到新排序。
这篇总结主要包括,常见需求的代码优化实践,简单的代码结构设计以及工程细节。
使用stream处理集合
在很多关于集合处理的任务上,使用Stream可以提高我们的代码质量,增强代码可读性。举下面的例子:
|
|
可以看到相同的需求,用Stream api实现可以减少代码量,使代码简洁清晰,提高开发效率的同时也便于后期维护。
Stream api 提供了开发中常见的集合操作,例如:集合元素的转换map
,列表的截断skip
与limit
,列表排序sort
,集合去重 distinct
,取最大最小值min
、max
等等。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):
返回两集合对称差(只存在其中一个集合的元素)。
- 逻辑运算
相关文档
- Collections (Java Platform SE 8 )
- CollectionUtils (Apache Commons Collections 4.4 API)
- IterableUtils (Apache Commons Collections 4.4 API)
- ListUtils (Apache Commons Collections 4.4 API)
注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类的例子
|
|
当声明的pojo类字段比较多时,会让代码看起来比较冗杂。我们可以利用lombok这个Java包来减少我们的代码量,只需要在Pojo类上方添加修饰符@Data
,在Java编译时,lombok会为我们自动添加相应的get,set方法,另外lombok还会重写toString
方法,方便日志打印时显示内部变量的值。
此外,我们还可以添加@Accessors(chain = true)
修饰符,在调用set方法时,方法会返回变量本身,可以减少进行连续赋值时的代码量。
|
|
最后,建议大家加入@NoArgsConstructor
修饰符,会在编译时自动生成一个空入参的constructor构建方法,也即相当于public NewsPojo()
。这样做主要是为了在反序列化使用,以fastjson为例,反序列化首先会用空入参的构建方法生成一个对象,如果一个类没有空入参的构建方法,会报异常。
汇总上面的几点,下面是给出的pojo类创建的一个模版,供大家参考。
|
|
注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 类提供的相关方法可以很好帮助我们提高代码质量。
|
|
上面这段代码中,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方法可以参考如下模板
|
|
需要注意的是,改写一个类的equals
方法,需要同时改写其hashcode
方法,可以参考如下方式进行改写
|
|
服务代码组织debugInfo
接下来介绍一下服务代码组织方面的经验。
在算法Java服务中,有一部分是针对获取到的数据,制定一系列规则逻辑,来返回符合业务需求、贴合用户体验的结果。这个过程中,常常会遇到查bad case的情况,比如为什么一些想要看到的新闻没有出现在返回列表中,或者为什么一条类别不对的新闻会出现在列表顶端。
数据结果的debug实际上是比较麻烦的。为了提升算法效果,算法制定的规则往往会越写越多,越写越复杂,进而出现更多意想不到的情况,同时也会导致查case更加困难。因此,我们最好设计一套方案,将整个算法链路中间结果汇总起来。
除了算法数据结果的debug之外,还需要考虑整个服务稳定性,进行一些容错设计,保证服务在错误情况下也有合理的返回结果。
通常,我会采用下面的结构去组织代码,以Java为例
|
|
创建RecommendProcessor
类,用于处理请求进入到结果返回的整个链路,RecommendProcessor
对象有一个 field debugInfo
用于业务中间结果的记录,所有逻辑的开发代码放入process
方法里。
在请求接口返回的最外层,创建一个新的RecommendProcessor
实例,将入参传入process
方法,并将出参从接口返回出去。process
的调用用try-catch包住,避免意料之外的错误影响了整个服务。catch里面的处理,可以需要视情况返回一个兜底数据(比如热门新闻列表),并将错误信息通过日志上报,方便后期修正。
为了提高代码可读性、方便后期维护,process方法里面的代码可以划分成若干个部分,比如召回部分、排序部分,将其抽象出来,建立相应的子Processor类,比如上面的RecallProcessor
、RankProcessor
,并将划分出来的代码放入对应子Processor类的process方法中。与RecommendProcessor
类一样,这些子类也有一个debugInfo
的field,用于记录算法中间的信息。需要注意的是,与主Processor类不同,这些子Processor类的debugInfo
不是新创建的,而是与主Processor类的debugInfo
共用同一个对象,可以将其通过子Processor的construtor方法的入参传入给子Processor实例。
关于debugInfo
里应该记录什么信息,我个人的习惯是:
- 中间结果:算法链路的中间结果是非常关键的信息,可以用来直接判断算法是否符合逻辑,并迅速定位到不合理的环节。如果合适,中间结果的记录,除了每个环节返回的id之外(如新闻推荐场景下的新闻id),还应该包括算法策略所用到的相关属性(如新闻热度等),方便后期debug。
- 耗时:可以将算法各个步骤的耗时一并记录,用于分析性能。
- 命中的策略:我们可能会用比较特殊的逻辑处理一些极个别的例子,但这些逻辑有时可能会影响其它case,而且在debug算法时可能会忘记这些“小众”的逻辑,这给分析带来了一些麻烦。当命中这些特殊逻辑时候,可以将其记录下来,为后期定位问题给到帮助。
其它
除了上述与算法开发直接相关的经验,Java服务开发一些基本经验也建议了解,比如空指针NPE的防范(可以参考我的这篇博文如何避免 Java NPE(NullPointerException) 空指针问题);Java的数据结构,包括原生类和第三方常用的(如MultiSet,MultiMap等);代码风格与规范;版本控制与分支管理等等。