1、引言
2、缘起
3、物相
4、知行
4.1、知行—运营策略
4.2、知行—产品设计
4.3、知行—运行环境搭建
4.4、知行—技术后端系分
4.5、知行—Java日志与使用
4.6、知行—踩坑与经验
4.7、知行—代码浅析
5、结语
整体代码框架
本文以抖音短视频的解析功能为例,分析讲解下代码:
前端
代码目录

前端开发参考:https://developers.weixin.qq.com/miniprogram/dev/reference/
1、images:放自己的静态图片文件
2、pages:基于页面分文件,首页、我的页面、历史页
3、utils:工具类
4、app:小程序的启动处理、整体小程序配置相关
整体前端围绕四个文件开发:
文件后缀 | 功能 |
---|---|
js | 核心处理逻辑 |
json | 页面全局配置 |
wxml | 页面细节布局 |
wxss | 自定义样式 |
细节分析
如首页去水印功能:

1、json
json里:
定义了页面标题、背景色、文字样式
2、wxss

局部细节的样式,如字体大小、颜色、对齐风格等
3、wxml
1、定义视频链接输入框,支持清空
2、用户去水印按钮,点击触发 js 里的 submit 方法
4、js
这里会先判断用户是否已登陆(即已获取到用户的openId),然后调用服务端http接口来去解析,成功会跳转到我的解析记录页面:

后端
代码目录

整体按照标准的 spring 架构分层设计:
Layer | Bundle | 功能 |
---|---|---|
biz | controller | web请求处理 |
scheduler | 定时任务调度 | |
message | 监听消息处理 | |
serviceimpl | 门面方法的实现层,涉及TR、RPC接口形式 | |
core | model | 领域模型 |
service | 核心服务 | |
common | dal | 数据库操作处理 |
utils | 工具方法 | |
serviceintegration | 调用外部系统方法 | |
servicefacade | 提供门面方法给外部调用 |
细节分析
1、trace切面
这里用拦截器实现将 TRACE_ID 放入 MDC 的上下文中,方便 logback 日志打印出来
public class TraceInterceptor implements HandlerInterceptor {
/**
* 前处理
*
* @param request 请求
* @param response 响应
* @param handler 处理
* @return boolean
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
MDC.put(LoggerConstants.TRACE_ID, UUID.randomUUID().toString()
.replace(CharsetConstants.DASH, StringUtils.EMPTY).toLowerCase());
return true;
}
/**
* 完成后
*
* @param request 请求
* @param response 响应
* @param handler 处理程序
* @param ex 异常
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) {
MDC.remove(LoggerConstants.TRACE_ID);
}
}
2、流程模版
这里提供模版方法供各接口使用,方便统一异常、日志处理;同时模版处理沉淀在core层,方便后续提供TR/RPC给其他系统调用时,不需要更改打印,代码层级等处理
public class ServiceTemplate {
/**
* 摘要日志记录器
*/
private static final Logger DIGEST_LOGGER = LoggerFactory.getLogger(LoggerConstants.CORE_SERVICE_DIGEST_LOGGER);
/**
* 日志记录器
*/
private static final Logger LOGGER = LoggerFactory.getLogger(ServiceTemplate.class);
/**
* 执行
*
* @param result 结果
* @param callback 回调
*/
public static void execute(BaseResult result, ServiceCallback callback) {
// 获取调用方法名
String declareMethod = MethodUtil.getPureCallMethodName(ServiceTemplate.class);
// 计时器
StopWatch stopWatch = new StopWatch();
stopWatch.start();
try {
// 执行服务校验参数
callback.checkParameter();
// 执行服务处理
callback.process();
result.setSuccess(true);
} catch (BizException e) {
LOGGER.warn(String.format("BizException, (method=%s, errorCode=%s, message=%s)", declareMethod, e.getResultCode(), e.getMessage()), e);
BaseResult.fillFailureResult(result, ErrorCodeEnum.getByCode(e.getResultCode()), e.getMessage());
} catch (Throwable e) {
LOGGER.error(String.format("Throwable, (method=%s, message=%s)", declareMethod, e.getMessage()), e);
BaseResult.fillFailureResult(result, ErrorCodeEnum.UNKNOWN_EXCEPTION);
} finally {
// 执行服务打印
callback.finalLog();
// 摘要日志处理
stopWatch.stop();
String extraDigestStr = StringUtils.EMPTY;
List<String> extraDigestLogItemList = callback.extraDigestLogItemList();
if (!CollectionUtils.isEmpty(extraDigestLogItemList)) {
extraDigestStr = String.join(CharsetConstants.COMMA, extraDigestLogItemList);
}
// 摘要打印:方法名、耗时、是否成功、是否可重试、结果码、额外摘要
DIGEST_LOGGER.info(String.format("%s|%s|%s|%s|%s|%s", declareMethod, stopWatch.getTotalTimeMillis(),
result.isSuccess(), result.getErrorCode() == null ? StringUtils.EMPTY : result.isRetryFail(),
result.getErrorCode() == null ? StringUtils.EMPTY : result.getErrorCode(), extraDigestStr));
}
}
}
3、解析视频处理:
1、parserMap 自动注入相关的解析器,key为对应的beanId,value为bean实例
2、Transactional 事务注解,表明两个db的操作是需要同时成功的
@Service
public class VideoServiceImpl implements VideoService {
private static final Logger LOGGER = LoggerFactory.getLogger(VideoServiceImpl.class);
/**
* 解析器map
*/
@Autowired
private Map<String, Parser> parserMap;
/**
* 用户信息dao
*/
@Resource
private UserInfoDAO userInfoDAO;
/**
* 解析视频记录dao
*/
@Resource
private ParseVideoRecordDAO parseVideoRecordDAO;
/**
* 解析视频
*
* @param url url
* @param userId 用户id
* @return {@link BaseResult}<{@link Boolean}>
*/
@Override
public BaseResult parseVideo(String url, String userId) {
BaseResult baseResult = new BaseResult<>();
ServiceTemplate.execute(baseResult, new ServiceCallback() {
@Override
public void checkParameter() {
AssertUtil.assertNotBlank(url, "视频url不能为空");
AssertUtil.assertNotNull(VideoSourceEnum.getByUrl(url), String.format("此链接目前不支持解析,目前仅支持:%s",
Arrays.stream(VideoSourceEnum.values()).map(VideoSourceEnum::getDesc).collect(Collectors.toList())));
AssertUtil.assertNotBlank(userId, "userId不能为空");
}
@Override
public void process() {
doParseVideo(url, userId);
}
@Override
public void finalLog() {
LOGGER.debug(String.format("解析视频,入参[url:%s, userId:%s], 出参[baseResult:%s]", url, userId, baseResult));
}
@Override
public List<String> extraDigestLogItemList() {
List<String> itemList = new ArrayList<>();
itemList.add(url);
itemList.add(userId);
return itemList;
}
});
return baseResult;
}
/**
* 解析视频
*
* @param url url
* @param userId 用户id
*/
private void doParseVideo(String url, String userId) {
// 1、查询用户数据
QueryWrapper<UserInfoDO> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(UserInfoDO::getUserId, userId);
UserInfoDO userInfoDO = userInfoDAO.selectOne(queryWrapper);
AssertUtil.assertNotNull(userInfoDO, ErrorCodeEnum.USER_INFO_NULL);
AssertUtil.assertTrue(userInfoDO.getAvailableNumber() > 0, ErrorCodeEnum.NONE_AVAILABLE_PARSE_COUNT);
// 2、视频解析,videoSourceEnum外层保护过了,这里不需要判null
VideoSourceEnum videoSourceEnum = VideoSourceEnum.getByUrl(url);
VideoDTO videoDTO = parserMap.get(videoSourceEnum.getParserBeanId()).parseVideo(url);
AssertUtil.assertNotNull(videoDTO, ErrorCodeEnum.WEB_API_CALL_FAIL);
// 3、数据事务操作
userInfoDO.setAvailableNumber(userInfoDO.getAvailableNumber() - 1);
userInfoDO.setParsedNumber(userInfoDO.getParsedNumber() + 1);
ParseVideoRecordDO parseVideoRecordDO = new ParseVideoRecordDO();
parseVideoRecordDO.setUserId(userInfoDO.getUserId());
BeanUtils.copyProperties(videoDTO, parseVideoRecordDO);
databaseTransaction(userInfoDO, parseVideoRecordDO);
}
/**
* 数据库事务
*
* @param userInfoDO 用户信息
* @param parseVideoRecordDO 解析视频记录
*/
@Transactional(timeout = 10)
public void databaseTransaction(UserInfoDO userInfoDO, ParseVideoRecordDO parseVideoRecordDO) {
userInfoDAO.updateById(userInfoDO);
parseVideoRecordDAO.insert(parseVideoRecordDO);
}
}
4、抖音解析器
1、基于原始链接正则提取出需要的真实链接,如原始链接:“2.89 复制打开抖音,看看【舌尖的作品】羊肉的奶香味与韭花酱的咸香碰撞融合,这一口内蒙古手… https://v.douyin.com/CeiJLLMMS/ sRK:/ 05/27 H@V.yt”,提取出:“https://v.douyin.com/CeiJLLMMS/”
2、headForHeaders 来获取链接请求后的 Location 内容,这是链接对应的 HRAD 请求后资源的重定向地址,可以提取出视频id
3、拼接后 GET 请求:“https://www.iesdouyin.com/share/video/” + videoId,可以获取页面的信息,里面是各种页面 Element 元素,找到需要的视频数据,根据对应路径 json 解析即可,然后视频链接里吧 “playwm” 替换成 “play” 就是无水印的原始视频
@Service
public class DouYinParser extends AbstractParser {
/**
* 数据key
*/
private static final String DATA_KEY = "window._ROUTER_DATA = ";
/**
* 数据路径
*/
private static final String DATA_PATH = "$.loaderData.['video_(id)/page'].videoInfoRes.item_list[0].";
/**
* 解析视频
*
* @param originalUrl 原始地址
*/
@Override
public VideoDTO parseVideo(String originalUrl) {
// 1、去除掉无用视频前后缀
String url = fetchTargetUrl(originalUrl, "(https?://v.douyin.com/[\\S]*)");
// 2、获取重定向后的地址,来获取视频id
url = restTemplateService.headForHeaders(url).getLocation().toString();
Matcher matcher = Pattern.compile("/share/video/([\\d]*)[/|?]").matcher(url);
if (!matcher.find()) {
throw new BizException(ErrorCodeEnum.ILLEGAL_VIDEO_URL);
}
String videoId = matcher.group(1);
if (StringUtils.isBlank(videoId)) {
throw new BizException(ErrorCodeEnum.ILLEGAL_VIDEO_URL);
}
// 3、http调用api获取结果
String dyWebApi = "https://www.iesdouyin.com/share/video/" + videoId;
HttpHeaders httpHeaders = fetchHttpHeaders(dyWebApi, null);
String content = restTemplateService.fetchHttpEntity(dyWebApi, httpHeaders, HttpMethod.GET, null, String.class);
if (StringUtils.isBlank(content)) {
throw new BizException(ErrorCodeEnum.WEB_API_CALL_FAIL);
}
// 4、解析出html标签,找到DATA_KEY对应的元素
Document document = Jsoup.parse(content);
for (Element element : document.getAllElements()) {
if (element == null) {
continue;
}
int index = element.data().indexOf(DATA_KEY);
if (index < 0) {
continue;
}
// 解码
String decodeContent = element.data().substring(index + DATA_KEY.length());
// 5、提取出对象
DocumentContext context = JsonPath.parse(decodeContent);
VideoDTO videoDTO = new VideoDTO();
videoDTO.setVideoId(videoId);
videoDTO.setVideoSource(VideoSourceEnum.DOU_YIN.getCode());
videoDTO.setVideoOriginalUrl(originalUrl);
videoDTO.setVideoTitle(context.read(DATA_PATH + "desc"));
videoDTO.setVideoCover(context.read(DATA_PATH + "video.cover.url_list[0]"));
videoDTO.setVideoParsedUrl(((String) context.read(DATA_PATH + "video.play_addr.url_list[0]"))
.replace("playwm", "play"));
return videoDTO;
}
return null;
}