• 十二条后端开发经验分享,纯干货


    一. 优雅的进行线程池异常处理

    在Java开发中,线程池的使用必不可少,使用无返回值 execute() 方法时,线程执行发生异常的话,需要记录日志,方便回溯,一般做法是在线程执行方法内 try/catch 处理,如下:

    1. @Test
    2. public void test() throws Exception {
    3. ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 60,
    4. TimeUnit.SECONDS, new ArrayBlockingQueue<>(100000));
    5. Future<Integer> submit = threadPoolExecutor.execute(() -> {
    6. try {
    7. int i = 1 / 0;
    8. return i;
    9. } catch (Exception e) {
    10. log.error(e.getMessage(), e);
    11. return null;
    12. }
    13. });
    14. }
    15. 复制代码

    但是当线程池调用方法很多时,那么每个线程执行方法内都要 try/catch 处理,这就不优雅了,其实ThreadPoolExecutor类还支持传入 ThreadFactory 参数,自定义线程工厂,在创建 thread 时,指定 setUncaughtExceptionHandler 异常处理方法,这样就可以做到全局处理异常了,代码如下:

    1. ThreadFactory threadFactory = r -> {
    2. Thread thread = new Thread(r);
    3. thread.setUncaughtExceptionHandler((t, e) -> {
    4. // 记录线程异常
    5. log.error(e.getMessage(), e);
    6. });
    7. return thread;
    8. };
    9. ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(5, 10, 60,
    10. TimeUnit.SECONDS, new ArrayBlockingQueue<>(100000),
    11. threadFactory);
    12. threadPoolExecutor.execute(() -> {
    13. log.info("---------------------");
    14. int i = 1 / 0;
    15. });
    16. 复制代码

    二. 线程池决绝策略设置错误导致业务接口执行超时

    先介绍下线程池得四种决绝策略

    • AbortPolicy:丢弃任务并抛出RejectedExecutionException异常,这是线程池默认的拒绝策略
    • DiscardPolicy:丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃。 使用此策略,可能会使我们无法发现系统的异常状态。建议是一些无关紧要的业务采用此策略
    • DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务。此拒绝策略,是一种喜新厌旧的拒绝策略。是否要采用此种拒绝策略,还得根据实际业务是否允许丢弃老任务来认真衡量。
    • CallerRunsPolicy:由调用线程处理该任务

    如下是一个线上业务接口使用得线程池配置,决绝策略采用 CallerRunsPolicy

    1. // 某个线上线程池配置如下
    2. ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
    3. 50, // 最小核心线程数
    4. 50, // 最大线程数,当队列满时,能创建的最大线程数
    5. 60L, TimeUnit.SECONDS, // 空闲线程超过核心线程时,回收该线程的最大等待时间
    6. new LinkedBlockingQueue<>(5000), // 阻塞队列大小,当核心线程使用满时,新的线程会放进队列
    7. new CustomizableThreadFactory("task"), // 自定义线程名
    8. new ThreadPoolExecutor.CallerRunsPolicy() // 线程执行的拒绝策略
    9. );
    10. 复制代码

    在某些情况下,子线程任务调用第三方接口超时,导致核心线程数、最大线程数占满、阻塞队列占满的情况下执行拒绝策略时,由于使用 CallerRunsPolicy 策略,导致业务线程执行子任务时继续超时,进而导致接口执行异常,这种情况下,考虑到子线程任务得重要性,不是很重要得话,可以使用 DiscardPolicy 策略,要是很重要,可以发送到消息队列中持久化子线程任务数据待后续处理

    三. 优雅的单例模式懒加载帮助类代码实现

    博主推荐通过静态内部类实现单例模式,并实现懒加载效果,代码如下

    1. // 使用静态内部类完成单例模式封装,避免线程安全问题,避免重复初始化成员属性
    2. @Slf4j
    3. public class FilterIpUtil {
    4.     private FilterIpUtil() {
    5.     }
    6.     private List<String> strings = new ArrayList<>();
    7. // 代码块在FilterIpUtil实例初始化时才会执行
    8.     {
    9. // 在代码块中完成文件的第一次读写操作,后续不再读这个文件
    10.         System.out.println("FilterIpUtil init");
    11.         try (InputStream resourceAsStream = FilterIpUtil.class.getClassLoader().getResourceAsStream("filterIp.txt")) {
    12.             // 将文件内容放到string集合中
    13.             IoUtil.readUtf8Lines(resourceAsStream, strings);
    14.         } catch (IOException e) {
    15.             log.error(e.getMessage(), e);
    16.         }
    17.     }
    18.     public static FilterIpUtil getInstance() {
    19.         return InnerClassInstance.instance;
    20.     }
    21.     // 使用内部类完成单例模式,由jvm保证线程安全
    22.     private static class InnerClassInstance {
    23.         private static final FilterIpUtil instance = new FilterIpUtil();
    24.     }
    25.     // 判断集合中是否包含目标参数
    26.     public boolean isFilter(String arg) {
    27.         return strings.contains(arg);
    28.     }
    29. }
    30. 复制代码

    四. 使用ip2region实现请求地址解析

    在博主之前公司得项目中,ip解析是调用淘宝IP还有聚合IP接口获取结果,通常耗时200毫秒左右,并且接口不稳定时而会挂。都会影响业务接口耗时,后来在 github 上了解到 ip2region 这个项目,使用本地ip库查询,查询速度微秒级别, 精准度能达到90%,但是ip库还是有少部分ip信息不准,建议数据库中把请求ip地址保存下来。简介如下:

    ip2region v2.0 - 是一个离线IP地址定位库和IP定位数据管理框架,10微秒级别的查询效率,提供了众多主流编程语言的 xdb 数据生成和查询客户端实现基于 xdb 文件的查询,下面是一个 Spring 项目中 ip2region 帮助类来实现ip地址解析

    1. /**
    2. * ip2region工具类
    3. */
    4. @Slf4j
    5. @Component
    6. public class Ip2region {
    7. private Searcher searcher = null;
    8. @Value("${ip2region.path:}")
    9. private String ip2regionPath = "";
    10. @PostConstruct
    11. private void init() {
    12. // 1、从 dbPath 加载整个 xdb 到内存。
    13. String dbPath = ip2regionPath;
    14. // 1、从 dbPath 加载整个 xdb 到内存。
    15. byte[] cBuff;
    16. try {
    17. cBuff = Searcher.loadContentFromFile(dbPath);
    18. searcher = Searcher.newWithBuffer(cBuff);
    19. } catch (Exception e) {
    20. log.error("failed to create content cached searcher: {}", e.getMessage(), e);
    21. }
    22. }
    23. public IpInfoBean getIpInfo(String ip) {
    24. if (StringUtils.isBlank(ip)) {
    25. return null;
    26. }
    27. // 3、查询
    28. try {
    29. long sTime = System.nanoTime();
    30. // 国家|区域|省份|城市|ISP
    31. String region = searcher.search(ip);
    32. long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
    33. log.info("{region: {}, ioCount: {}, took: {} μs}", region, searcher.getIOCount(), cost);
    34. if (StringUtils.isNotBlank(region)) {
    35. String[] split = region.split("\|");
    36. IpInfoBean ipInfo = new IpInfoBean();
    37. ipInfo.setIp(ip);
    38. if (!"".equals(split[0])) {
    39. ipInfo.setCountry(split[0]);
    40. }
    41. if (!"".equals(split[2])) {
    42. ipInfo.setProvince(split[2]);
    43. }
    44. if (!"".equals(split[3])) {
    45. ipInfo.setCity(split[3]);
    46. }
    47. if (!"".equals(split[4])) {
    48. ipInfo.setIsp(split[4]);
    49. }
    50. return ipInfo;
    51. }
    52. } catch (Exception e) {
    53. log.error("failed to search({}): {}", ip, e);
    54. return null;
    55. }
    56. // 4、关闭资源 - 该 searcher 对象可以安全用于并发,等整个服务关闭的时候再关闭 searcher
    57. // searcher.close();
    58. // 备注:并发使用,用整个 xdb 数据缓存创建的查询对象可以安全的用于并发,也就是你可以把这个 searcher 对象做成全局对象去跨线程访问。
    59. return null;
    60. }
    61. }
    62. 复制代码

    要注意得就是 ip2region v2.0 版本使用的xdb文件不建议放在项目 resources 下一起打包,存在编码格式问题,建议通过指定路径加载得方式单独放在服务器目录下

    五. 优雅得Springboot + mybatis配置多数据源方式

    Springboot + mybatis 得项目中一般通过 @MapperScan 注解配置 dao 层包目录,来实现 dao 层增强,其实项目中配置一个@MapperScan 是指定一个数据源,配置两个@MapperScan就可以指定两个数据源,通过不同得 dao 层包目录区分,来实现不同数据源得访问隔离。

    比如下面代码中,com.xxx.dao.master 目录下为主数据源 dao 文件,com.xxx.dao.slave 为从数据源 dao 文件,这个方式比网上得基于 aop 加注解得方式更加简洁好用,也没有单个方法中使用不同数据源切换得问题,因此推荐这种写法

    1. /**
    2. * 主数据源
    3. */
    4. @Slf4j
    5. @Configuration
    6. @MapperScan(basePackages = {"com.xxx.dao.master"},
    7. sqlSessionFactoryRef = "MasterSqlSessionFactory")
    8. public class MasterDataSourceConfig {
    9. @Bean(name = "MasterDataSource")
    10. @Qualifier("MasterDataSource")
    11. @ConfigurationProperties(prefix = "spring.datasource.master")
    12. public DataSource clickHouseDataSource() {
    13. return DruidDataSourceBuilder.create().build();
    14. }
    15. @Bean(name = "MasterSqlSessionFactory")
    16. public SqlSessionFactory getSqlSessionFactory(@Qualifier("MasterDataSource") DataSource dataSource) throws Exception {
    17. MybatisSqlSessionFactoryBean sessionFactoryBean = new MybatisSqlSessionFactoryBean();
    18. sessionFactoryBean.setDataSource(dataSource);
    19. sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
    20. .getResources("classpath*:mapper/master/*.xml"));
    21. log.info("------------------------------------------MasterDataSource 配置成功");
    22. return sessionFactoryBean.getObject();
    23. }
    24. }
    25. /**
    26. * 从数据源
    27. */
    28. @Slf4j
    29. @Configuration
    30. @MapperScan(basePackages = {"com.xxx.dao.slave"},
    31. sqlSessionFactoryRef = "SlaveSqlSessionFactory")
    32. public class MasterDataSourceConfig {
    33. @Bean(name = "SlaveDataSource")
    34. @Qualifier("SlaveDataSource")
    35. @ConfigurationProperties(prefix = "spring.datasource.slave")
    36. public DataSource clickHouseDataSource() {
    37. return DruidDataSourceBuilder.create().build();
    38. }
    39. @Bean(name = "SlaveSqlSessionFactory")
    40. public SqlSessionFactory getSqlSessionFactory(@Qualifier("SlaveDataSource") DataSource dataSource) throws Exception {
    41. MybatisSqlSessionFactoryBean sessionFactoryBean = new MybatisSqlSessionFactoryBean();
    42. sessionFactoryBean.setDataSource(dataSource);
    43. sessionFactoryBean.setMapperLocations(new PathMatchingResourcePatternResolver()
    44. .getResources("classpath*:mapper/slave/*.xml"));
    45. log.info("------------------------------------------SlaveDataSource 配置成功");
    46. return sessionFactoryBean.getObject();
    47. }
    48. }
    49. 复制代码

    数据源yml配置

    1. spring:
    2. datasource:
    3. type: com.alibaba.druid.pool.DruidDataSource
    4. driverClassName: com.mysql.cj.jdbc.Driver
    5. # 主库数据源
    6. master:
    7. url: jdbc:mysql://localhost:3306/db1?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
    8. username: root
    9. password:
    10. slave:
    11. url: jdbc:mysql://localhost:3306/db2?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
    12. username: root
    13. password:
    14. 复制代码

    博主刚开始编码一、两年得时候一个项目中遇到了多数据源使用得问题,那时候题主便在网上搜索Spring 多数据源得帖子,大多数都是基于Spring提供得AbstractRoutingDataSource + AOP + 注解 来做动态切换,包括现在流行得 Mybatis plus 官方得多数据源解决方案也是这种做法,这种做法解决了博主当时得多数据源使用问题,后来加了一个需求,在一个定时任务中,查询两个数据源得数据,才发现动态切换在单个方法中不好用了,最后使用得原生jdbc数据源解决。多年后,博主在另一家公司得项目中又遇到了多数据源问题,但是这次博主在网上搜索得是Mybatis 多数据源,才发现了这个优雅得解决方案,进而推荐给大家

    六. Spring Security项目中,使用MDC实现接口请求调用追踪,以及用户ID记录

    MDC介绍

    MDC(Mapped Diagnostic Context,映射调试上下文)是 log4j 、logback及log4j2 提供的一种方便在多线程条件下记录日志的功能。MDC 可以看成是一个与当前线程绑定的哈希表,可以往其中添加键值对。MDC 中包含的内容可以被同一线程中执行的代码所访问。当前线程的子线程会继承其父线程中的 MDC 的内容。当需要记录日志时,只需要从 MDC 中获取所需的信息即可。

    虽然MDC能够方便得实现接口请求调用追踪功能,但是它在子线程中会丢失父线程中添加得键值对信息,解决方法是通过父线程中调用线程池前调用 MDC.getCopyOfContextMap() ,然后在子线程中第一个调用 MDC.setConextMap() 获取键值对信息,完整实现代码如下:

    1. /**
    2. * 自定义Spring线程池,解决子线程丢失reqest_id问题
    3. */
    4. public class ThreadPoolExecutorMdcWrapper extends ThreadPoolTaskExecutor {
    5. @Override
    6. public void execute(Runnable task) {
    7. super.execute(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    8. }
    9. @Override
    10. public <T> Future<T> submit(Callable<T> task) {
    11. return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    12. }
    13. @Override
    14. public Future<?> submit(Runnable task) {
    15. return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    16. }
    17. }
    18. /**
    19. * MDC帮助类,添加reqest_id
    20. */
    21. public class ThreadMdcUtil {
    22. public static final String REQUEST_ID = "request_id";
    23. /**
    24. * 设置请求唯一ID
    25. */
    26. public static void setTraceIdIfAbsent() {
    27. if (MDC.get(REQUEST_ID) == null) {
    28. MDC.put(REQUEST_ID, IdUtil.getUid());
    29. }
    30. }
    31. /**
    32. * 存在userId则添加到REQUEST_ID中
    33. * @param userId
    34. */
    35. public static void setUserId(String userId) {
    36. String s = MDC.get(REQUEST_ID);
    37. if (s != null) {
    38. MDC.put(REQUEST_ID, s + "_" + userId);
    39. }
    40. }
    41. public static void removeTraceId() {
    42. MDC.remove(REQUEST_ID);
    43. }
    44. public static <T> Callable<T> wrap(final Callable<T> callable, final Map<String, String> context) {
    45. return () -> {
    46. if (context == null) {
    47. MDC.clear();
    48. } else {
    49. MDC.setContextMap(context);
    50. }
    51. setTraceIdIfAbsent();
    52. try {
    53. return callable.call();
    54. } finally {
    55. MDC.clear();
    56. }
    57. };
    58. }
    59. public static Runnable wrap(final Runnable runnable, final Map<String, String> context) {
    60. return () -> {
    61. if (context == null) {
    62. MDC.clear();
    63. } else {
    64. MDC.setContextMap(context);
    65. }
    66. // 设置traceId
    67. setTraceIdIfAbsent();
    68. try {
    69. runnable.run();
    70. } finally {
    71. MDC.clear();
    72. }
    73. };
    74. }
    75. }
    76. 复制代码

    Spring Security 中添加 token 过滤器

    1. /**
    2. * token过滤器 验证token有效性
    3. *
    4. * @author ruoyi
    5. */
    6. @Slf4j
    7. @Component
    8. public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    9. @Autowired
    10. private TokenService tokenService;
    11. @Override
    12. protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
    13. throws ServletException, IOException {
    14. try {
    15. // 入口传入请求ID
    16. ThreadMdcUtil.setTraceIdIfAbsent();
    17. LoginUserDetail loginUser = tokenService.getLoginUser(request);
    18. if (Objects.nonNull(loginUser) && Objects.isNull(SecurityContextHolder.getContext().getAuthentication())) {
    19. // 记录userId
    20. ThreadMdcUtil.setUserId(String.valueOf(loginUser.getMember().getId()));
    21. tokenService.verifyToken(loginUser);
    22. UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
    23. authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
    24. SecurityContextHolder.getContext().setAuthentication(authenticationToken);
    25. }
    26. chain.doFilter(request, response);
    27. } finally {
    28. // 出口移除请求ID
    29. ThreadMdcUtil.removeTraceId();
    30. }
    31. }
    32. }
    33. 复制代码

    最后在 logback.xml 中添加 %X{request_id}

    1. "pattern"
    2. value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{request_id}] [%thread] [%-5level] %logger{36}:%L %M - %msg%n"/>
    3. 复制代码

    日志打印效果如下:

    1. 2022-11-27 21:29:48.008 [86c76336100c414dbe9217aeb099ccd5_12] [http-nio-82-exec-2] [INFO ] c.w.m.a.s.impl.IHomeServiceImpl:56 getHomeIndexDataCompletableFuture - getHomeIndexDataCompletableFuture:com.wayn.common.util.R@701f7b8e[code=200,msg=操作成功,map={bannerList=[{"createTime":"2020-06-26 19:56:03","delFlag":false,"id":14,"imgUrl":"https://m.360buyimg.com/mobilecms/s700x280_jfs/t1/117335/39/13837/263099/5f291a83E8ba761d0/5c0460445cb28248.jpg!cr_1125x449_0_166!q70.jpg.dpg","jumpUrl":"http://82.157.141.70/mall/#/detail/1155015","sort":0,"status":0,"title":"hh2","updateTime":"2022-06-19 09:16:46"},{"createTime":"2020-06-26 19:56:03","delFlag":false,"id":15,"imgUrl":"https://m.360buyimg.com/mobilecms/s700x280_jfs/t1/202096/26/11652/265782/616acb67E4fcdc9ac/8d7cdfbb6c934e67.jpg!cr_1125x449_0_166!q70.jpg.dpg","jumpUrl":"#/detail/1155015","sort":0,"status":0,"title":"hh","updateTime":"2022-06-19 09:04:58"}], newGoodsList=[{"actualSales":0,"brandId":0,"brief":"酥脆奶香,甜酸回味","categoryId":1008015,"counterPrice":56.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1116011","id":1116011,"isHot":true,"isNew":true,"isOnSale":true,"keywords":"","name":"蔓越莓曲奇 200克","picUrl":"http://yanxuan.nosdn.127.net/767b370d07f3973500db54900bcbd2a7.png","retailPrice":36.00,"shareUrl":"","sort":5,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":0,"brandId":0,"brief":"粉彩色泽,记录生活","categoryId":1012003,"counterPrice":49.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1127047","id":1127047,"isHot":false,"isNew":true,"isOnSale":true,"keywords":"","name":"趣味粉彩系列笔记本","picUrl":"http://yanxuan.nosdn.127.net/6c03ca93d8fe404faa266ea86f3f1e43.png","retailPrice":29.00,"shareUrl":"","sort":2,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":0,"brandId":0,"brief":"慢回弹海绵,时尚设计。","categoryId":1008002,"counterPrice":66.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1134030","id":1134030,"isHot":false,"isNew":true,"isOnSale":true,"keywords":"","name":"简约知性记忆棉坐垫","picUrl":"http://yanxuan.nosdn.127.net/aa49dfe878becf768eddc4c1636643a6.png","retailPrice":46.00,"shareUrl":"","sort":12,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":0,"brandId":0,"brief":"慢回弹海绵的呵护,萌趣添彩。","categoryId":1008002,"counterPrice":69.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1134032","id":1134032,"isHot":false,"isNew":true,"isOnSale":true,"keywords":"","name":"趣味粉彩系列记忆棉坐垫","picUrl":"http://yanxuan.nosdn.127.net/8b30eeb17c831eba08b97bdcb4c46a8e.png","retailPrice":49.00,"shareUrl":"","sort":13,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":0,"brandId":0,"brief":"100%桑蚕丝,丝滑润肤","categoryId":1008009,"counterPrice":2619.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1135002","id":1135002,"isHot":false,"isNew":true,"isOnSale":true,"keywords":"","name":"宫廷奢华真丝四件套","picUrl":"http://yanxuan.nosdn.127.net/45548f26cfd0c7c41e0afc3709d48286.png","retailPrice":2599.00,"shareUrl":"","sort":1,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":0,"brandId":0,"brief":"自由海军领探索未来梦","categoryId":1020003,"counterPrice":89.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1135072","id":1135072,"isHot":false,"isNew":true,"isOnSale":true,"keywords":"","name":"经典海魂纹水手裙(婴童)","picUrl":"http://yanxuan.nosdn.127.net/43e57d4208cdc78ac9c088f9b3e798f5.png","retailPrice":69.00,"shareUrl":"","sort":3,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":0,"brandId":0,"brief":"经典海魂纹自由海军领","categoryId":1020003,"counterPrice":89.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1135073","id":1135073,"isHot":false,"isNew":true,"isOnSale":true,"keywords":"","name":"海魂纹哈衣水手服(婴童)","picUrl":"http://yanxuan.nosdn.127.net/53052b04ae001d289c040e09ea92231c.png","retailPrice":69.00,"shareUrl":"","sort":4,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":5,"brandId":0,"brief":"差旅好伴侣","categoryId":1032000,"counterPrice":119.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1152031","id":1152031,"isHot":true,"isNew":true,"isOnSale":true,"keywords":"","name":"魔兽世界-伊利丹颈枕眼罩套装","picUrl":"http://yanxuan.nosdn.127.net/fd6e78a397bd9e9804116a36f0270b0a.png","retailPrice":99.00,"shareUrl":"","sort":4,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":5,"brandId":0,"brief":"桌面整理神器","categoryId":1032000,"counterPrice":519.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1152095","id":1152095,"isHot":false,"isNew":true,"isOnSale":true,"keywords":"","name":"魔兽世界 联盟·暴风城 堡垒收纳盒","picUrl":"http://yanxuan.nosdn.127.net/c86b49f635fa141decebabbd0966a6ef.png","retailPrice":499.00,"shareUrl":"","sort":6,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":0,"brandId":0,"brief":"3重透气,清爽柔滑","categoryId":1008009,"counterPrice":479.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1152161","id":1152161,"isHot":false,"isNew":true,"isOnSale":true,"keywords":"","name":"竹语丝麻印花四件套","picUrl":"http://yanxuan.nosdn.127.net/977401e75113f7c8334c4fb5b4bf6215.png","retailPrice":459.00,"shareUrl":"","sort":6,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10}], categoryList=[{"createTime":"2020-12-08 23:09:12","delFlag":false,"iconUrl":"http://cdn.wayn.xin/9fc3c52571aa38a1466f114c2dc892fc.png","id":2,"jumpType":0,"name":"大牌手机","picUrl":"http://cdn.wayn.xin/2545dd00ca4575759024af2811949a9d.jpg","sort":1,"status":0,"updateTime":"2020-12-12 19:26:48","valueId":5,"valueUrl":"http://baidu.com"},{"createTime":"2020-12-06 13:27:54","delFlag":false,"iconUrl":"http://82.157.141.70/upload/2022/02/21/a23fa32c8f4004a8c9fbbb1784462163.jpg","id":1,"jumpType":0,"name":"滋补保健2","picUrl":"http://cdn.wayn.xin/d4de7172eb7ae4178ae4dafd6a973d8f.jpg","sort":2,"status":0,"updateTime":"2022-06-19 09:17:20","valueId":2},{"createTime":"2020-12-08 23:26:15","delFlag":false,"iconUrl":"http://cdn.wayn.xin/6bc0f8a131d2d16b8fc2004d4aa4860c.png","id":3,"jumpType":1,"name":"不锈钢锅","picUrl":"http://cdn.wayn.xin/314d87257f7a2ff03d5f4c5183797912.jpg","sort":2,"status":0,"updateTime":"2020-12-12 19:27:24","valueId":1036000},{"createTime":"2020-12-12 19:28:08","delFlag":false,"iconUrl":"http://cdn.wayn.xin/5a90531d901529967885279d7dc826e1.png","id":14,"jumpType":0,"name":"进口牛奶","picUrl":"http://cdn.wayn.xin/0b1f6ab63d5e222c52c83a2d0581e44c.jpg","sort":3,"status":0,"valueId":5},{"createTime":"2020-12-12 19:28:33","delFlag":false,"iconUrl":"http://cdn.wayn.xin/33530951827ca7e59940d51cda537d84.png","id":15,"jumpType":0,"name":"量贩囤货","picUrl":"http://cdn.wayn.xin/bb6daee3b3e51c3008db97585249f513.jpg","sort":4,"status":0,"valueId":2},{"createTime":"2020-12-12 19:28:50","delFlag":false,"iconUrl":"http://cdn.wayn.xin/7d337f25111b263b29d5d12589015c46.png","id":16,"jumpType":0,"name":"清洁用品","picUrl":"http://cdn.wayn.xin/be8995bda39d03b17349b8ec0dcab3d5.jpg","sort":5,"status":0,"valueId":2},{"createTime":"2020-12-12 19:29:10","delFlag":false,"iconUrl":"http://cdn.wayn.xin/2e632ec0173bb477dcdb601495e0412a.png","id":17,"jumpType":0,"name":"洗护用品","picUrl":"http://cdn.wayn.xin/53fb88c9d1245caa882aa3fc29187d0b.jpg","sort":6,"status":0,"valueId":4},{"createTime":"2020-12-12 19:29:28","delFlag":false,"iconUrl":"http://cdn.wayn.xin/942323c0e74677cf2aa15f09a1e63bca.png","id":18,"jumpType":0,"name":"日用百货","picUrl":"http://cdn.wayn.xin/8587f91db2edcb43e57da9835cc7ec76.jpg","sort":7,"status":0,"valueId":2},{"createTime":"2020-12-12 19:29:46","delFlag":false,"iconUrl":"http://cdn.wayn.xin/18d9d860ba9b8b28522e050f11a8a8e0.png","id":19,"jumpType":0,"name":"明星乳胶","picUrl":"http://cdn.wayn.xin/65273c7fb2273e90958e92626248a90a.jpg","sort":8,"status":0,"valueId":6},{"createTime":"2020-12-12 19:30:15","delFlag":false,"iconUrl":"http://cdn.wayn.xin/7c790577afda91eebc3c95586e190957.png","id":20,"jumpType":0,"name":"口碑好物","picUrl":"http://cdn.wayn.xin/210011b35be4ceee39e6a466b40b8e22.jpg","sort":9,"status":0,"updateTime":"2021-04-01 20:13:08","valueId":5}], expire_time=1669549170235, hotGoodsList=[{"actualSales":1,"brandId":1001045,"brief":"一级桑蚕丝,吸湿透气柔软","categoryId":1036000,"counterPrice":719.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1006013","id":1006013,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"双宫茧桑蚕丝被 空调被","picUrl":"http://yanxuan.nosdn.127.net/583812520c68ca7995b6fac4c67ae2c7.png","retailPrice":699.00,"shareUrl":"","sort":7,"unit":"件","updateTime":"2021-08-08 11:19:36","virtualSales":10},{"actualSales":1,"brandId":1001045,"brief":"双层子母被,四季皆可使用","categoryId":1008008,"counterPrice":14199.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1006014","id":1006014,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"双宫茧桑蚕丝被 子母被","picUrl":"http://yanxuan.nosdn.127.net/2b537159f0f789034bf8c4b339c43750.png","retailPrice":1399.00,"shareUrl":"","sort":15,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":6,"brandId":1001000,"brief":"加大加厚,双色精彩","categoryId":1036000,"counterPrice":219.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1011004","id":1011004,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"色织精梳AB纱格纹空调被","picUrl":"http://yanxuan.nosdn.127.net/0984c9388a2c3fd2335779da904be393.png","retailPrice":199.00,"shareUrl":"","sort":2,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":6,"brandId":0,"brief":"共享亲密2人时光","categoryId":1008008,"counterPrice":219.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1019002","id":1019002,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"升级款护颈双人记忆枕","picUrl":"http://yanxuan.nosdn.127.net/0118039f7cda342651595d994ed09567.png","retailPrice":199.00,"shareUrl":"","sort":10,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":6,"brandId":0,"brief":"健康保护枕","categoryId":1008008,"counterPrice":119.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1019006","id":1019006,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"植物填充护颈夜交藤枕","picUrl":"http://yanxuan.nosdn.127.net/60c3707837c97a21715ecc3986a744ce.png","retailPrice":99.00,"shareUrl":"","sort":7,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":6,"brandId":0,"brief":"厚实舒适","categoryId":1008001,"counterPrice":59.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1021000","id":1021000,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"被","name":"埃及进口长绒棉毛巾","picUrl":"http://yanxuan.nosdn.127.net/7191f2599c7fe44ed4cff7a76e853154.png","retailPrice":39.00,"shareUrl":"","sort":7,"unit":"条","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":6,"brandId":1001020,"brief":"浪漫毛线绣球,简约而不简单","categoryId":1008009,"counterPrice":319.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1022000","id":1022000,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"意式毛线绣球四件套","picUrl":"http://yanxuan.nosdn.127.net/5350e35e6f22165f38928f3c2c52ac57.png","retailPrice":299.00,"shareUrl":"","sort":18,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":32,"brandId":1001000,"brief":"柔软纱布,婴童可用","categoryId":1036000,"counterPrice":269.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1027004","id":1027004,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"色织六层纱布夏凉被","picUrl":"http://yanxuan.nosdn.127.net/6252f53aaf36c072b6678f3d8c635132.png","retailPrice":249.00,"shareUrl":"","sort":3,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":32,"brandId":0,"brief":"原生苦荞,健康护颈","categoryId":1008008,"counterPrice":119.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1036002","id":1036002,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"高山苦荞麦枕","picUrl":"http://yanxuan.nosdn.127.net/ffd7efe9d5225dff9f36d5110b027caa.png","retailPrice":99.00,"shareUrl":"","sort":5,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10},{"actualSales":32,"brandId":0,"brief":"5cm记忆绵的亲密包裹","categoryId":1008008,"counterPrice":619.00,"createTime":"2018-02-01 00:00:00","delFlag":false,"goodsSn":"1037011","id":1037011,"isHot":true,"isNew":false,"isOnSale":true,"keywords":"","name":"安睡慢回弹记忆绵床垫","picUrl":"http://yanxuan.nosdn.127.net/a03ea6f4509439acdafcb7ceba1debe0.png","retailPrice":599.00,"shareUrl":"","sort":22,"unit":"件","updateTime":"2018-02-01 00:00:00","virtualSales":10}]}]
    2. 2022-11-27 21:29:48.336 [9d919fb6d33c4652ba28ff87ae210809_12] [http-nio-82-exec-3] [DEBUG] c.w.c.c.m.s.G.selectGoodsListPage_mpCount:137 debug - ==> Preparing: SELECT COUNT(*) AS total FROM shop_goods WHERE del_flag = 0 AND is_on_sale = 1
    3. 2022-11-27 21:29:48.387 [9d919fb6d33c4652ba28ff87ae210809_12] [http-nio-82-exec-3] [DEBUG] c.w.c.c.m.s.G.selectGoodsListPage_mpCount:137 debug - ==> Parameters:
    4. 2022-11-27 21:29:48.426 [9d919fb6d33c4652ba28ff87ae210809_12] [http-nio-82-exec-3] [DEBUG] c.w.c.c.m.s.G.selectGoodsListPage_mpCount:137 debug - <== Total: 1
    5. 2022-11-27 21:29:48.430 [9d919fb6d33c4652ba28ff87ae210809_12] [http-nio-82-exec-3] [DEBUG] c.w.c.c.m.s.G.selectGoodsListPage:137 debug - ==> Preparing: select id, goods_sn, name, pic_url, counter_price, retail_price, actual_sales, virtual_sales from shop_goods WHERE del_flag = 0 and is_on_sale = 1 order by create_time desc LIMIT ?
    6. 2022-11-27 21:29:48.452 [9d919fb6d33c4652ba28ff87ae210809_12] [http-nio-82-exec-3] [DEBUG] c.w.c.c.m.s.G.selectGoodsListPage:137 debug - ==> Parameters: 6(Long)
    7. 2022-11-27 21:29:48.476 [9d919fb6d33c4652ba28ff87ae210809_12] [http-nio-82-exec-3] [DEBUG] c.w.c.c.m.s.G.selectGoodsListPage:137 debug - <== Total: 6
    8. 复制代码

    最后分析上诉日志:通过86c76336100c414dbe9217aeb099ccd5实现接口调用追踪,通过12用户ID,实现用户调用追踪

    七. alibaba excel导出时自定义格式转换优雅实现

    官网介绍:EasyExcel 是一个基于Java的简单、省内存的读写Excel的开源项目。在尽可能节约内存的情况下支持读写百M的Excel。

    EasyExcelalibaba 出的一个基于 java poi 得excel通用处理类库,他的优势在于内存消耗。对比 easypoi 方案,EasyExcel 在内存消耗、知名度(大厂光环)上更出众些。

    博主在使用过程中发现导出excel,官网对自定义格式字段提供了 converter 接口,但只简单提供了CustomStringStringConverter 类代码,达不到博主想要得优雅要求,如下:

    1. public class CustomStringStringConverter implements Converter<String> {
    2. @Override
    3. public Class<?> supportJavaTypeKey() {
    4. return String.class;
    5. }
    6. @Override
    7. public CellDataTypeEnum supportExcelTypeKey() {
    8. return CellDataTypeEnum.STRING;
    9. }
    10. /**
    11. * 这里读的时候会调用
    12. *
    13. * @param context
    14. * @return
    15. */
    16. @Override
    17. public String convertToJavaData(ReadConverterContext<?> context) {
    18. return "自定义:" + context.getReadCellData().getStringValue();
    19. }
    20. /**
    21. * 这里是写的时候会调用 不用管
    22. *
    23. * @return
    24. */
    25. @Override
    26. public WriteCellData<?> convertToExcelData(WriteConverterContext<String> context) {
    27. return new WriteCellData<>(context.getValue());
    28. }
    29. }
    30. 复制代码

    在以上代码中,打个比方想要实现性别字段得自定义格式转换,就需要在convertToExcelData方法中,添加如下代码

    1. @Override
    2. public WriteCellData<?> convertToExcelData(WriteConverterContext<String> context) {
    3. String value = context.getValue();
    4. if ("man".equals(value)) {
    5. return new WriteCellData<>("男");
    6. } else {
    7. return new WriteCellData<>("女");
    8. }
    9. }
    10. 复制代码

    可以看到,非常得不优雅,对于这种类型字段,博主习惯使用枚举类来定义字段所有类型,然后将枚举类转换为 map(value,desc) 结构,就可以优雅得实现这个自定义格式得需求

    1. /**
    2. * 一、先定义int字段抽象转换类,实现通用转换逻辑
    3. */
    4. public abstract class AbstractIntConverter implements Converter<Integer> {
    5. abstract List<ConverterDTO> getArr();
    6. public WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
    7. List<ConverterDTO> values = getArr();
    8. Map<Integer, String> map = values.stream().collect(toMap(ConverterDTO::getType, ConverterDTO::getDesc));
    9. String result = map.getOrDefault(value, "");
    10. return new WriteCellData<>(result);
    11. }
    12. static class ConverterDTO {
    13. private Integer type;
    14. private String desc;
    15. public Integer getType() {
    16. return type;
    17. }
    18. public void setType(Integer type) {
    19. this.type = type;
    20. }
    21. public String getDesc() {
    22. return desc;
    23. }
    24. public void setDesc(String desc) {
    25. this.desc = desc;
    26. }
    27. public ConverterDTO(Integer type, String desc) {
    28. this.type = type;
    29. this.desc = desc;
    30. }
    31. }
    32. }
    33. /**
    34. * 二、定义通用状态字段转换类
    35. */
    36. public class StatusConverter extends AbstractIntConverter {
    37. @Override
    38. List<ConverterDTO> getArr() {
    39. StatusEnum[] values = StatusEnum.values();
    40. return Arrays.stream(values).map(sexEnum -> new ConverterDTO(sexEnum.getType(), sexEnum.getDesc())).toList();
    41. }
    42. /**
    43. * 状态枚举
    44. */
    45. enum StatusEnum {
    46. MAN(0, "启用"),
    47. WOMAN(1, "禁用");
    48. private Integer type;
    49. private String desc;
    50. StatusEnum(Integer type, String desc) {
    51. this.type = type;
    52. this.desc = desc;
    53. }
    54. public Integer getType() {
    55. return type;
    56. }
    57. public String getDesc() {
    58. return desc;
    59. }
    60. }
    61. }
    62. 复制代码

    最后再导出 ExcelProperty 中甜腻加 StatusConverter ,就优雅得实现了自定义格式得需求

    1. public class User extends BaseEntity {
    2. ...
    3. /**
    4. * 用户状态 0 启用 1 禁用
    5. */
    6. @ExcelProperty(value = "用户状态", converter = StatusConverter.class)
    7. private Integer userStatus;
    8. ...
    9. }
    10. 复制代码

    八. Springboot 默认redis客户端lettuce经常连接超时解决方案

    不知道大家有没有遇到这种情况,线上项目使用 lettuce 客户端,当操作 redis 得接口一段时间没有调用后(比如30分钟),再次调用 redis 操作后,就会遇到连接超时得问题,导致接口异常。博主直接给出分析过程:

    1. 通过wireshark抓包工具,发现项目中 redis 连接创建后,一段时间未传输数据后,客户端发送 psh 包,未收到服务端 ack 包,触发tcp得超时重传机制,在重传次数重试完后,最终客户端主动关闭了连接。

    到这里我们就知道这个问题,主要原因在于服务端没有回复客户端(比如tcp参数设置、防火墙主动关闭等,都是针对一段时间内没有数据传输得tcp连接会做关闭处理),造成了客户端得连接超时

    面对这个问题有三种解决方案:

    • redis操作异常后进行重试,这篇文章有介绍 生产环境Redis连接,长时间无响应被服务器断开问题
    • 启用一个心跳定时任务,定时访问 redis,保持 redis 连接不被关闭,简而言之,就是写一个定时任务,定时调用 redisget 命令,进而保活 redis 连接
    • 基于Springboot 提供得 LettuceClientConfigurationBuilderCustomizer 自定义客户端配置,博主这里主要针对第三种自定义客户端配置来讲解一种优雅得方式

    Springboot 项目中关于 lettuce 客户端得自动配置是没有启用保活配置得,要启用得话代码如下:

    1. /**
    2. * 自定义lettuce客户端配置
    3. *
    4. * @return LettuceClientConfigurationBuilderCustomizer
    5. */
    6. @Bean
    7. public LettuceClientConfigurationBuilderCustomizer lettuceClientConfigurationBuilderCustomizer() {
    8. return clientConfigurationBuilder -> {
    9. LettuceClientConfiguration clientConfiguration = clientConfigurationBuilder.build();
    10. ClientOptions clientOptions = clientConfiguration.getClientOptions().orElseGet(ClientOptions::create);
    11. ClientOptions build = clientOptions.mutate().build();
    12. SocketOptions.KeepAliveOptions.Builder builder = build.getSocketOptions().getKeepAlive().mutate();
    13. // 保活配置
    14. builder.enable(true);
    15. builder.idle(Duration.ofSeconds(30));
    16. SocketOptions.Builder socketOptionsBuilder = clientOptions.getSocketOptions().mutate();
    17. SocketOptions.KeepAliveOptions keepAliveOptions = builder.build();
    18. socketOptionsBuilder.keepAlive(keepAliveOptions);
    19. SocketOptions socketOptions = socketOptionsBuilder.build();
    20. ClientOptions clientOptions1 = ClientOptions.builder().socketOptions(socketOptions).build();
    21. clientConfigurationBuilder.clientOptions(clientOptions1);
    22. };
    23. }
    24. 复制代码

    添加 lettuce 客户端的自定义配置,在 KeepAliveOptions 中启用 enable ,这样 lettuce 客户端就会在tcp协议规范上启用 keep alive 机制自动发送心跳包

    九. redis客户端lettuce启用epoll

    直接给 官网连接,配置很简单,添加一个 netty-all 得依赖,lettuce 会自动检测项目系统是否支持 epolllinux 系统支持),并且是否有netty-transport-native-epoll依赖( netty-all 包含 netty-transport-native-epoll ),都满足得话就会自动启用 epoll 事件循环,进一步提升系统性能

    1. <dependency>
    2. <groupId>io.netty</groupId>
    3. <artifactId>netty-all</artifactId>
    4. </dependency>
    5. 复制代码

    十. Springboot web项目优雅停机

    web项目配置了优雅停机后,在重启jar包,或者容器时可以防止正在活动得线程被突然停止( kill -9 无解,请不要使用这个参数杀线上进程,docker compose 项目尽量不要用 docker-compose down 命令关闭项目,使用 docker-compose rm -svf 可以触发优雅停机),造成用户请求失败,在此期间允许完成现有请求但不允许新请求,配置如下:

    1. server: shutdown: "graceful"
    2. 复制代码

    十一. nginx配置通用请求后缀

    先说下这个配置产生得前提,博主公司pc客户项目是基于 electron 打包得网页项目,每次项目大版本更新时,为了做好兼容性,防止客户端网页缓存等,会使用一个新网页地址,打个比方:

    老网页地址,v1.1.0 版本网页访问地址:api.dev.com/pageV110

    新网页地址,v1.2.0 版本网页访问地址:api.dev.com/pageV120

    那么项目得nginx配置则则需要新加一个 v1.2.0 得配置如下:

    1. server {
    2. listen 80;
    3. server_name api.dev.com;
    4. client_max_body_size 10m;
    5. # 老网页v1.1.0配置
    6. location ~ ^/pageV110 {
    7. alias /home/wwwroot/api.dev.com/pageV110;
    8. index index.html index.htm;
    9. }
    10. # 新网页v1.2.0配置
    11. location ~ ^/pageV120 {
    12. alias /home/wwwroot/api.dev.com/pageV120;
    13. index index.html index.htm;
    14. }
    15. }
    16. 复制代码

    那么博主在每次项目发布得时候就需要配合前端发版,配置一个新网页,故产生了这个通用配置得需求,如下:

    1. server {
    2. listen 80;
    3. server_name api.dev.com;
    4. client_max_body_size 10m;
    5. # 配置正则localtion
    6. location ~ ^/pageV(.*) {
    7. set $s $1; # 定义后缀变量
    8. alias /home/wwwroot/api.dev.com/pageV$s;
    9. index index.html index.htm;
    10. }
    11. }
    12. 复制代码

    nginx 配置文件语法中,location 语句可以使用正则表达式,定义 set $s $1 变量,实现了通用配置

    十二. 关于开发人员的自我提升和突破

    博主这里主要总结了四点:

    1. 多和他人沟通,沟通能把复杂问题简单化,有时候开发阶段一个需求多问几句,可以减少因为个人理解差异导致的需求不一致问题,进而减少开发时间
    2. 建立长短期目标,观看技术视频、书籍给自己充电,比如7天利用业余时间看完一本电子书,三十天从零开始一个新项目等
    3. 善于总结,对于项目中的疑难bug,踩坑点要有记录,防止下次遇到再掉坑里
    4. 敢于尝试、担责,对项目、代码里明确不合理的地方要敢于跟他人沟通,修改问题代码,达到优化目的。对于自己造成的问题要承担,不要推卸责任。对于线上问题要重视,优先解决线上问题
  • 相关阅读:
    多源最短路径算法:Floyd-Warshall算法分析
    IDEA 关闭SpringBoot启动Logo/图标
    Centos7 自部署中间件开机启动,以及java应用开机启动方法
    如何用Android Studio实现登录跳转
    Flutter 作为谷歌的开源框架到底有何可取之处?我们又该如何学习?
    Java --- Spring6前的程序问题
    图论与宽搜——图中点的层次
    Python -- I/O编程
    Windows内核函数 - ANSI_STRING字符串与UNICODE_STRING字符串
    Qt-键盘消息的传递-键盘消息的获取-C++
  • 原文地址:https://blog.csdn.net/Trouvailless/article/details/128146456