目录
验证数据
加载城市数据
查询列车站点信息
查询列车余票信息
构建列车返回数据
12306 项目中列车数据检索接口路径 TicketController的pageListTicketQuery。
@GetMapping("/api/ticket-service/ticket/query")
public Result<TicketPageQueryRespDTO> pageListTicketQuery(TicketPageQueryReqDTO requestParam) {
return Results.success(ticketService.pageListTicketQueryV1(requestParam));
}
验证数据
查询列车数据 Service 实现层接口第一行代码,就是通过责任链模式验证数据是否必填以及城市
数据是否存在等执行逻辑。
ticketPageQueryAbstractChainContext.handler(TicketChainMarkEnum.TRAIN_QUERY_FILTER.name(), requestParam);
handler具体代码如下:
1. 检查相关数据是否为空或空的字符串,这个是最先被执行的:
/**
* 查询列车车票流程过滤器之验证数据是否为空或空的字符串
*
*
*/
@Component
public class TrainTicketQueryParamNotNullChainFilter implements TrainTicketQueryChainFilter<TicketPageQueryReqDTO> {
@Override
public void handler(TicketPageQueryReqDTO requestParam) {
if (StrUtil.isBlank(requestParam.getFromStation())) {
throw new ClientException("出发地不能为空");
}
if (StrUtil.isBlank(requestParam.getToStation())) {
throw new ClientException("目的地不能为空");
}
if (requestParam.getDepartureDate() == null) {
throw new ClientException("出发日期不能为空");
}
}
@Override
public int getOrder() {
return 0;
}
}
2. 检查数据是否正确
/**
* 查询列车车票流程过滤器之验证数据是否正确
*
*
*/
@Component
@RequiredArgsConstructor
public class TrainTicketQueryParamVerifyChainFilter implements TrainTicketQueryChainFilter<TicketPageQueryReqDTO> {
private final RegionMapper regionMapper;
private final StationMapper stationMapper;
private final DistributedCache distributedCache;
private final RedissonClient redissonClient;
/**
* 缓存数据为空并且已经加载过标识
*/
private static boolean CACHE_DATA_ISNULL_AND_LOAD_FLAG = false;
@Override
public void handler(TicketPageQueryReqDTO requestParam) {
StringRedisTemplate stringRedisTemplate = (StringRedisTemplate) distributedCache.getInstance();
HashOperations<String, Object, Object> hashOperations = stringRedisTemplate.opsForHash();
List<Object> actualExistList = hashOperations.multiGet(
QUERY_ALL_REGION_LIST,
ListUtil.toList(requestParam.getFromStation(), requestParam.getToStation())
);
long emptyCount = actualExistList.stream().filter(Objects::isNull).count();
if (emptyCount == 0L) {
return;
}
if (emptyCount == 1L || (emptyCount == 2L && CACHE_DATA_ISNULL_AND_LOAD_FLAG && distributedCache.hasKey(QUERY_ALL_REGION_LIST))) {
throw new ClientException("出发地或目的地不存在");
}
RLock lock = redissonClient.getLock(LOCK_QUERY_ALL_REGION_LIST);
lock.lock();
try {
if (distributedCache.hasKey(QUERY_ALL_REGION_LIST)) {
actualExistList = hashOperations.multiGet(
QUERY_ALL_REGION_LIST,
ListUtil.toList(requestParam.getFromStation(), requestParam.getToStation())
);
emptyCount = actualExistList.stream().filter(Objects::nonNull).count();
if (emptyCount != 2L) {
throw new ClientException("出发地或目的地不存在");
}
return;
}
List<RegionDO> regionDOList = regionMapper.selectList(Wrappers.emptyWrapper());
List<StationDO> stationDOList = stationMapper.selectList(Wrappers.emptyWrapper());
HashMap<Object, Object> regionValueMap = Maps.newHashMap();
for (RegionDO each : regionDOList) {
regionValueMap.put(each.getCode(), each.getName());
}
for (StationDO each : stationDOList) {
regionValueMap.put(each.getCode(), each.getName());
}
hashOperations.putAll(QUERY_ALL_REGION_LIST, regionValueMap);
CACHE_DATA_ISNULL_AND_LOAD_FLAG = true;
emptyCount = regionValueMap.keySet().stream()
.filter(each -> StrUtil.equalsAny(each.toString(), requestParam.getFromStation(), requestParam.getToStation()))
.count();
if (emptyCount != 2L) {
throw new ClientException("出发地或目的地不存在");
}
} finally {
lock.unlock();
}
}
@Override
public int getOrder() {
return 20;
}
}
加载城市数据
12306 站点查询实际功能中,比如你搜索了北京南到杭州东的搜索条件,它会帮你列出北京到杭州所有的列车车次。这个很好实现,直接通过站点关联到城市,通过城市查询列车即可。我们在缓存中,有一个 Hash 结构数据,专门负责保存列车站点 Code 值与城市之间的关联关系。
// 列车查询逻辑较为复杂,详细解析文章查看 https://nageoffer.com/12306/question
// v1 版本存在严重的性能深渊问题,v2 版本完美的解决了该问题。通过 Jmeter 压测聚合报告得知,性能提升在 300% - 500%+
List<Object> stationDetails = stringRedisTemplate.opsForHash()
.multiGet(REGION_TRAIN_STATION_MAPPING, Lists.newArrayList(requestParam.getFromStation(), requestParam.getToStation()));
long count = stationDetails.stream().filter(Objects::isNull).count();
if (count > 0) {
RLock lock = redissonClient.getLock(LOCK_REGION_TRAIN_STATION_MAPPING);
lock.lock();
try {
stationDetails = stringRedisTemplate.opsForHash()
.multiGet(REGION_TRAIN_STATION_MAPPING, Lists.newArrayList(requestParam.getFromStation(), requestParam.getToStation()));
count = stationDetails.stream().filter(Objects::isNull).count();
if (count > 0) {
List<StationDO> stationDOList = stationMapper.selectList(Wrappers.emptyWrapper());
Map<String, String> regionTrainStationMap = new HashMap<>();
stationDOList.forEach(each -> regionTrainStationMap.put(each.getCode(), each.getRegionName()));
stringRedisTemplate.opsForHash().putAll(REGION_TRAIN_STATION_MAPPING, regionTrainStationMap);
stationDetails = new ArrayList<>();
stationDetails.add(regionTrainStationMap.get(requestParam.getFromStation()));
stationDetails.add(regionTrainStationMap.get(requestParam.getToStation()));
}
} finally {
lock.unlock();
}
}
查询列车站点信息
采用Redis而不是Elasticsearch,因为搜索只允许选择一天的出发日期。
同时在12306网站上尝试,虽然页面上有很多查询条件,但大多数条件都是由前端进行筛选,实际上并没有触发后端的请求,发现只有在点击“查询”按钮时才会真正触发后端的请求,而点击页面上的其他筛选条件并不会向后端发出请求。
列车站点数据存入 Redis 中,结构如下:
具体查询代码:
List<TicketListDTO> seatResults = new ArrayList<>();
String buildRegionTrainStationHashKey = String.format(REGION_TRAIN_STATION, stationDetails.get(0), stationDetails.get(1));
Map<Object, Object> regionTrainStationAllMap = stringRedisTemplate.opsForHash().entries(buildRegionTrainStationHashKey);
if (MapUtil.isEmpty(regionTrainStationAllMap)) {
RLock lock = redissonClient.getLock(LOCK_REGION_TRAIN_STATION);
lock.lock();
try {
regionTrainStationAllMap = stringRedisTemplate.opsForHash().entries(buildRegionTrainStationHashKey);
if (MapUtil.isEmpty(regionTrainStationAllMap)) {
LambdaQueryWrapper<TrainStationRelationDO> queryWrapper = Wrappers.lambdaQuery(TrainStationRelationDO.class)
.eq(TrainStationRelationDO::getStartRegion, stationDetails.get(0))
.eq(TrainStationRelationDO::getEndRegion, stationDetails.get(1));
List<TrainStationRelationDO> trainStationRelationList = trainStationRelationMapper.selectList(queryWrapper);
for (TrainStationRelationDO each : trainStationRelationList) {
TrainDO trainDO = distributedCache.safeGet(
TRAIN_INFO + each.getTrainId(),
TrainDO.class,
() -> trainMapper.selectById(each.getTrainId()),
ADVANCE_TICKET_DAY,
TimeUnit.DAYS);
TicketListDTO result = new TicketListDTO();
result.setTrainId(String.valueOf(trainDO.getId()));
result.setTrainNumber(trainDO.getTrainNumber());
result.setDepartureTime(convertDateToLocalTime(each.getDepartureTime(), "HH:mm"));
result.setArrivalTime(convertDateToLocalTime(each.getArrivalTime(), "HH:mm"));
result.setDuration(DateUtil.calculateHourDifference(each.getDepartureTime(), each.getArrivalTime()));
result.setDeparture(each.getDeparture());
result.setArrival(each.getArrival());
result.setDepartureFlag(each.getDepartureFlag());
result.setArrivalFlag(each.getArrivalFlag());
result.setTrainType(trainDO.getTrainType());
result.setTrainBrand(trainDO.getTrainBrand());
if (StrUtil.isNotBlank(trainDO.getTrainTag())) {
result.setTrainTags(StrUtil.split(trainDO.getTrainTag(), ","));
}
long betweenDay = cn.hutool.core.date.DateUtil.betweenDay(each.getDepartureTime(), each.getArrivalTime(), false);
result.setDaysArrived((int) betweenDay);
result.setSaleStatus(new Date().after(trainDO.getSaleTime()) ? 0 : 1);
result.setSaleTime(convertDateToLocalTime(trainDO.getSaleTime(), "MM-dd HH:mm"));
seatResults.add(result);
regionTrainStationAllMap.put(CacheUtil.buildKey(String.valueOf(each.getTrainId()), each.getDeparture(), each.getArrival()), JSON.toJSONString(result));
}
stringRedisTemplate.opsForHash().putAll(buildRegionTrainStationHashKey, regionTrainStationAllMap);
}
} finally {
lock.unlock();
}
}
查询出来列车基本信息后,开始对列车按照出发时间进行排序。
seatResults = CollUtil.isEmpty(seatResults)
? regionTrainStationAllMap.values().stream().map(each -> JSON.parseObject(each.toString(), TicketListDTO.class)).toList()
: seatResults;
seatResults = seatResults.stream().sorted(new TimeStringComparator()).toList();
查询列车余票信息
列车基本信息已经全部填充完毕了,接下来就是查询列车余票信息并填充到基本信息中。
列车余票数据是实时变更的,如果在存储到基本信息中,就没办法变更了,所以单独存储。
for (TicketListDTO each : seatResults) {
String trainStationPriceStr = distributedCache.safeGet(
String.format(TRAIN_STATION_PRICE, each.getTrainId(), each.getDeparture(), each.getArrival()),
String.class,
() -> {
LambdaQueryWrapper<TrainStationPriceDO> trainStationPriceQueryWrapper = Wrappers.lambdaQuery(TrainStationPriceDO.class)
.eq(TrainStationPriceDO::getDeparture, each.getDeparture())
.eq(TrainStationPriceDO::getArrival, each.getArrival())
.eq(TrainStationPriceDO::getTrainId, each.getTrainId());
return JSON.toJSONString(trainStationPriceMapper.selectList(trainStationPriceQueryWrapper));
},
ADVANCE_TICKET_DAY,
TimeUnit.DAYS
);
List<TrainStationPriceDO> trainStationPriceDOList = JSON.parseArray(trainStationPriceStr, TrainStationPriceDO.class);
List<SeatClassDTO> seatClassList = new ArrayList<>();
trainStationPriceDOList.forEach(item -> {
String seatType = String.valueOf(item.getSeatType());
String keySuffix = StrUtil.join("_", each.getTrainId(), item.getDeparture(), item.getArrival());
Object quantityObj = stringRedisTemplate.opsForHash().get(TRAIN_STATION_REMAINING_TICKET + keySuffix, seatType);
int quantity = Optional.ofNullable(quantityObj)
.map(Object::toString)
.map(Integer::parseInt)
.orElseGet(() -> {
Map<String, String> seatMarginMap = seatMarginCacheLoader.load(String.valueOf(each.getTrainId()), seatType, item.getDeparture(), item.getArrival());
return Optional.ofNullable(seatMarginMap.get(String.valueOf(item.getSeatType()))).map(Integer::parseInt).orElse(0);
});
seatClassList.add(new SeatClassDTO(item.getSeatType(), quantity, new BigDecimal(item.getPrice()).divide(new BigDecimal("100"), 1, RoundingMode.HALF_UP), false));
});
each.setSeatClassList(seatClassList);
}
构建列车返回数据
查看 12306 列车查询页可知,会存在不同的查询条件,这些查询条件都是通过本次查询所有列车数据构建出来的。不同地区的不同查询列车数据,车次类型、出发车站、到达车站以及车次席别都不同。
接下来就是通过构建者模式构建列车查询返回数据。
return TicketPageQueryRespDTO.builder()
.trainList(seatResults)
.departureStationList(buildDepartureStationList(seatResults))
.arrivalStationList(buildArrivalStationList(seatResults))
.trainBrandList(buildTrainBrandList(seatResults))
.seatClassTypeList(buildSeatClassList(seatResults))
.build();