最近,前端开发把我恶心着了,为了甩锅,我写了个牛逼的日志切面!
上一篇:字节跳动面试经验总结,已顺利拿到offer!
背景
最近项目进入联调阶段,服务层的接口需要和协议层进行交互,协议层需要将入参 [json字符串] 组装成服务层所需的 json 字符串,组装的过程中很容易出错。
入参出错导致接口调试失败问题在联调中出现很多次,因此就想写一个请求日志切面把入参信息打印一下,同时协议层调用服务层接口名称对不上也出现了几次,通过请求日志切面就可以知道上层是否有没有发起调用,方便前后端甩锅还能拿出证据。
本篇文章是实战性的,对于切面的原理不会讲解,只会简单介绍一下切面的知识点。
切面介绍
面向切面编程是一种编程范式,它作为 OOP 面向对象编程的一种补充,用于处理系统中分布于各个模块的横切关注点,比如事务管理、权限控制、缓存控制、日志打印等等。
AOP 把软件的功能模块分为两个部分:
核心关注点
横切关注点
业务处理的主要功能为核心关注点,而非核心、需要拓展的功能为横切关注点。AOP 的作用在于分离系统中的各种关注点,将核心关注点和横切关注点进行分离。
使用切面有以下好处:
集中处理某一关注点/横切逻辑
可以很方便的添加/删除关注点
侵入性少,增强代码可读性及可维护性
因此当想打印请求日志时很容易想到切面,对控制层代码 0 侵入。
切面的使用【基于注解】
@Aspect => 声明该类为一个注解类
切点注解:
@Pointcut => 定义一个切点,可以简化代码
通知注解:
@Before => 在切点之前执行代码
@After => 在切点之后执行代码
@AfterReturning => 切点返回内容后执行代码,可以对切点的返回值进行封装
@AfterThrowing => 切点抛出异常后执行
@Around => 环绕,在切点前后执行代码
动手写一个请求日志切面
使用 @Pointcut 定义切点:
@Pointcut("execution(* your_package.controller..*(..))")
public void requestServer() {
}
@Pointcut 定义了一个切点,因为是请求日志切边,因此切点定义的是 Controller 包下的所有类下的方法。定义切点以后在通知注解中直接使用 requestServer 方法名就可以了。
使用 @Before 再切点前执行:
@Before("requestServer()")
public void doBefore(JoinPoint joinPoint) {ServletRequestAttributes attributes = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();LOGGER.info("===============================Start========================");LOGGER.info("IP : {}", request.getRemoteAddr());LOGGER.info("URL : {}", request.getRequestURL().toString());LOGGER.info("HTTP Method : {}", request.getMethod());LOGGER.info("Class Method : {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
}
在进入 Controller 方法前,打印出调用方 IP、请求 URL、HTTP 请求类型、调用的方法名。
使用 @Around 打印进入控制层的入参:
@Around("requestServer()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {long start = System.currentTimeMillis();Object result = proceedingJoinPoint.proceed();LOGGER.info("Request Params : {}", getRequestParams(proceedingJoinPoint));LOGGER.info("Result : {}", result);LOGGER.info("Time Cost : {} ms", System.currentTimeMillis() - start);return result;
}
打印了入参、结果以及耗时。
getRquestParams 方法:
private Map<String, Object> getRequestParams(ProceedingJoinPoint proceedingJoinPoint) {Map<String, Object> requestParams = new HashMap<>();//参数名String[] paramNames = ((MethodSignature)proceedingJoinPoint.getSignature()).getParameterNames();//参数值Object[] paramValues = proceedingJoinPoint.getArgs();for (int i = 0; i < paramNames.length; i++) {Object value = paramValues[i];//如果是文件对象if (value instanceof MultipartFile) {MultipartFile file = (MultipartFile) value;value = file.getOriginalFilename(); //获取文件名}requestParams.put(paramNames[i], value);}return requestParams;}
通过 @PathVariable 以及 @RequestParam 注解传递的参数无法打印出参数名,因此需要手动拼接一下参数名,同时对文件对象进行了特殊处理,只需获取文件名即可。
@After 方法调用后执行:
@After("requestServer()")
public void doAfter(JoinPoint joinPoint) {LOGGER.info("===============================End========================");
}
没有业务逻辑只是打印了 End。
完整切面代码:
@Component
@Aspect
public class RequestLogAspect {private final static Logger LOGGER = LoggerFactory.getLogger(RequestLogAspect.class);@Pointcut("execution(* your_package.controller..*(..))")public void requestServer() {}@Before("requestServer()")public void doBefore(JoinPoint joinPoint) {ServletRequestAttributes attributes = (ServletRequestAttributes)
RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();LOGGER.info("===============================Start========================");LOGGER.info("IP : {}", request.getRemoteAddr());LOGGER.info("URL : {}", request.getRequestURL().toString());LOGGER.info("HTTP Method : {}", request.getMethod());LOGGER.info("Class Method : {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());}@Around("requestServer()")public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {long start = System.currentTimeMillis();Object result = proceedingJoinPoint.proceed();LOGGER.info("Request Params : {}", getRequestParams(proceedingJoinPoint));LOGGER.info("Result : {}", result);LOGGER.info("Time Cost : {} ms", System.currentTimeMillis() - start);return result;}@After("requestServer()")public void doAfter(JoinPoint joinPoint) {LOGGER.info("===============================End========================");}/*** 获取入参* @param proceedingJoinPoint** @return* */private Map<String, Object> getRequestParams(ProceedingJoinPoint proceedingJoinPoint) {Map<String, Object> requestParams = new HashMap<>();//参数名String[] paramNames =
((MethodSignature)proceedingJoinPoint.getSignature()).getParameterNames();//参数值Object[] paramValues = proceedingJoinPoint.getArgs();for (int i = 0; i < paramNames.length; i++) {Object value = paramValues[i];//如果是文件对象if (value instanceof MultipartFile) {MultipartFile file = (MultipartFile) value;value = file.getOriginalFilename(); //获取文件名}requestParams.put(paramNames[i], value);}return requestParams;}
}
高并发下请求日志切面
写完以后对自己的代码很满意,但是想着可能还有完善的地方就和朋友交流了一下。
果然还有继续优化的地方 每个信息都打印一行,在高并发请求下确实会出现请求之间打印日志串行的问题,因为测试阶段请求数量较少没有出现串行的情况。
果然生产环境才是第一发展力,能够遇到更多 bug,写更健壮的代码,解决日志串行的问题只要将多行打印信息合并为一行就可以了,因此构造一个对象。
RequestInfo.java:
@Data
public class RequestInfo {private String ip;private String url;private String httpMethod;private String classMethod;private Object requestParams;private Object result;private Long timeCost;
}
环绕通知方法体:
@Around("requestServer()")
public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {long start = System.currentTimeMillis();ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();Object result = proceedingJoinPoint.proceed();RequestInfo requestInfo = new RequestInfo();requestInfo.setIp(request.getRemoteAddr());requestInfo.setUrl(request.getRequestURL().toString());requestInfo.setHttpMethod(request.getMethod());requestInfo.setClassMethod(String.format("%s.%s", proceedingJoinPoint.getSignature().getDeclaringTypeName(),proceedingJoinPoint.getSignature().getName()));requestInfo.setRequestParams(getRequestParamsByProceedingJoinPoint(proceedingJoinPoint));requestInfo.setResult(result);requestInfo.setTimeCost(System.currentTimeMillis() - start);LOGGER.info("Request Info : {}", JSON.toJSONString(requestInfo));return result;
}
将 url、http request 这些信息组装成 RequestInfo 对象,再序列化打印对象,打印序列化对象结果而不是直接打印对象是因为序列化有更直观、更清晰,同时可以借助在线解析工具对结果进行解析。另外,搜索公众号互联网架构师后台回复“2T”,获取一份惊喜礼包。
是不是还不错?在解决高并发下请求串行问题的同时添加了对异常请求信息的打印,通过使用 @AfterThrowing 注解对抛出异常的方法进行处理。
RequestErrorInfo.java:
@Data
public class RequestErrorInfo {private String ip;private String url;private String httpMethod;private String classMethod;private Object requestParams;private RuntimeException exception;
}
异常通知环绕体:
@AfterThrowing(pointcut = "requestServer()", throwing = "e")
public void doAfterThrow(JoinPoint joinPoint, RuntimeException e) {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();RequestErrorInfo requestErrorInfo = new RequestErrorInfo();requestErrorInfo.setIp(request.getRemoteAddr());requestErrorInfo.setUrl(request.getRequestURL().toString());requestErrorInfo.setHttpMethod(request.getMethod());requestErrorInfo.setClassMethod(String.format("%s.%s", joinPoint.getSignature().getDeclaringTypeName(),joinPoint.getSignature().getName()));requestErrorInfo.setRequestParams(getRequestParamsByJoinPoint(joinPoint));requestErrorInfo.setException(e);LOGGER.info("Error Request Info : {}", JSON.toJSONString(requestErrorInfo));
}
对于异常,耗时是没有意义的,因此不统计耗时,而是添加了异常的打印。
最后放一下完整日志请求切面代码:
@Component
@Aspect
public class RequestLogAspect {private final static Logger LOGGER = LoggerFactory.getLogger(RequestLogAspect.class);@Pointcut("execution(* your_package.controller..*(..))")public void requestServer() {}@Around("requestServer()")public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {long start = System.currentTimeMillis();ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();Object result = proceedingJoinPoint.proceed();RequestInfo requestInfo = new RequestInfo();requestInfo.setIp(request.getRemoteAddr());requestInfo.setUrl(request.getRequestURL().toString());requestInfo.setHttpMethod(request.getMethod());requestInfo.setClassMethod(String.format("%s.%s", proceedingJoinPoint.getSignature().getDeclaringTypeName(),proceedingJoinPoint.getSignature().getName()));requestInfo.setRequestParams(getRequestParamsByProceedingJoinPoint(proceedingJoinPoint));requestInfo.setResult(result);requestInfo.setTimeCost(System.currentTimeMillis() - start);LOGGER.info("Request Info : {}", JSON.toJSONString(requestInfo));return result;}@AfterThrowing(pointcut = "requestServer()", throwing = "e")public void doAfterThrow(JoinPoint joinPoint, RuntimeException e) {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();RequestErrorInfo requestErrorInfo = new RequestErrorInfo();requestErrorInfo.setIp(request.getRemoteAddr());requestErrorInfo.setUrl(request.getRequestURL().toString());requestErrorInfo.setHttpMethod(request.getMethod());requestErrorInfo.setClassMethod(String.format("%s.%s", joinPoint.getSignature().getDeclaringTypeName(),joinPoint.getSignature().getName()));requestErrorInfo.setRequestParams(getRequestParamsByJoinPoint(joinPoint));requestErrorInfo.setException(e);LOGGER.info("Error Request Info : {}", JSON.toJSONString(requestErrorInfo));}/*** 获取入参* @param proceedingJoinPoint** @return* */private Map<String, Object> getRequestParamsByProceedingJoinPoint(ProceedingJoinPoint proceedingJoinPoint) {//参数名String[] paramNames = ((MethodSignature)proceedingJoinPoint.getSignature()).getParameterNames();//参数值Object[] paramValues = proceedingJoinPoint.getArgs();return buildRequestParam(paramNames, paramValues);}private Map<String, Object> getRequestParamsByJoinPoint(JoinPoint joinPoint) {//参数名String[] paramNames = ((MethodSignature)joinPoint.getSignature()).getParameterNames();//参数值Object[] paramValues = joinPoint.getArgs();return buildRequestParam(paramNames, paramValues);}private Map<String, Object> buildRequestParam(String[] paramNames, Object[] paramValues) {Map<String, Object> requestParams = new HashMap<>();for (int i = 0; i < paramNames.length; i++) {Object value = paramValues[i];//如果是文件对象if (value instanceof MultipartFile) {MultipartFile file = (MultipartFile) value;value = file.getOriginalFilename(); //获取文件名}requestParams.put(paramNames[i], value);}return requestParams;}@Datapublic class RequestInfo {private String ip;private String url;private String httpMethod;private String classMethod;private Object requestParams;private Object result;private Long timeCost;}@Datapublic class RequestErrorInfo {private String ip;private String url;private String httpMethod;private String classMethod;private Object requestParams;private RuntimeException exception;}
}
赶紧给你们的应用加上吧【如果没加的话】,没有日志的话,总怀疑上层出错,但是却拿不出证据。
地藏 Kelvin 的评论:关于 traceId 跟踪定位,可以根据 traceId 跟踪整条调用链,以 log4j2 为例介绍如何加入 traceId。
添加拦截器:
public class LogInterceptor implements HandlerInterceptor {private final static String TRACE_ID = "traceId";@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {String traceId = java.util.UUID.randomUUID().toString().replaceAll("-", "").toUpperCase();ThreadContext.put("traceId", traceId);return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView)throws Exception {}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)throws Exception { ThreadContext. remove(TRACE_ID);}
}
在调用前通过 ThreadContext 加入 traceId,调用完成后移除。
修改日志配置文件:在原来的日志格式中添加 traceId 的占位符:
<property name="pattern">[TRACEID:%X{traceId}] %d{HH:mm:ss.SSS} %-5level %class{-1}.%M()/%L - %msg%xEx%n</property>
执行效果:
日志跟踪更方便,DMC 是配置 logback 和 log4j 使用的,使用方式和 ThreadContext 差不多,将 ThreadContext.put 替换为 MDC.put 即可,同时修改日志配置文件。
推荐使用 log4j2,log4j2 也是可以配合 MDC 一起使用的:
MDC 是 slf4j 包下的,其具体使用哪个日志框架与我们的依赖有关。
热心网友评论:if(value instanceof MultipartFile)这种流处理方式?但是如果参数碰到 List files 呢?这种处理方式好像就不行了哈。
确实代码有欠缺考虑的地方,解决方案如下:
//如果是批量文件上传
if (value instanceof List) {try {List<MultipartFile> multipartFiles = castList(value, MultipartFile.class);if (multipartFiles!= null) {List<String> fileNames = new ArrayList<>();for (MultipartFile file : multipartFiles) {fileNames.add(file.getOriginalFilename());}requestParams.put(paramNames[i], fileNames);break;}} catch (ClassCastException e) {//忽略不是文件类型的List}
}
对 List 的类型做一个判断,如果是文件 List,就进行遍历获取文件名。
其中 castList 方法为:
public static <T> List<T> castList(Object obj, Class<T> clazz) {List<T> result = new ArrayList<T>();if (obj instanceof List<?>) {for (Object o : (List<?>) obj) {result.add(clazz.cast(o));}return result;}return null;
}
修改后完整的代码为:
@Component
@Aspect
public class RequestLogAspect {private final static Logger LOGGER = LoggerFactory.getLogger(RequestLogAspect.class);@Pointcut("execution(* com.hikvision.trainplatform.controller..*(..))")public void requestServer() {}@Around("requestServer()")public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {long start = System.currentTimeMillis();ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();Object result = proceedingJoinPoint.proceed();RequestInfo requestInfo = new RequestInfo();requestInfo.setIp(request.getRemoteAddr());requestInfo.setUrl(request.getRequestURL().toString());requestInfo.setHttpMethod(request.getMethod());requestInfo.setClassMethod(String.format("%s.%s", proceedingJoinPoint.getSignature().getDeclaringTypeName(),proceedingJoinPoint.getSignature().getName()));requestInfo.setRequestParams(getRequestParamsByProceedingJoinPoint(proceedingJoinPoint));requestInfo.setResult(result);requestInfo.setTimeCost(System.currentTimeMillis() - start);LOGGER.info("Request Info : {}", JSON.toJSONString(requestInfo));return result;}@AfterThrowing(pointcut = "requestServer()", throwing = "e")public void doAfterThrow(JoinPoint joinPoint, RuntimeException e) {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();HttpServletRequest request = attributes.getRequest();RequestErrorInfo requestErrorInfo = new RequestErrorInfo();requestErrorInfo.setIp(request.getRemoteAddr());requestErrorInfo.setUrl(request.getRequestURL().toString());requestErrorInfo.setHttpMethod(request.getMethod());requestErrorInfo.setClassMethod(String.format("%s.%s", joinPoint.getSignature().getDeclaringTypeName(),joinPoint.getSignature().getName()));requestErrorInfo.setRequestParams(getRequestParamsByJoinPoint(joinPoint));requestErrorInfo.setException(e);LOGGER.info("Error Request Info : {}", JSON.toJSONString(requestErrorInfo));}/*** 获取入参* @param proceedingJoinPoint** @return* */private Map<String, Object> getRequestParamsByProceedingJoinPoint(ProceedingJoinPoint proceedingJoinPoint) {//参数名String[] paramNames = ((MethodSignature)proceedingJoinPoint.getSignature()).getParameterNames();//参数值Object[] paramValues = proceedingJoinPoint.getArgs();return buildRequestParam(paramNames, paramValues);}private Map<String, Object> getRequestParamsByJoinPoint(JoinPoint joinPoint) {//参数名String[] paramNames = ((MethodSignature)joinPoint.getSignature()).getParameterNames();//参数值Object[] paramValues = joinPoint.getArgs();return buildRequestParam(paramNames, paramValues);}private Map<String, Object> buildRequestParam(String[] paramNames, Object[] paramValues) {Map<String, Object> requestParams = new HashMap<>();for (int i = 0; i < paramNames.length; i++) {Object value = paramValues[i];//如果是文件对象if (value instanceof MultipartFile) {MultipartFile file = (MultipartFile) value;value = file.getOriginalFilename(); //获取文件名}//如果是批量文件上传if (value instanceof List) {System.out.println("Yes...");try {List<MultipartFile> multipartFiles = castList(value, MultipartFile.class);if (multipartFiles!= null) {List<String> fileNames = new ArrayList<>();for (MultipartFile file : multipartFiles) {fileNames.add(file.getOriginalFilename());}requestParams.put(paramNames[i], fileNames);break;}} catch (ClassCastException e) {//忽略不是文件类型的List}}requestParams.put(paramNames[i], value);}return requestParams;}public static <T> List<T> castList(Object obj, Class<T> clazz) {List<T> result = new ArrayList<T>();if (obj instanceof List<?>) {for (Object o : (List<?>) obj) {result.add(clazz.cast(o));}return result;}return null;}@Datapublic class RequestInfo {private String ip;private String url;private String httpMethod;private String classMethod;private Object requestParams;private Object result;private Long timeCost;}@Datapublic class RequestErrorInfo {private String ip;private String url;private String httpMethod;private String classMethod;private Object requestParams;private RuntimeException exception;}
}
文章来源:https://c1n.cn/D5opM
感谢您的阅读,也欢迎您发表关于这篇文章的任何建议,关注我,技术不迷茫!小编到你上高速。
· END ·
最后,关注公众号互联网架构师,在后台回复:2T,可以获取我整理的 Java 系列面试题和答案,非常齐全。
正文结束
推荐阅读 ↓↓↓
1.救救大龄码农!45岁程序员在国务院网站求助总理!央媒网评来了...
2.如何才能成为优秀的架构师?
3.从零开始搭建创业公司后台技术栈
4.程序员一般可以从什么平台接私活?
5.37岁程序员被裁,120天没找到工作,无奈去小公司,结果懵了...
6.IntelliJ IDEA 2019.3 首个最新访问版本发布,新特性抢先看
7.这封“领导痛批95后下属”的邮件,句句扎心!
8.15张图看懂瞎忙和高效的区别!
最近,前端开发把我恶心着了,为了甩锅,我写了个牛逼的日志切面!相关推荐
- 8年前端开发的知识点沉淀(不知道会多少字,一直写下去吧...)
先啰嗦几句 2011年开始从事前端,从一个页面的切图仔到如今还算合格的前端工程师,一路走来,我很清楚要学的很多,其中也学了很多,学的同时也丢了很多(身陷边学边丢,边丢边学的状态).尤其这3年,前端领域 ...
- 8年前端开发的知识点沉淀(不知道会多少字,一直写下去吧,)
先啰嗦几句 2011年开始从事前端,从一个页面的切图仔到如今还算合格的前端工程师,一路走来,我很清楚要学的很多,其中也学了很多,学的同时也丢了很多(身陷边学边丢,边丢边学的状态).尤其这3年,前端领域 ...
- 做前端开发拿 30W 年薪很难吗?
之前在知乎上看到有不少的前端工程师们一直在讨论这么一个问题 好的web前端年薪会有多少? 底下不少前辈高人们放出了自己的工资截图,也有新人出来表示瞻仰.高的4,50w往上走都有(更高的 ...
- web前端开发七武器
http://blog.sina.com.cn/s/blog_40e5679c010119pf.html 武器一:前端开发IDE 最佳前端开发IDE:IntelliJ IDEA 推荐指数: 凭什么 ...
- Web前端开发工程师到底是干什么的?
Web前端开发工程师其实是查BUG,改BUG,写BUG.哈哈,以上内容纯属开玩笑.事实上,Web前端开发工程师简单地说,就是使用HTML.CSS.JavaScript等技术来实现客户端(手机和电脑)上 ...
- 如何学习才能成为优秀的Web前端开发工程师?
随着Web前端技术的广泛应用,Web前端开发工程师也成为了热门的岗位之一,并且吸引了很多人想要转行成为Web前端开发工程师.那么如何学习才能成为Web前端开发工程师呢? 如何学习才能成为优秀的Web前 ...
- 【微信小程序】零基础搭建微信小程序 前端开发
已经用微信开发者工具做完了基础页面,正在思索如何搭建后台._为了理清思路边写博客边思考. 前端开发 小程序的框架 页面设置 在app.json里写这个小程序的所有页面,写在第一个的编译时默认第一个展示 ...
- 移动平台WEB前端开发技巧汇总
原名<移动平台3G手机网站前端开发布局技巧汇总>,由武方博整理的,让我们了解下移动设备上的WEB站点开发的基础知识,多些时间和精力去优化其他细节,我这里对原文的标签格式做了细微的调整,阅读 ...
- 2019年前端开发10大战略性技术蓝图
2010年的你,如果能学会Android开发,现在的你,薪资不会低于年薪50万-- 2015年的你,如果能熟练使用react,现在的你,薪资不会低于月薪30K-- 看到这两个数据,也许有人会反驳:技术 ...
- 总结2019大前端开发十大战略性技术布局
2010年,如果你能学会Android开发到目前,你的薪资不会低于年薪50万 2015年,如果你能熟练使用react到目前,你的薪资不会低于月薪30K-- 看到这两个数据,也许有人会说,首先技术刚出来 ...
最新文章
- 【 FPGA 】超声波测距小实验(一)
- 考前自学系列·计算机组成原理·补码定点加减运算和溢出判断,浮点数的加减运算,原码的乘法
- [UE4]使用蓝图关闭对象的碰撞SetActorEnableCollision
- mysql海量数据存储
- jQuery07源码 (3803 , 4299) attr() prop() val() addClass()等 : 对元素属性的操作
- 用WM_COPYDATA消息来实现两个进程之间传递数据
- MySQL启动关闭服务巨慢,这样解决!
- 安卓案例:View动画 - 弹球碰壁
- gcc和arm-linux-gcc区别
- MVC中使用T4模板
- DataFrame和RDD互操作的两种方式:
- django 自定义标签
- 关于大型网站技术演进的思考(十三)--网站静态化处理—CSI(5)
- 在线摩尔斯密码加密解密工具
- go语言发送手机短信 - 互亿无线
- 【小程序】小游戏开发工具详解(下)
- 芯鼎盛LED恒流驱动芯片TX6128设计的DC-DC开关电源降压恒流DEMO说明
- 开发Web版一对一远程直播教室只需30分钟 - 使用face2face网络教室
- python自动化介绍
- window系统 安装 nvm 详细步骤