• SpringBoot与Loki的那些事


    前言

    因为网上好多都没有通过Loki的API自己实现对日志监控系统,所以我就下定决心自己出一版关于loki与springboot的博文供大家参考,这个可以说是比较实用,很适合中小型企业。因此我酝酿了挺久了,对于loki的研究也比较久,希望各位读者能有新的收获。

    简介

    Loki是Grafana Labs团队的开源项目,可以组成一个功能齐全的日志堆栈。Loki是一个水平可扩展,高可用性,多租户的日志聚合系统。它的设计非常经济高效且易于操作,因为它不会为日志内容编制索引,而是为每个日志流编制一组标签。Loki是用来存储日志和处理查询,需要通过promtail来收集日志,也可是通过后端的logback等日志框架来收集日志,通过grafana提供的loki可视化查看日志,当然了loki也提供了API,可以根据自己的需求来自己实现可视化界面,能够减少三方插件的使用。

    安装

    笔者要介绍的是通过Loki的API编写自己可视化界面,并且通过logback来实现收集日志。 大致的结构如图

    简单介绍一下,主要就是通过springboot后端的logback日志框架来收集日志,在推送到loki中存储,loki执行对日志的查询,通过API根据标签等信息去查询日志并且在自定义的前端界面中展示。

    整体思路

    其实宏观来看,要达成这个需求说起来是十分简单的,只需配置logback配置,在通过MDC写入、收集日志,这里可以好多的写法,可以是通过反射写入日志,也可以是在需要打印的地方写入日志,并且是将日志区分为不同的标签。在前端就可以根据所定义的标签来查看相应的日志。前端获取日志信息逻辑也很简单,就只是通过Loki提供的API获取每行的日志。接下来我就一一详细的介绍SpringBoot与Loki的那些事。 可以查看此图便于理解:

    Loki实战开发

    接下来就详细讲解笔者在实战开发中是如何编写的,本次介绍只是对编写的代码进行详讲,对于代码可能不会全部粘贴,不然冗余起来效果不好,各位读者可以各自发挥,更加完善。其实整个业务也不难,基本都是loki自身提供的API,读者可以通过Loki官方网站grafana.com/docs/loki/l… 去进一步对Loki的API进行查阅,后面笔者可能也会出一篇来专门对Loki的API以及配置进行介绍。好了,废话不多说,马上进入正题。

    springboot中的配置

    首先需要配置向Loki推送日志,也就是需要通过Loki的API:POST /loki/api/v1/push ,可以直接将地址通过appender写死在logback日志框架中,但是在项目开发中,要考虑到环境的不同,应该是能够根据需要来修改loki服务器的地址,因此将loki的服务器地址配置在application-dev.yml中。

    1. loki:
    2. url: http://localhost:3100/loki/api/v1

    配置logback日志框架

    先获取yml配置的地址,通过appender添加到日志框架中,当然,配置客户端也不一定是LogBack框架,还有Log4j2框架也是能够使用的,具体配置可以看官网github.com/loki4j/loki… 和 github.com/tkowalcz/tj… ,本章只对loki进行讲解,对于日志框架,后期也会一一列出,各位读者有什么不了解的,可以先到网上查阅资料。因为笔者不是部署多台Loki服务器,不同的系统采用system这个标签来进行区分。

    1. <springProperty scope="context" name="lokiUrl" source="loki.url"/>
    2. <property name="LOKI_URL" value="${lokiUrl}"/>
    3. <!--添加loki-->
    4. <appender name="lokiAppender" class="com.github.loki4j.logback.Loki4jAppender">
    5. <batchTimeoutMs>1000</batchTimeoutMs>
    6. <http class="com.github.loki4j.logback.ApacheHttpSender">
    7. <url>${LOKI_URL}/push</url>
    8. </http>
    9. <format>
    10. <label>
    11. <pattern>system=${SYSTEM_NAME},level=%level,logType=%X{log_file_type:-logType}</pattern>
    12. </label>
    13. <message>
    14. <pattern>${log.pattern}</pattern>
    15. </message>
    16. <sortByTime>true</sortByTime>
    17. </format>
    18. </appender>

    注解与切面写入日志

    自定义注解,并且设置日志标签值。

    1. /**
    2. * @author: lyd
    3. * @description: 自定义日志注解,用作LOKI日志分类
    4. * @Date: 2022/10/10
    5. */
    6. @Retention(RetentionPolicy.RUNTIME)
    7. @Target({ ElementType.METHOD})
    8. @Documented
    9. public @interface LokiLog {
    10. LokiLogType type() default LokiLogType.DEFAULT;
    11. }

    通过枚举的方式来定义日志类型的标签值

    1. /**
    2. * @author: lyd
    3. * @description: 枚举便签值 - 类型自己定义
    4. * @Date: 2022/10/11
    5. */
    6. public enum LokiLogType {
    7. DEFAULT("默认"),
    8. A("A"),
    9. B("B"),
    10. C("C");
    11. private String desc;
    12. LokiLogType(String desc) {
    13. this.desc=desc;
    14. }
    15. public String getDesc() {
    16. return desc;
    17. }
    18. }

    编写切面,写入日志,内部通过MDC.put("log_file_type", logType.getDesc());(MDC ( Mapped Diagnostic Contexts ),它是一个线程安全的存放诊断日志的容器。可以理解为log_file_type是标签名,logType.getDesc()是标签值。

    1. /**
    2. * @author: lyd
    3. * @description: 自定义日志切面:https://cloud.tencent.com/developer/article/1655923
    4. * @Date: 2022/10/10
    5. */
    6. @Aspect
    7. @Slf4j
    8. @Component
    9. public class LokiLogAspect {
    10. /**
    11. * 切到所有OperatorLog注解修饰的方法
    12. */
    13. @Pointcut("@annotation(org.nl.wms.log.LokiLog)")
    14. public void operatorLog() {
    15. // 空方法
    16. }
    17. /**
    18. * 利用@Around环绕增强
    19. *
    20. * @return
    21. */
    22. @Around("operatorLog()")
    23. public synchronized Object around(ProceedingJoinPoint pjp) throws Throwable {
    24. // ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    25. // HttpServletRequest request = attributes.getRequest();
    26. // HttpServletResponse response = attributes.getResponse();
    27. Signature signature = pjp.getSignature();
    28. MethodSignature methodSignature = (MethodSignature) signature;
    29. Method method = methodSignature.getMethod();
    30. LokiLog lokiLog = method.getAnnotation(LokiLog.class);
    31. // 获取描述信息
    32. LokiLogType logType = lokiLog.type();
    33. MDC.put("log_file_type", logType.getDesc());
    34. log.info("输入参数:" + JSONObject.toJSONString(pjp.getArgs()));
    35. Object proceed = pjp.proceed();
    36. log.info("返回参数:" + JSONObject.toJSONString(proceed));
    37. MDC.remove("log_file_type");
    38. return proceed;
    39. }
    40. }

    使用注解,在方法中引用注解即可

    1. @LokiLog(type = LokiLogType.A)

    前端界面与后端接口

    前端界面介绍起来可能比较麻烦,毕竟写的代码也比较多,这里就选取讲解,代码量比较多,也不会是全部代码粘贴,样式之类的,我相信读者会根据自己的需求去实现,这里主要的是记录开发的思路。

    日志的初步获取

    前端的界面就如图,本次是以el-admin这个为基础制作的demo

    查找日志是需要通过标签与标签值来获取日志信息,因此首先需要的是携带标签对到后端访问Loki的API拿到数据,读者可以查阅官网的API,结合着学习。

    一开始当vue视图渲染的时候,就会从后端获取loki日志标签,具体后端接口的业务代码如下:

    1. /**
    2. * 获取labels和values树
    3. *
    4. * @return
    5. */
    6. @Override
    7. public JSONArray getLabelsValues() {
    8. JSONArray result = new JSONArray();
    9. // 获取所有标签
    10. String labelString = HttpUtil.get(lokiUrl + "/labels", CharsetUtil.CHARSET_UTF_8);
    11. JSONObject parse = (JSONObject) JSONObject.parse(labelString);
    12. JSONArray labels = parse.getJSONArray("data");
    13. for (int i=0; i<labels.size(); i++) {
    14. // 获取标签下的所有值
    15. String valueString = HttpUtil.get(lokiUrl + "/label/" + labels.getString(i) + "/values", CharsetUtil.CHARSET_UTF_8);
    16. JSONObject parse2 = (JSONObject) JSONObject.parse(valueString);
    17. JSONArray values = parse2.getJSONArray("data");
    18. JSONArray children = new JSONArray();
    19. // 组成树形状态 两级
    20. for (int j=0; j<values.size(); j++) {
    21. JSONObject leaf = new JSONObject();
    22. leaf.put("label", values.getString(j));
    23. leaf.put("value", values.getString(j));
    24. children.add(leaf);
    25. }
    26. JSONObject node = new JSONObject();
    27. node.put("label", labels.getString(i));
    28. node.put("value", labels.getString(i));
    29. node.put("children", children);
    30. result.add(node);
    31. }
    32. return result;
    33. }

    核心代码就只有通过Hutool工具包去访问API获取标签HttpUtil.get(lokiUrl + "/labels", CharsetUtil.CHARSET_UTF_8); 以及 获取标签值HttpUtil.get(lokiUrl + "/label/" + labels.getString(i) + "/values", CharsetUtil.CHARSET_UTF_8); 因为我的前端是用elment-ui的树来接收的,因此我就将返回的数据设计成相应的形式。

    1. <el-form-item label="日志标签">
    2. <el-cascader
    3. v-model="labelAndValue"
    4. :options="labelsOptions"
    5. placeholder="请选择标签"
    6. @change="queryData"
    7. />
    8. </el-form-item>

    模糊查找与更多参数

    loki提供了相应的API来进行模糊查找日志,无非就是通过loki的API携带关键字进行模糊查找日志,笔者的做法是获取含有关键字的日志内容。

    1. "/query_range?query={system=\"" + systemName + "\", " + logLabel + "=\"" + logLabelValue + "\"} |= `" + text + "`"

    并且还能够通过时间段来查询,笔者实现了的效果如图 [图片上传失败...(image-7087ac-1668999498015)]

    不仅可以通过关键字,还有时间段时间范围以及查找的方向和一次性显示的条数,最好是建议不要超过1000条数据,滚动步数是实现滚动下拉的时候获取新的日志数据的条目数。 后端代码如下,简单介绍一下,就是提供所需要的查询条件来对日志进行筛选。不管是获取日志数据还是滚动下拉获取的日志数据都可以通用这个接口,然而主要的参数设置可以在前端进行打磨,以下代码还有优化的空间,毕竟当时刚开始写的时候没考虑这么多。

    1. @Override
    2. public JSONObject getLogData(JSONObject json) {
    3. String logLabel = "";
    4. String logLabelValue = "";
    5. Long start = 0L;
    6. Long end = 0L;
    7. String text = "";
    8. String limit = "100";
    9. String direction = "backward";
    10. if (json.get("logLabel") != null) logLabel = json.getString("logLabel");
    11. if (json.get("logLabelValue") != null) logLabelValue = json.getString("logLabelValue");
    12. if (json.get("text") != null) text = json.getString("text");
    13. if (json.get("start") != null) start = json.getLong("start");
    14. if (json.get("end") != null) end = json.getLong("end");
    15. if (json.get("limits") != null) limit = json.getString("limits");
    16. if (json.get("direction") != null) direction = json.getString("direction");
    17. /**
    18. * 组织参数
    19. * 纳秒数
    20. * 1660037391880000000
    21. * 1641453208415000000
    22. * http://localhost:3100/loki/api/v1/query_range?query={host="localhost"} |= ``&limit=1500&start=1641453208415000000&end=1660027623419419002
    23. */
    24. JSONObject parse = null;
    25. String query = lokiUrl + "/query_range?query={system=\"" + systemName + "\", " + logLabel + "=\"" + logLabelValue + "\"} |= `" + text + "`";
    26. String result = "";
    27. if (start==0L) {
    28. result = HttpUtil.get(query + "&limit=" + limit + "&direction=" + direction, CharsetUtil.CHARSET_UTF_8);
    29. } else {
    30. result = HttpUtil.get(query + "&limit=" + limit + "&start=" + start + "&end=" + end + "&direction=" + direction, CharsetUtil.CHARSET_UTF_8);
    31. }
    32. try {
    33. parse = (JSONObject) JSONObject.parse(result);
    34. } catch (Exception e) {
    35. // reslut的值可能为:too many outstanding requests,无法转化成Json
    36. System.out.println("reslut:" + result);
    37. // e.printStackTrace();
    38. }
    39. return parse;
    40. }

    前端的逻辑是比较复杂的,因为需要做大量的赋值与设置。 前端js方法代码,主要是对参数数据的组织,这里需要注意的是,因为loki需要的是纳秒级别的时间戳,这里就需要十分注意前端js的精度。还有一点就是,如果后端日志是有颜色标签的,那么前端直接渲染就会显示标签,所以这里需要进行相应的处理,就是用过AnsiUp插件进行操作

    1. queryData() {
    2. console.log(this.labelAndValue)
    3. // 清空查询数据
    4. this.clearParam()
    5. if (this.labelAndValue.length > 0) {
    6. queryParam.logLabel = this.labelAndValue[0]
    7. queryParam.logLabelValue = this.labelAndValue[1]
    8. }
    9. if (queryParam.logLabelValue === null) { // 判空
    10. this.$message({
    11. showClose: true,
    12. message: '请选择标签',
    13. type: 'warning'
    14. })
    15. this.showEmpty = true
    16. this.emptyText = '请选择标签'
    17. return
    18. }
    19. if (this.timeRange.length !== 0) { // 如果是输入时间范围
    20. queryParam.start = (new Date(this.timeRange[0]).getTime() * 1000000).toString()
    21. queryParam.end = (new Date(this.timeRange[1]).getTime() * 1000000).toString()
    22. }
    23. if (this.timeZoneValue) {
    24. const time = new Date()
    25. queryParam.start = ((time.getTime() - this.timeZoneValue) * 1000000).toString()
    26. queryParam.end = (time.getTime() * 1000000).toString()
    27. }
    28. if (this.text) {
    29. queryParam.text = this.text.replace(/^\s*|\s*$/g, '') // 去空
    30. }
    31. if (this.limits) {
    32. queryParam.limits = this.limits
    33. }
    34. queryParam.direction = this.direction
    35. var ansi_up = new AnsiUp()
    36. logOperation.getLogData(queryParam).then(res => {
    37. this.showEmpty = false
    38. if (res.data.result.length === 1) {
    39. this.logs = res.data.result[0].values
    40. for (const i in res.data.result[0].values) {
    41. this.logs[i][1] = ansi_up.ansi_to_html(res.data.result[0].values[i][1])
    42. }
    43. } else if (res.data.result.length > 1) {
    44. // 清空
    45. this.logs = []
    46. for (const j in res.data.result) { // 用push的方式将所有日志数组添加进去
    47. for (const values_index in res.data.result[j].values) {
    48. this.logs.push(res.data.result[j].values[values_index])
    49. }
    50. }
    51. for (const k in this.logs) {
    52. this.logs[k][1] = ansi_up.ansi_to_html(this.logs[k][1])
    53. }
    54. if (this.direction === 'backward') { // 由于使用公共标签会导致时间顺序错乱,因此对二维数组进行排序
    55. this.logs.sort((a, b) => b[0] - a[0])
    56. } else {
    57. this.logs.sort((a, b) => a[0] - b[0])
    58. }
    59. } else {
    60. this.showEmpty = true
    61. this.emptyText = '暂无日志信息,请选择时间段试试'
    62. }
    63. })
    64. },

    通过AnsiUp插件可以将带有颜色标签的日志以颜色展示,代码如下:

    1. <div style="margin: 3px; min-height: 80vh;">
    2. <!--数据判空-->
    3. <el-empty v-if="showEmpty" :description="emptyText" />
    4. <!--数据加载-->
    5. <el-card v-else shadow="hover" style="width: 100%" class="log-warpper">
    6. <div style="width: 100%">
    7. <div v-for="(log, index) in logs" :key="index">
    8. <div style="margin-bottom: 5px; font-size: 12px;" v-html="log[1]" />
    9. </div>
    10. </div>
    11. </el-card>
    12. </div>

    向后端请求日志返回的结果是如下图所示

    滚动追加日志

    其实下拉滚动的代码与上面直接获取日志的是差不多的,只是在数据的追加是不一样的做法,这里需要注意的是要考虑日志的展示是正序还是逆序,不同的顺序计算时间范围是不一样的,就如下代码

    1. if (this.direction === 'backward') { // 设置时间区间
    2. queryParam.start = (this.logs[this.logs.length - 1][0] - zone).toString()
    3. queryParam.end = this.logs[this.logs.length - 1][0]
    4. } else {
    5. queryParam.start = this.logs[this.logs.length - 1][0]
    6. queryParam.end = (parseFloat(this.logs[this.logs.length - 1][0]) + parseFloat(zone.toString())).toString()
    7. }

    在滚动获取日志的思路是获取最后一条数据的时间,往后推一定的时间差,所以需要考虑是正序还是倒序,默认是6小时。

    1. mounted() {
    2. window.addEventListener('scroll', this.handleScroll)
    3. }
    4. methods: {
    5. handleScroll() { // 滚动事件
    6. const scrollTop = document.documentElement.scrollTop// 滚动高度
    7. const clientHeight = document.documentElement.clientHeight// 可视高度
    8. const scrollHeight = document.documentElement.scrollHeight// 内容高度
    9. const bottomest = Math.ceil(scrollTop + clientHeight)
    10. if (bottomest >= scrollHeight) {
    11. // 加载新数据
    12. queryParam.limits = this.scrollStep
    13. queryParam.direction = this.direction
    14. // 获取时间差
    15. let zone = queryParam.end - queryParam.start
    16. if (this.timeRange.length) { // 如果是输入时间范围
    17. zone = ((new Date(this.timeRange[1]).getTime() - new Date(this.timeRange[0]).getTime()) * 1000000).toString()
    18. }
    19. if (this.timeZoneValue) {
    20. zone = this.timeZoneValue * 1000000
    21. }
    22. if (zone === 0) {
    23. zone = 3600 * 1000 * 6
    24. }
    25. if (this.direction === 'backward') { // 设置时间区间
    26. queryParam.start = (this.logs[this.logs.length - 1][0] - zone).toString()
    27. queryParam.end = this.logs[this.logs.length - 1][0]
    28. } else {
    29. queryParam.start = this.logs[this.logs.length - 1][0]
    30. queryParam.end = (parseFloat(this.logs[this.logs.length - 1][0]) + parseFloat(zone.toString())).toString()
    31. }
    32. var ansi_up = new AnsiUp()
    33. logOperation.getLogData(queryParam).then(res => {
    34. console.log(res)
    35. this.showEmpty = false
    36. if (res.data.result.length === 1) {
    37. // 如果返回的日志是一样的就不显示
    38. if (res.data.result[0].values.length === 1 && ansi_up.ansi_to_html(res.data.result[0].values[0][1]) === this.logs[this.logs.length - 1][1]) {
    39. this.$notify({
    40. title: '警告',
    41. duration: 1000,
    42. message: '当前时间段日志已最新!',
    43. type: 'warning'
    44. })
    45. return
    46. }
    47. const log = res.data.result[0].values
    48. for (const i in res.data.result[0].values) {
    49. log[i][1] = ansi_up.ansi_to_html(res.data.result[0].values[i][1])
    50. this.logs.push(log[i])
    51. }
    52. } else if (res.data.result.length > 1) {
    53. const tempArray = [] // 数据需要处理,由于是追加数组,所以需要用额外变量来存放
    54. // 刷新就是添加,不清空原数组
    55. for (const j in res.data.result) { // 用push的方式将所有日志数组添加进去
    56. for (const values_index in res.data.result[j].values) {
    57. tempArray.push(res.data.result[j].values[values_index])
    58. }
    59. }
    60. if (this.direction === 'backward') { // 由于使用公共标签会导致时间顺序错乱,因此对二维数组进行排序
    61. tempArray.sort((a, b) => b[0] - a[0])
    62. } else {
    63. tempArray.sort((a, b) => a[0] - b[0])
    64. }
    65. for (const k in tempArray) {
    66. tempArray[k][1] = ansi_up.ansi_to_html(tempArray[k][1]) // 数据转换
    67. this.logs.push(tempArray[k]) // 追加数据
    68. }
    69. } else {
    70. this.$notify({
    71. title: '警告',
    72. duration: 1000,
    73. message: '暂无以往日志数据!',
    74. type: 'warning'
    75. })
    76. }
    77. })
    78. }
    79. }
    80. }

    定时刷新日志

    当然,日志的获取也是需要实时刷新的,这种不仅可以使用定时器还能够使用websocket,笔者使用的是定时器,因为这个写起来比较简单。相关的代码以及解析如下: 视图

    1. <el-form-item>
    2. <el-dropdown split-button type="primary" size="mini" @click="queryData">
    3. 查询{{ runStatu }}
    4. <el-dropdown-menu slot="dropdown">
    5. <el-dropdown-item v-for="(item, index) in runStatuOptions" :key="index" @click.native="startInterval(item)">{{ item.label }}</el-dropdown-item>
    6. </el-dropdown-menu>
    7. </el-dropdown>
    8. </el-form-item>

    方法代码 代码大致也和上面两种情况是类似的,思路是获取当前时间前(时间差)的时间到当前时间的日志信息。这里不需要管日志的时序方向,只需要做好始终时间,注意纳秒级别,还有定时器不要忘记销毁。

    1. startInterval(item) {
    2. this.runStatu = item.label
    3. console.log(item.value)
    4. if (item.value !== 0) {
    5. this.timer = setInterval(() => { // 定时刷新
    6. this.intervalLogs()
    7. }, item.value)
    8. } else {
    9. console.log('销毁了')
    10. clearInterval(this.timer)
    11. }
    12. },
    13. intervalLogs() { // 定时器的方法
    14. // 组织参数
    15. // 设置开始时间和结束时间
    16. // 开始为现在时间
    17. const start = new Date()
    18. const end = new Date()
    19. // 时差判断
    20. let zone = queryParam.end - queryParam.start
    21. if (this.timeRange.length) { // 如果是输入时间范围
    22. zone = ((new Date(this.timeRange[1]).getTime() - new Date(this.timeRange[0]).getTime()) * 1000000).toString()
    23. }
    24. if (this.timeZoneValue) {
    25. zone = this.timeZoneValue * 1000000
    26. }
    27. if (zone === 0) { // 防止空指针
    28. start.setTime(start.getTime() - 3600 * 1000 * 6)
    29. queryParam.start = (start.getTime() * 1000000).toString()
    30. } else {
    31. queryParam.start = (start.getTime() * 1000000 - zone).toString()
    32. }
    33. queryParam.end = (end.getTime() * 1000000).toString()
    34. queryParam.limits = this.limits
    35. console.log('定时器最后参数:', queryParam)
    36. var ansi_up = new AnsiUp() // 后端日志格式转化
    37. logOperation.getLogData(queryParam).then(res => {
    38. console.log('res', res)
    39. this.showEmpty = false
    40. debugger
    41. if (res.data.result.length === 1) {
    42. this.logs = res.data.result[0].values
    43. for (const i in res.data.result[0].values) { // 格式转换
    44. this.logs[i][1] = ansi_up.ansi_to_html(res.data.result[0].values[i][1])
    45. }
    46. } else if (res.data.result.length > 1) {
    47. // 清空
    48. this.logs = []
    49. for (const j in res.data.result) { // 用push的方式将所有日志数组添加进去
    50. for (const values_index in res.data.result[j].values) {
    51. this.logs.push(res.data.result[j].values[values_index])
    52. }
    53. }
    54. for (const k in this.logs) {
    55. this.logs[k][1] = ansi_up.ansi_to_html(this.logs[k][1])
    56. }
    57. if (this.direction === 'backward') { // 由于使用公共标签会导致时间顺序错乱,因此对二维数组进行排序
    58. this.logs.sort((a, b) => b[0] - a[0])
    59. } else {
    60. this.logs.sort((a, b) => a[0] - b[0])
    61. }
    62. } else {
    63. this.showEmpty = true
    64. this.emptyText = '暂无日志信息,请选择时间段试试'
    65. }
    66. })
    67. },

    最后粘一小段展示的界面

    总结

    loki是轻量级的分布式日志查询框架,特别适合中小型企业,尤其是工业项目,在项目上线的时候可以通过这样的一个界面来观察日志,确实能够得到很大的帮助,但是这个loki不是特别的稳定,最为常见的是会出现ERP ERROR,这种错误是最头疼的,个人感觉可能是计算机或者网络的因素造成。

  • 相关阅读:
    AI创作系统ChatGPT商业运营版源码+AI绘画/支持GPT联网提问/支持Midjourney绘画+Prompt应用+支持国内AI提问模型
    Docker Compose安装
    如何取消自动播放音乐:取消手机汽车连上后汽车自动播放音乐?
    代码随想录算法训练营day57 | LeetCode 647. 回文子串 516. 最长回文子序列
    如何备份Syslog配置文件?
    树状数组笔记
    数字图像处理(入门篇)四 像素关系
    基于HTML美食餐饮文化项目的设计与实现 HTML+CSS上海美食介绍网页(8页) 大学生美食文化网站制作 简单餐饮文化网页设计成品
    一文详解|Go 分布式链路追踪实现原理
    CNN反向求导推导
  • 原文地址:https://blog.csdn.net/m0_57042151/article/details/127960508