轻量式RPC调用日志链路设计方案

导语:

调用链跟踪系统,又称为tracing,是微服务设计架构中,从系统层面对整体的monitoring和profiling的一种技术手

背景说明

由于我们的项目是微服务方向,中后台服务调用链路过深,追踪路径过长,其中某个服务报错或者异常很难追踪到对应链路和报错,且各个服务/模块之间的调用关系复杂,部分服务与服务之间还存在一些proxy服务(实现服务的多活部署)。这些现象就导致在开发调试、问题跟踪上都会逐步出现问题。因此,对MDC进行了调研。

MDC(Mapped Diagnostic Context,映射调试上下文)是 log4j 、logback及log4j2 提供的一种方便在多线程条件下记录日志的功能。MDC 可以看成是一个与当前线程绑定的哈希表,可以往其中添加键值对。MDC 中包含的内容可以被同一线程中执行的代码所访问。当前线程的子线程会继承其父线程中的 MDC 的内容。当需要记录日志时,只需要从 MDC 中获取所需的信息即可。MDC 的内容则由程序在适当的时候保存进去。对于一个 Web 应用来说,通常是在请求被处理的最开始保存这些数据。

跟踪链


图示中,A~E五个节点表示五个服务。用户发起一次请求RequestX到A,同时由于该请求依赖服务B与C,因此A分别发送RPC请求到B和C,B处理完请求后会直接返回到A,但是服务C还依赖服务D和E,因此还要发起两个RPC请求分别到D和E,D和E处理完毕后回到C,C才继续回复到A,最终A会回复用户ReplyX。对于这样一个请求,简单实用的分布式跟踪的实现,就是为服务器上每一次发送和接收动作来收集跟踪标识符和时间戳。
Trace和Span是两个很重要的名词。我们使用Trace表示对一次请求完整调用链的跟踪,而将两个服务例如上面的服务A和服务B的请求/响应过程叫做一次Span,trace是通过span来体现的, 通过一句话总结,我们可以将一次trace,看成是span的有向图,而这个有向图的边即为span。为了可以更好的理解这两个名词,我们可以再来看一下面的调用图。

[Span A]  ←←←(the root span)
|
+------+------+
|             |
[Span B]      [Span C] ←←←(Span C is a `ChildOf` Span A)
|             |
[Span D]      +---+-------+
|           |
[Span E]    [Span F] >>> [Span G] >>> [Span H]
↑
↑
↑
(Span G `FollowsFrom` Span F)上图中,包含了8个span信息

tracing过程的spans图

––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|–––––––|> time
 [Span A···················································]
   [Span B··············································]
      [Span D··········································]
    [Span C········································]
         [Span E·······]        [Span F··] [Span G··] [Span H··]

Spans的时间轴关系图
而分布式跟踪系统要做的,就是记录每次发送和接受动作的标识符和时间戳,将一次请求涉及到的所有服务串联起来,只有这样才能搞清楚一次请求的完整调用链。

设计思路

如何获取 TraceId?

虽然TraceId 贯穿于整个 IT 系统,只不过大部分时候,它只是默默配合上下文承担着链路传播的职责,没有显式的暴露出来。常见的 TraceId 获取方式有以下几种:

  • 前端请求 Header 或响应体 Response:大部分用户请求都是在端上设备发起的,因此 TraceId 生成的最佳地点也是在端上设备,通过请求 Header 透传给后端服务。因此,我们在通过浏览器开发者模式调试时,就可以获取当前测试请求 Header 中的 TraceId 进行筛选。如果端上设备没有接入分布式链路追踪埋点,也可以将后端服务生成的 TraceId 添加到 Response 响应体中返回给前端。这种方式非常适合前后端联调场景,可以快速找到每一次点击对应的 TraceId,进而分析行为背后的链路轨迹与状态。
  • 网关日志:网关是所有用户请求发往后端服务的代理中转站,可以视为后端服务的入口。在网关的 access.log 访问日志中添加 TraceId,可以帮助我们快速分析每一次异常访问的轨迹与原因。比如一个超时或错误请求,到底是网关自身的原因,还是后端某个服务的原因,可以通过调用链中每个 Span 的状态得到确定性的结论。
  • 应用日志:应用日志可以说是我们最熟悉的一种日志,我们会将各种业务或系统的行为、中间状态和结果,在开发编码的过程中顺手记录到应用日志中,使用起来非常方便。同时,它也是可读性最强的一类日志,即使是非开发运维人员也能大致理解应用日志所表达的含义。因此,我们可以将 TraceId 也记录到应用日志中进行关联,一旦出现某种业务异常,我们可以先通过当前应用的日志定位到报错信息,再通过关联的 TraceId 去追溯该应用上下游依赖的其他信息,最终定位到导致问题出现的根因节点。
  • 组件日志:在分布式系统中,大部分应用都会依赖一些外部组件,比如数据库、消息、配置中心等等。这些外部组件也会经常发生这样或那样的异常,最终影响应用服务的整体可用性。但是,外部组件通常是共用的,有专门的团队进行维护,不受应用 Owner 的控制。因此,一旦出现问题,也很难形成有效的排查回路。此时,我们可以将 TraceId 透传给外部组件,并要求他们在自己的组件日志中进行关联,同时开放组件日志查询权限。举个例子,我们可以通过 SQL Hint 传播链 TraceId,并将其记录到数据库服务端的 Binlog 中,一旦出现慢 SQL 就可以追溯数据库服务端的具体表现,比如一次请求记录数过多,查询语句没有建索引等等。
基于日志模板实现日志与 TraceId 自动关联示例

基于 SDK 手动埋点需要一行行的修改代码,无疑是非常繁琐的,如果需要在日志中批量添加 TraceId,可以采用日志模板注入的方式。
目前大部分的日志框架都支持 Slf4j 日志门面,它提供了一种 MDC(Mapped Dignostic Contexts)机制,可以在多线程场景下线程安全的实现用户自定义标签的动态注入。
第一步,我们先通过 MDC 的 put 方法将自定义标签添加到诊断上下文中:

@Test 
public void testMDC() {     
    MDC.put("userName", "xiaoming");     
    MDC.put("traceId", GlobalTracer.get().activeSpan().context().toTraceId());     
    log.info("Just test the MDC!"); 
}

第二步,在日志配置文件的 Pattern 描述中添加标签变量 %X{userName} 和 %X{traceId}。

<appender name="console" class="ch.qos.logback.core.ConsoleAppender">     
  <filter class="ch.qos.logback.classic.filter.ThresholdFilter">         
    <level>INFO</level>     
  </filter>     
  <encoder>         
    <pattern>%d{HH:mm:ss} [%thread] %-5level [userName=%X{userName}] [traceId=%X{traceId}] %msg%n</pattern>         
    <charset>utf-8</charset>     
  </encoder> 
</appender>

这样,我们就完成了 MDC 变量注入的过程,最终日志输出效果如下所示:


15:17:47 [http-nio-80-exec-1] INFO [userName=xiaoming] [traceId=ee14662c52387763] Just test t
组件抽成
RestTemplate的http请求的处理方法

要将MDC(Mapped Diagnostic Context)的上下文信息传递到目标服务器,需要自定义一个Interceptor并添加到RestTemplate中。

  • 首先,创建一个类来实现ClientHttpRequestInterceptor接口,该接口提供了对每次HTTP请求前后进行处理的能力。在preHandle()方法中获取MDC的上下文信息,然后设置到HttpHeaders中;在postHandle()方法中清除MDC的上下文信息。

添加HandlerInterceptor拦截器,塞入上下文TRACE_ID

import org.slf4j.MDC;

@Component
public class LoggerAdapterHandler implements HandlerInterceptor {

    private final static String TRACE_ID = "TRACEID";
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { {
        MDC.put(TRACE_ID,  String spanIdNew = UUID.randomUUID().toString().replace("-","").substring(0,16));
        return true;
    }
                                                                                                                        }

拦截客户端请求

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.ResponseExtractor;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.net.URI;
import java.util.Enumeration;
 
@Component
public class MdcInterceptor implements ClientHttpRequestInterceptor {
    private static final Logger logger = LoggerFactory.getLogger(MdcInterceptor.class);
    
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        HttpServletRequest servletRequest = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        
        // 从MDC中获取上下文信息
        Enumeration<String> mdcKeys = MDC.getCopyOfContextMap().keys();
        while (mdcKeys.hasMoreElements()) {
            String key = mdcKeys.nextElement();
            
            if (!key.equals("TRACEID")) {
                continue;
            }
            
            String value = MDC.get(key);
            request.getHeaders().add(key, value);
        }
        
        return execution.execute(request, body);
    }
}
  • 然后,配置RestTemplate时注入这个Interceptor:
@Configuration
public class RestConfig {
    @Bean
    public RestTemplate restTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        List<ClientHttpRequestInterceptor> interceptors = new ArrayList<>();
        interceptors.add(new MdcInterceptor());
        restTemplate.setInterceptors(interceptors);
        return restTemplate;
    }
}

最后,就可以在其他地方使用RestTemplate发起HTTP请求了,MDC的上下文信息会被自动传递到目标服务器。
tracingId生成规则:A->ThreadPool(多线程情况下tracingId不一致,重写线程池,手动赋值父线程id)->发起http请求->B系统响应或者回调,携带A的tracingId+B的tracingId->B重复A的流程调用C或者整个流程结束
A->TracingA规则->B->TracingA规则+TracingB规则->C->TracingA规则+TracingB规则+TracingC规则
A<-TracingA规则+TracingB规则+TracingC规则<-B<-TracingA规则+TracingB规则+TracingC规则<-C<-TracingA规则+TracingB规则+TracingC规则

okhhtp请求的处理方式

编写okHttp的拦截器OkHttpLoggerInterceptor。此时需要日志打印的也可以在拦截器中编写请求的url,请求报文以及返回报文日志。

import com.csrcb.constants.MyConstant;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import okio.Buffer;
import okio.BufferedSource;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
 
import java.io.IOException;
import java.util.UUID;
 
/**
 * @Classname OkHttpLoggerInterceptor
 * @Description okhttp请求的拦截器,将traceId及parentSpanId塞入
 */
@Slf4j
public class OkHttpLoggerInterceptor implements Interceptor {
 
    @Override
    public Response intercept(Chain chain) throws IOException {
        //目前默认使用的是post请求,且格式是utf8,使用的是application/json
        Request request = chain.request();
        //copy all headers to newheaders
        Headers.Builder headers = request.headers().newBuilder();
        //add traceid | spanid | parentspanid to headers
        if (StringUtils.isNotBlank(MDC.get(MyConstant.TRACE_ID_MDC_FIELD))){
            headers.add(MyConstant.TRACE_ID_HTTP_FIELD, MDC.get(MyConstant.TRACE_ID_MDC_FIELD));//设置X-B3-TraceId
        }
        if (StringUtils.isNotBlank(MyConstant.SPAN_ID_MDC_FIELD)){
            headers.add(MyConstant.PARENT_SPAN_ID_HTTP_FIELD,MDC.get(MyConstant.SPAN_ID_MDC_FIELD));
        }
        String spanIdNew = UUID.randomUUID().toString().replace("-","").substring(0,16);
        headers.add(MyConstant.SPAN_ID_HTTP_FIELD, spanIdNew);//设置X-B3-SpanId供外部使用
        //rebuild a new request
        request = request.newBuilder().headers(headers.build()).build();
 
        Buffer buffer = new Buffer();
        request.body().writeTo(buffer);
        String requestBody = buffer.readUtf8();
        String requestUrl = request.url().toString();
        String[] url = requestUrl.split("/");
        log.info("[Request Addr]: " + request.url());
        log.info("[Service Name]: " + url[url.length - 1]+ "; [Request Body]: " + requestBody);
        Response response = chain.proceed(request);
        BufferedSource source = response.body().source();
        source.request(Long.MAX_VALUE);
        buffer = source.buffer();
        String responseBody = buffer.readUtf8();
        log.info("[Response Status Code]: " + response.code() + "; [Resonse Status Text]: " + HttpStatus.valueOf(response.code()).name());
        log.info("[Service Name]: " + url[url.length - 1]+ "; [Response Body]: " + responseBody);
        return response.newBuilder().body(ResponseBody.create(response.body().contentType(), responseBody)).build();
    }
}

引入方式

<!--minio-->
<dependency>
  <groupId>cn.lifecycle</groupId>
  <artifactId>tracing-spring-boot-starter</artifactId>
  <version>${minio.version}</version>
</dependency>

MDC 在处理多线程父子线程无法传递数据的问题

重写线程池

public class ThreadPoolExecutorMdcWrapper extends ThreadPoolExecutor {
    public ThreadPoolExecutorMdcWrapper(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
    }

    public ThreadPoolExecutorMdcWrapper(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory);
    }

    public ThreadPoolExecutorMdcWrapper(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, RejectedExecutionHandler handler) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, handler);
    }

    public ThreadPoolExecutorMdcWrapper(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
    }

    @Override
    public void execute(Runnable runnable) {
        super.execute(MDCUtil.wrap(runnable,MDC.getCopyOfContextMap()));
    }

    @Override
    public Future<?> submit(Runnable runnable) {
        return super.submit(MDCUtil.wrap(runnable,MDC.getCopyOfContextMap()));
    }

    @Override
    public <T> Future<T> submit(Runnable runnable, T result) {
        return super.submit(MDCUtil.wrap(runnable,MDC.getCopyOfContextMap()), result);
    }

    @Override
    public <T> Future<T> submit(Callable<T> callable) {
        return super.submit(MDCUtil.wrap(callable,MDC.getCopyOfContextMap()));
    }
}

工具类
手动set 上下文

public class MDCUtil {
    public static Runnable wrap(final Runnable runnable, final Map<String, String> context) {
        return () -> {
            if (context == null) {
                MDC.clear();
            }else {
                MDC.setContextMap(context);
            }
            //如果不是子线程的话先生成traceId
            setTraceIdIfAbsent();
            try {
                runnable.run();
            }finally {
                MDC.clear();
            }
        };
    }


    public static <T> Callable<T> wrap(final Callable<T> callable, final Map<String, String> context) {
        return () -> {
            if (context == null) {
                MDC.clear();
            } else {
                MDC.setContextMap(context);
            }
            setTraceIdIfAbsent();
            try {
                return ((T) callable.call());
            }finally {
                MDC.clear();
            }
        };

    }

    public static void setTraceIdIfAbsent() {
        if (MDC.get(Constants.TRACE_ID) == null) {
            MDC.put(Constants.TRACE_ID, TraceIdUtil.getTraceId());
        }
    }
}

延展

Zipkin(github ,homepage),是一款由java开发的分布式追踪系统。在微服务架构下,它用于帮助收集排查潜在问题的时序数据,同时管理数据收集和数据查询。
Jaeger(github ,homepage),则是受Dapper和OpenZipkin启发,由Uber使用golang开发的分布式跟踪系统。由于我们项目的技术栈为golang,所以重点调研了Jaeger并在此列出。

Jaeger的instrumentation过程
开源链路追踪比较
image.png

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/361782.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

探索设计模式的魅力:精准解读桥接模式-用桥接模式构建可扩展的软件系统

设计模式专栏&#xff1a;http://t.csdnimg.cn/nolNS 目录 一、了解桥接模式&#xff1a;探索抽象和实现的分离 1.1 开-闭原则 1.2 组合/聚合复用原则 1.3 定义 1.4 用意 1.5 基本思想 1.6 组成部分 1.7 桥梁模式的示意性系统的结构图 二、桥接模式的优势&#xff1a…

torchvision.models._utils.IntermediateLayerGetter()使用

torchvision.models._utils.IntermediateLayerGetter&#xff08;&#xff09;使用 源码如下&#xff1a; from collections import OrderedDictimport torch from torch import nnclass IntermediateLayerGetter(nn.ModuleDict):"""Module wrapper that ret…

【Tomcat与网络6】 Tomcat是如何扩展Java线程池的?

目录 1.Java 的线程池 2.Tomcat 的线程池 学习Tomcat的时候&#xff0c;有很多绚丽的技术值得我们学习&#xff0c;但是个人认为Tomcat的线程池扩展是最值得研究的一个部分&#xff0c;线程池的应用太广了&#xff0c;也重要了&#xff0c;Java原生线程池的特征我相信很多人都…

matlab中的图窗属性和坐标轴的属性

图窗的Position和Outerposition Position 指定窗口的尺寸和窗口在屏幕中的位置。 Outerposition 指定窗口外轮廓的大小和位置。 两者都是用一个4维向量来定义&#xff0c;格式为[左 底 宽 高]。 可通过set函数修改Position和Outerposition&#xff0c;如下&#xff1a;在屏幕左…

adb 无线连接 操作Android设备

最近集五福活动比较热门 可以用这个工具 用自己擅长的语言写一个循环程序 运行起来就可以 自动帮我们 看视频得福卡了 很方便 while (true) {sleep(mt_rand(15, 25));system(adb shell input swipe 500 2000 500 1000 100); } 1. 首先下载 安卓开发工具 adb adb网盘链接 链接…

鸿蒙(HarmonyOS)项目方舟框架(ArkUI)之Gauge组件

鸿蒙&#xff08;HarmonyOS&#xff09;项目方舟框架&#xff08;ArkUI&#xff09;之Gauge组件 一、操作环境 操作系统: Windows 10 专业版、IDE:DevEco Studio 3.1、SDK:HarmonyOS 3.1 二、Gauge组件 数据量规图表组件&#xff0c;用于将数据展示为环形图表。 子组件 无…

解决H5中IOS手机底部被弹出键盘遮挡问题

在开发移动端的H5应用时&#xff0c;我们经常会遇到一个问题&#xff0c;就是在iOS手机上&#xff0c;当输入框获取焦点并弹出键盘时&#xff0c;键盘会遮挡住页面底部的内容&#xff0c;给用户带来不便。本文将介绍一种很巧妙的解决方案&#xff0c;通过滚动页面的方式&#x…

AI智能分析+明厨亮灶智慧管理平台助力“舌尖上的安全”

春节是中国最重要的传统节日之一&#xff0c;在春节期间&#xff0c;人们聚餐需求激增&#xff0c;餐饮业也迎来了高峰期。在这个时期&#xff0c;餐饮企业需要更加注重食品安全和卫生质量&#xff0c;以保证消费者的健康和权益&#xff0c;明厨亮灶智慧管理成为了餐饮业中备受…

【spring】springcloud中的组件有那些?

&#x1f34e;个人博客&#xff1a;个人主页 &#x1f3c6;个人专栏&#xff1a;spring ⛳️ 功不唐捐&#xff0c;玉汝于成 目录 正文 我的其他博客 正文 说出主要的组件&#xff1a; Spring Cloud Eureka,服务注册中心,特性有失效剔除、服务保护Spring Cloud Zuul,API服…

【python】使用GtkPaned的程序

一、GTK小部件&#xff08;widgets&#xff09; GTK&#xff08;GIMP Toolkit&#xff09;是一种广泛使用的图形用户界面工具包&#xff0c;用于创建跨平台的GUI应用程序。它提供了许多构建用户界面的控件&#xff0c;称为"小部件"&#xff08;widgets&#xff09;。…

ArrayList集合初始化长度是多少,初始化的时候分配内存空间吗

ArrayList一旦初始化&#xff0c;在内存中就会分配空间吗 是的&#xff0c;当ArrayList在Java中初始化时&#xff0c;即使它没有添加任何元素&#xff0c;也会立即分配内存空间。具体来说&#xff0c;对于默认构造函数创建的ArrayList&#xff08;即不指定初始容量&#xff09…

idea/webstorm 创建Vue实例 Unresolved type Vue 处理方法

1.电脑本地安装node.js 官网下载 2. 其他: 未排除变量,前期试错(以下步骤配置了,但不确定对解决问题是否有帮助)

EasyExcel根据对应的实体类模板完成多个sheet的写入与读取

1.展示模板一的实体类 import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ContentRowHeight; import com.alibaba.excel.annotation.write.style.HeadRowH…

(已解决)Properties和Yaml格式互转

工具转换&#xff1a; 推荐转换工具或者下载idea插件yamls yml&#xff0c;properties互转工具&#xff1a;yaml和proper互转工具 插件转换&#xff1a; 下载yaml插件&#xff0c;对需要转换的文件右键选择转换

Redis常用数据类型--String

String 常用命令SETGETMGETMSETSETNXINCR/DECRINCRBY/DECRBYINCRBYFLOATAPPENDGETRANGESETRANGESTRLEN 内部编码典型应用场景 常用命令 SET 将 string 类型的 value 设置到 key 中。如果 key 之前存在&#xff0c;则覆盖&#xff0c;⽆论原来的数据类型是什么。之 前关于此 k…

GIT基础命令使用

远程仓库的使用 HTTPS&#xff1a;零配置&#xff0c;但每次访问需要账号和密码 SSH&#xff1a;需要进行额外的配置配置成功后不需要重复输入账号密码 生成SSH Key ① 打开 Git Bash ②粘贴如下的命令&#xff0c;并将 your_emailexample.com 替换为注册Github账号时填写…

TSINGSEE青犀智能分析网关V4如何利用AI智能算法保障安全生产、监管,掀开安全管理新篇章

旭帆科技的智能分析网关V4内含近40种智能分析算法&#xff0c;包括人体、车辆、消防、环境卫生、异常检测等等&#xff0c;在消防安全、生产安全、行为检测等场景应用十分广泛。如常见的智慧工地、智慧校园、智慧景区、智慧城管等等&#xff0c;还支持抓拍、记录、告警、语音对…

Zookeeper分布式命名服务实战

目录 分布式命名服务 分布式API目录 分布式节点的命名 分布式的ID生成器 分布式的ID生成器方案&#xff1a; 基于Zookeeper实现分布式ID生成器 基于Zookeeper实现SnowFlakeID算法 分布式命名服务 命名服务是为系统中的资源提供标识能力。ZooKeeper的命名服务主要是利用Z…

基于OpenCV的高压电力检测项目案例

一、项目背景与目标 随着高压电力设施的日益增多&#xff0c;传统的巡检方式已无法满足现代电力系统的需求。为此&#xff0c;我们决定利用计算机视觉技术&#xff0c;特别是OpenCV库&#xff0c;开发一个高压电力检测系统。目标是实现自动化、高精度的电力设备检测&#xff0c…

java之mybatis入门

大前题 正确创建好了springboot工程&#xff0c;极其依赖 配置数据库连接 application.yml spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driverurl: jdbc:mysql://localhost:3306/cangqiongusername: rootpassword: rootlombok Data 自动生成代码&#xff08…
最新文章