发布于 

去哪儿网Code Review大赛

去哪儿网Code Review大赛

序号12

1、tagger 系统会协助运营人员圈定一批用户进行营销活动的投放,量级在百万到千万量级。 
2、推送平台需要将这些用户信息从数据库中读取出来做一些预处理。UserService 就是来做这个读取并预处理工作的。

public class UserService {
@Autowired
private UserDAO userDao;

public void preproccessUser(String deviceTableName) {
int totalCount = 10000000; //1000 万行数据
int pageSize = 1000; //分页读取,每页读取 1000 条记录
int pageCount = totalCount / pageSize;// 一共需要读取的页数
for (int pageIndex = 0; pageIndex < pageCount; pageIndex++) {
List<Object> objects = userDao.selectByPage(deviceTableName, pageIndex * pageSize, pageSize);
this.preproccess(objects);
}
}

//完成数据预处理
public void preproccess(List<Object> objectList) {
//预处理
for (Object obj : objectList) {
// 每条验证和规范处理
}
}
}

@DAO
interface UserDAO {
@SQL("select * from :1 limit :2 , :3")
List<Object> selectByPage(String tableName, int from, int size);
}

官方采分点:

1(1分)对objectList 进行判空处理
2(1分)分页改主键游标分页
3(1分)28行 SQL注入风险

我的采分点:

  1. 在注释规范上:风格十分要统一,比如9行~11行是放在代码的后面。19行的注释放在代码的马上。个人更喜欢上面的风格)
  2. 单词拼写错误,process。命名比注释更加重要。逃)
  3. 分页性能问题,采用游标的方式,记录每次返回的max(id),然后带着id去查询
  4. 空指针问题:objectList判空处理
  5. 数据量比较大,建议采用多线程处理,不然有可能出现活动结束了,程序都没推送完
  6. UserDAO采用参数传入到执行sql中,这时候就要小心SQL注入

序号16

集合迭代中修改的常见错误点

本实例为搜索用户筛选品牌信息场景,通过运营配置相关品牌映射关系实现召回相关的品牌商

package com.noah.starter.demo.web.code;

public class KeywordMapBrandTransferAnalyzer extends Analyzer {
private String configKey;

@Override
boolean doAccept(ProcContext context) {
//从上下文获取接口传入分类品牌信息
Set<SearchCategory> categorySet = context.getParamPgCate();
if (CollectionUtils.isEmpty(categorySet)) {
return true;
}
//从配置中获取品牌映射关系
Map<Integer, Set<Integer>> transferMapping = ConfigCenterValues.getBrandTransferMapping(configKey);
for (SearchCategory category : categorySet) {
Set<Integer> brands = brandMap.get(category.getBrandId());
if (category.getBrandId() != 0 && CollectionUtils.isNotEmpty(brands)) {
//如果有配置品牌,则添加进上下文召回
for (Integer brand : brands) {
if (brand != category.getBrandId()) {
SearchCategory mapCategory = new SearchCategory(category.getCateId(), brand, category.getModelId());
categorySet.add(mapCategory);
}
}
}
}
return true;
}

}

class SearchCategory {
Integer cateId;
Integer brandId;
Integer modelId;

public SearchCategory(Integer cateId, Integer brandId, Integer modelId) {
this.cateId = cateId;
this.brandId = brandId;
this.modelId = modelId;
}

public Integer getCateId() {
return cateId;
}

public void setCateId(Integer cateId) {
this.cateId = cateId;
}

public Integer getBrandId() {
return brandId;
}

public void setBrandId(Integer brandId) {
this.brandId = brandId;
}

public Integer getModelId() {
return modelId;
}

public void setModelId(Integer modelId) {
this.modelId = modelId;
}
}

官方采分点:

1、 categorySet存在有概率出现迭代时修改问题
2、 SearchCategory没有没有重写hashCode和equals无法判重

我的采分点:

  1. SearchCategory的属性没有被private修饰,不符合Java对象封装的思想
  2. 第二十行的if判断,两边都是Integer对象,应该使用equals()方法来比较是否相等。而不是使用==,因为在-128~127范围的数字使用==和不在这个范围的数字是不等的
  3. SearchCategory 没有重写 hashcode 和 equals 方法
  4. categorySet存在有概率出现迭代时修改问题,在for循环当中新增/修改/移除元素

序号17

不可变集合

促销活动查询用户库存

public List<PlatformUserStock> queryAllPlatformUserStock(long userId) {
final List<PlatformUserStock> platformUserDayStocks = this.platformUserStockService
.queryUserStock(userId, StoreLimitTypeEnum.EVERY_DAY.getCode());
final List<PlatformUserStock> platformUserAllStocks = this.platformUserStockService
.queryUserStock(userId, StoreLimitTypeEnum.TOTAL_LIMIT.getCode());
platformUserDayStocks.addAll(platformUserAllStocks);
return platformUserDayStocks;
}

public List<PlatformUserStock> queryUserStock(Long userId, int type) {
if (userId == null) {
return Collections.emptyList();
}
return platformUserTotalStockMapper.queryUserStock(userId, type);
}

官方采分点:

1、 Collections.emptyList() 是不可变list,在后面的使用中,调用add方法是不支持的,会出现异常。

我的采分点:

  1. 为什么调用queryUserStock方法要采用:注入自己的方式来调用,this.platformUserStockService,应该要删除
  2. queryAllPlatformUserStock的入参是基本类型:long,而queryUserStock是封装类型:Long,所以第11行的代码返回结果永远为true,入参类型应该要保持一致
  3. 注意点:第10行的代码永远不会返回空指针,因为mybatis会帮我们封装空集合
  4. Collections.emptyList() 是不可变list,在后面的使用中,调用add方法是不支持的,会出现异常。

序号21

redis原子操作

机票报价代理商政策批量导入时,会给代理商加锁,只允许一个任务对数据库进行写操作。

public static boolean lockImport(String key, String value, int expire) {
String redisKey = makeKey(key);
try {
if (redisClient.setnx(redisKey, value) >= SETNX_SUCCESS) {
redisClient.expire(redisKey, expire);
return true;
}
} catch (Exception e) {
QMonitor.recordOne("redis_add_error");
}
return false;
}

官方得分点:

1、第四行和第五行,加锁和设置过期时间应该要原子性操作
2、catch异常,直接吞了异常,没有打印异常堆栈信息

我的得分点:

同上~

序号22

Stream不可变性和TreeSet的排序

在机票首页有一个乘机人填写框,通过填写框可以添加本次搜索的乘机人。在乘机人填写框上 有个推荐逻辑,推荐我的联系人列表里的最近有过生单记录的乘机人 2 人,乘机人选取顺序是生单时间最近的 2 人。

代码上下文:本段代码是过滤掉乘机人证件类型不是身份证的乘机人。

private void certsFilter(OmPassengersResponse omPassengersResponse) {
List<OmPassengersResponse.Passenger> passengers = omPassengersResponse.getPassengers();
// 按照订单创建时间排序
passengers = omPassengersResponse.getPassengers().stream()
.sorted((a, b) -> a.getOrderTime().compareTo(b.getOrderTime())).collect(Collectors.toList());
// 无手机号不推荐
passengers = omPassengersResponse.getPassengers().stream()
.filter(passenger -> StringUtils.isNotBlank(passenger.getPhoneNum())).collect(Collectors.toList());
// 无证件过滤,根据姓名和身份证号去重
passengers = passengers.stream().filter(passenger -> CollectionUtils.isNotEmpty(passenger.getCerts()))
.collect(Collectors.collectingAndThen(Collectors.toCollection(() -> new TreeSet<>(Comparator.comparing(compare()))), ArrayList::new));
if (passengers.size() > configVal.getNum()) {
passengers = passengers.subList(0, configVal.getNum());
}
omPassengersResponse.setPassengers(passengers);
}

/**
* 根据姓名和身份证号去重
*/
private Function<OmPassengersResponse.Passenger, String> compare() {
return p -> p.getName() + p.getCerts().get(0).getNumber();
}

官方得到分点:

1、本题目考查的是过滤、排序的操作,应该先操作过滤再排序。性能会更好。
2、使用TreeSet<>(Comparator.comparing(compare())))来排序,不符合题目的要求:最近有过生单记录的乘机人 2 人

序号23

编码规范

规则校验 充血模式开发,在 entity 中增加一些必要的逻辑代码,例如重写 get 方法等

代码上下文:规则校验简易代码,RuleParamEntity 为规则校验的入参,runQlExpress 为规则执行代码。

class RuleParamEntity implements Serializable {
private static final long serialVersionUID = -7816951117938588941L;
/**
* 规则编号
*/
private String ruleNo;
/**
* 用户名称
*/
private String userName;
/**
* qunar 手机号
*/
private String qunarMobile;

public String getQunarMobile() {
if (StringUtils.isNotBlank(qunarMobile)) {
return qunarMobile;
}
if (StringUtils.isNotBlank(userName) && StringUtils.isBlank(qunarMobile)) {
UserDetail userDetail = UserClient.queryEncryptUserByName(userName);
qunarMobile = userDetail.getMobile();
}
return qunarMobile;
}

public boolean runQlExpress(ExpressRunner runner, RuleParamEntity request, TbfRuleConfEntity entity) {
try {
QMonitor.recordOne("RuleComponent_ruleChecking_" + entity.getKeyword());
DefaultContext<String, Object> context = new DefaultContext<String, Object>();
Object curInfo = ParseBeanUtils.invokeMethod(request, entity.getKeyword());
if (curInfo == null) {
log.info("runQlExpress get curInfo blank ! request : {} entity : {}", JSONObject.toJSONString(request), JSONObject.toJSONString(entity));
QMonitor.recordOne("RuleComponent_ruleChecking_" + entity.getKeyword() + "_NOT_VERIFIED");
entity.setVerificationResult(VerificationResultsEnum.NOT_VERIFIED.getCode());
return VerificationResultsEnum.NOT_VERIFIED;
}
Object res = runner.execute(entity.getQLExpress(ParseBeanUtils.fieldIsString(request, entity.getKeyword())), context, null, true, false);
if (!((Boolean) res).booleanValue()) {
entity.setVerificationResult(VerificationResultsEnum.DENIED.getCode());
QMonitor.recordOne("RuleComponent_ruleChecking_" + entity.getKeyword() + "_DENIED");
return VerificationResultsEnum.DENIED;
}
entity.setVerificationResult(VerificationResultsEnum.PASSED.getCode());

QMonitor.recordOne("RuleComponent_ruleChecking_" + entity.getKeyword() + "_PASSED");
return VerificationResultsEnum.PASSED;
} catch (Exception e) {
log.error("ruleChecking error request : {} entity : {}", JSONObject.toJSONString(request), JSONObject.toJSONString(entity), e);
QMonitor.recordOne("RuleComponent_runQlExpress_Exception");
}
entity.setVerificationResult(VerificationResultsEnum.DENIED.getCode());
return VerificationResultsEnum.DENIED;
}

}

官方得分:

1、49行JSONObject.toJSONString(entity),使用fastJson库会调用entity的get方法,但是我们业务中使用的充血模型。重写getQunarMobile()方法,建议方法名修改为queryXXX()
2、12行代码userDetail,使用该对象的时候,需要判空

序号24

接口安全

更新当前用户的基本信息和地址信息,该操作必须要求用户是登录态。

@RestController
public class DemoController {
private final UserService userService;

public DemoController(UserService userService) {
this.userService = userService;
}

@PostMapping("/update")
public Object updateUserInfo(@Valid @RequestBody UserUpdateCommand command, HttpServletRequest request) {
String openId = getCurrentUser(request).getOpenid();
if (StringUtils.isEmpty(openId)) {
return ApiResult.forbidden("用户未登录,不允许进行操作");
}
List<Address> addresses = Lists.transform(command.getAddresses(), (Function<AddressDTO, Address>) input -> DTOMapper.INSTANCE.toDTO(input));
User user = userService.queryUser(command.getOpenId());
for (Address address : addresses) {
address.setOpenId(openId);
}
DTOMapper.INSTANCE.copyToEntity(command, user);
user.setAddresses(addresses);
userService.save(user);
return ApiResult.success();
}
}

官方得分点:

1、11行获取到的openId和传参数的command的openId有可能不同,需要加上校验逻辑

我的得分点:

同上~

序号14

redis 分布式锁实现

基础数据与携程比价后调用中转点庖丁接口,发现有中转报价缺失时会发 QMQ 消息,当缺失 原因为【政策包报价不包含当前中转点过滤】时,需要记录当前航线中转点,并按时间先后补 齐缺失中转点城市至最大数量
代码上下文:

1、data 为基础数据推送 QMQ 消息 bean,内有中转航组信息和缺失原因

2、中转航组信息机场码需转城市码

3、获取航线锁,使用 zset 存储缺失中转点至最大数量

4、QMQ 推送消息 QPS 为 2~3

{
String depAirport1 = data.getDep1();
String arrAirport1 = data.getArr1();
String depAirport2 = data.getDep2();
String arrAirport2 = data.getArr2();
String flyDate1 = data.getFlyDate1();
if (!Objects.equals(arrAirport1, depAirport2)) {
QMonitor.recordOne("smart_flight_price_miss_mid_different");
}
//缺失的中转点
String missedMidAirport = StringUtils.isBlank(arrAirport1) ? depAirport2 : arrAirport1;
if (StringUtils.isBlank(depAirport1) || StringUtils.isBlank(arrAirport2) || StringUtils.isBlank(missedMidAirport) || StringUtils.isBlank(flyDate1)) {
logger.error("缺少必要信息,本次中转点不补充 depAirport1:{},arrAirport2:{},missedMidAirport:{},flyDate1:{}", depAirport1, arrAirport2, missedMidAirport, flyDate1);
QMonitor.recordOne("smart_flight_price_miss_param_miss");
return;
}
String lockKey = null;
boolean tryLock = false;
try {
//机场码转城市码
String depCity1 = InfoCenter.getCityCodeFromAirportCode(depAirport1);
String missedMidCity = InfoCenter.getCityCodeFromAirportCode(missedMidAirport);
String arrCity2 = InfoCenter.getCityCodeFromAirportCode(arrAirport2);
lockKey = getLockKey(depCity1, arrCity2, flyDate1);
//尝试获取航线锁
tryLock = RedisLockUtil.checkLock(transferSedis, lockKey, QConfigUtils.getConfig("union_price_miss_lock_expire_time", 3000));
if (!tryLock) {
logger.info("smart_flight_price_miss_lock_check is locked {},{},{}", depCity1, arrCity2, flyDate1);
QMonitor.recordOne("smart_flight_price_miss_lock_check_is_locked");
return;
}

String redisKey = ctripMissMiddleCityService.buildCacheKey(depCity1, arrCity2, flyDate1);
Long sedisSize = transferSedis.zcard(redisKey);
int selectMaxCount = QConfigUtils.getConfig(QconfigConstant.SELECT_MAX_COUNT, QconfigConstant.DEFAULT_SELECT_MAX_COUNT);
long overSize = sedisSize - selectMaxCount;
if (overSize >= 0) {
transferSedis.zremrangeByRank(redisKey, 0L, overSize);
}
transferSedis.zadd(redisKey, System.currentTimeMillis(), missedMidCity);
transferSedis.expire(redisKey, QConfigUtils.getConfig("union_price_miss_mid_city_expire_time", 60000));
QMonitor.recordOne("smart_flight_price_miss_put_zset");
} catch (Exception e) {
logger.error("获取缺失中转点错误", e);
QMonitor.recordOne("smart_flight_price_miss_city_add_error");
} finally {
if (tryLock && StringUtils.isNotEmpty(lockKey)) {
//解锁
RedisLockUtil.unlock(transferSedis, lockKey);
}
}
}

官方得分点:

1、可能当前线程会释放其它线程加的锁
2、没有实现锁续命逻辑,可能有多个请求同时执行同步代码逻辑
3、zset存redis与设置zset集合key的过期时间两者没有原子执行
采分点:
1、 RedisLockUtil.unlock(transferSedis, lockKey);释放其他线程的锁,通常
一个客户端一个锁
2、实现续命锁逻辑对时间进行刷新
3、设置过期时间要原子操作(可用lua脚本调用多条执行命令)

我的得分点:

学习redission实现的分布式锁。