Golang context 原理分析

  • 1. 说在前面
  • 2. 场景分析
    • 2.1 链式传递
    • 2.2 主动取消
    • 2.3 任务超时
    • 2.4 数据存储
  • 3. 源码解读
    • 3.1 一个核心数据结构
      • 3.1.1 Context
    • 3.2 四种具体实现
      • 3.2.1 emptyCtx
      • 3.2.2 cancelCtx
      • 3.2.3 timerCtx
      • 3.2.4 valueCtx
    • 3.3 六个核心方法
      • 3.3.1 Background() && TODO()
      • 3.3.2 WithCancel()
      • 3.3.3 WithDeadline()
      • 3.3.4 WithTimeout()
      • 3.3.5 WithValue()
  • 4. 一些思考
    • 思考1:emptyCtx 为什么不是 struct{}类型?
    • 思考2:backgound 和 todo 有什么区别?
    • 思考3:cancelCtx 怎么保证父亲 👨 取消的同时取消儿子 👦?
    • 思考4:valueCtx 可以用于数据存储吗?

1. 说在前面

context 是 golang 中的经典工具,主要在异步场景中用于实现并发协调控制以及对 goroutine 的生命周期管理。除此之外,context 还兼有一定的数据存储能力。本文旨在剖析 context 的核心工作原理。

本文使用到的 Go 版本为 1.18,源码位置 src/context/context.go

2. 场景分析

2.1 链式传递

在 Go 中可以认为协程的组织是一种链式传递,每一个子协程的创建都是基于父协程,但是父协程对子协程的控制则是通过 context 实现;同样的,每一个 context 也都是基于父 context 创建,最终形成链式结构,根 context 就是 emptyCtx。

2.2 主动取消

取消场景,是父协程任务取消的时候,将子协程一并取消。

在下面这个案例中,子协程的任务需要 2s 才能执行完,但是父协程 1s 后执行报错主动 cancel 任务的执行,cancel() 方法会通知子协程一并取消任务的执行。

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

func testCtx(ctx context.Context) {
	cancelCtx, cancel := context.WithCancel(ctx)
	defer cancel()

	go func(ctx context.Context) {
		cancelCtx, cancel := context.WithCancel(ctx)
		defer cancel()

		// do something

		select {
		case <-time.After(2 * time.Second):
			// 假设任务需要 2s 完成
			fmt.Println("work done")
		case <-cancelCtx.Done():
			fmt.Println("work canceled")
			return
		}

		// do something
	}(cancelCtx)

	// 模拟执行过程中父协程报错取消任务
	<-time.After(1 * time.Second)
	err := errors.New("fake err")
	if err != nil {
		cancel()
	}
}

func main() {
	ctx := context.Background()
	testCtx(ctx)
	<-time.After(3 * time.Second)
}

2.3 任务超时

超时场景,是父协程任务超时的时候会触发取消流程,需将子协程一并取消。

在下面这个案例中,子协程的任务需要 2s 才能执行完,但是父协程 1s 后任务超时,开始执行取消任务的流程,通知子协程一并取消任务的执行。

package main

import (
	"context"
	"fmt"
	"time"
)

func testCtx(ctx context.Context) {
	timerCtx, cancel := context.WithTimeout(ctx, 1*time.Second)
	defer cancel()

	go func(ctx context.Context) {
		timerCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
		defer cancel()

		// do something

		select {
		case <-time.After(2 * time.Second):
			// 假设任务需要 2s 完成
			fmt.Println("work done")
		case <-timerCtx.Done():
			fmt.Println("work canceled")
			return
		}

		// do something
	}(timerCtx)

	// 模拟等待子协程退出(偷懒)
	<-time.After(2 * time.Second)
}

func main() {
	ctx := context.Background()
	testCtx(ctx)
	<-time.After(3 * time.Second)
}

2.4 数据存储

context 可以用于数据存储,通常是用于存储一些元数据。

package main

import (
	"context"
	"fmt"
	"time"
)

func testCtx(ctx context.Context) {
	valueCtx := context.WithValue(ctx, "name", "father")
	fmt.Println(valueCtx.Value("name"))

	go func(ctx context.Context) {
		valueCtx := context.WithValue(ctx, "name", "child")
		fmt.Println(valueCtx.Value("name"))
	}(valueCtx)
}

func main() {
	ctx := context.Background()
	testCtx(ctx)
	<-time.After(3 * time.Second)
}

3. 源码解读

3.1 一个核心数据结构

3.1.1 Context

首先 Context 本质是官方提供的一个 interface,实现了该 interface 定义的都被称之为 context。

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}
  • Deadline() 返回 ctx 生命终了时间,如果没有则 ok 为 false
  • Done() 返回一个 channel 用于判断 ctx 是否已经结束
  • Err() 用于 ctx 结束后获取错误信息
  • Value() 获取 ctx 中存入的键值对

3.2 四种具体实现

3.2.1 emptyCtx

官方实现的一个空 ctx 版本,默认都是返回空值,通常是作为所有 ctx 的根。

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

func (*emptyCtx) Done() <-chan struct{} {
	return nil
}

func (*emptyCtx) Err() error {
	return nil
}

func (*emptyCtx) Value(key any) any {
	return nil
}

3.2.2 cancelCtx

具有取消功能,父 ctx 取消的时候将所有子 ctx 一并取消。

类型定义

type cancelCtx struct {
	Context

	mu       sync.Mutex            // 保护临界资源
	done     atomic.Value          // chan struct{} 类型,用于判断 ctx 是否取消,第一次调用 cancel() 后 close
	children map[canceler]struct{} // 存储所有的子 ctx
	err      error                 // ctx 取消后记录错误信息
}

Done() 方法实现

func (c *cancelCtx) Done() <-chan struct{} {
	d := c.done.Load()
	if d != nil {
		return d.(chan struct{})
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	d = c.done.Load()
	if d == nil {
		d = make(chan struct{})
		c.done.Store(d)
	}
	return d.(chan struct{})
}
  • 如果 done 不为空则直接返回
  • 加锁🔒
  • 如果 done 为空则创建,这里也说说明一点:done 是懒加载的,第一次调用 Done() 方法才会创建 done
  • 返回 done
  • 解锁🔒

Value() 方法实现

var cancelCtxKey int

func (c *cancelCtx) Value(key any) any {
	if key == &cancelCtxKey {
		return c
	}
	return value(c.Context, key)
}
  • 如果传入的 key 是 cancelCtxKey 则返回自身,这里制定了一种私有协议(外部无法访问 cancelCtxKey),用于后面判断一个父 ctx 是否是一个 cancelCtx 类型
  • 否则调用 value 方法找到 key 对应的 value

Err() 方法实现

func (c *cancelCtx) Err() error {
	c.mu.Lock()
	err := c.err
	c.mu.Unlock()
	return err
}
  • 加锁🔒保护,返回 err

Deadline() 方法实现

  • 未实现,继承父 Context

3.2.3 timerCtx

具有定时取消的功能,因为是继承自 cancelCtx 所以同样具有主动取消功能

类型定义

type timerCtx struct {
	cancelCtx
	timer *time.Timer  // timerCtx 内部维护的一个定时器

	deadline time.Time // ctx 的终了时间
}

Done() 方法实现

  • 未实现,继承父 cancelCtx

Value() 方法实现

  • 未实现,继承父 cancelCtx

Err() 方法实现

  • 未实现,继承父 cancelCtx

Deadline() 方法实现

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
	return c.deadline, true
}
  • 返回 dealline

3.2.4 valueCtx

具有存储数据的功能,通常是一些元数据信息

类型定义

type valueCtx struct {
	Context
	key, val any // 记录数据
}

Done() 方法实现

  • 未实现,继承父 Context

Value() 方法实现

func (c *valueCtx) Value(key any) any {
	if c.key == key {
		return c.val
	}
	return value(c.Context, key)
}

func value(c Context, key any) any {
	for {
		switch ctx := c.(type) {
		case *valueCtx:
			if key == ctx.key {
				return ctx.val
			}
			c = ctx.Context
		case *cancelCtx:
			if key == &cancelCtxKey {
				return c
			}
			c = ctx.Context
		case *timerCtx:
			if key == &cancelCtxKey {
				return &ctx.cancelCtx
			}
			c = ctx.Context
		case *emptyCtx:
			return nil
		default:
			return c.Value(key)
		}
	}
}
  • 判断传入的 key 是否等于当前 ctx 的 k,如果相等则返回
  • 否则就从父 ctx 中找,一直到 emptyCtx 找不到就返回 nil

Err() 方法实现

  • 未实现,继承父 Context

Deadline() 方法实现

  • 未实现,继承父 Context

3.3 六个核心方法

3.3.1 Background() && TODO()

用于获取 emptyCtx,本质上没有区别,仅仅是语义上的区别。

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

3.3.2 WithCancel()

函数说明:传入一个父 ctx 返回一个子 ctx 和一个 cancel 函数

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}
  • 如果传入的 parent 是空的,panic
  • 基于 parent 创建 cancelCtx
  • propagateCancel 用于传递 cancel 的特性,用于保证父 ctx 取消的时候,子 ctx 也取消
  • 返回创建的 cancelCtx 和一个闭包函数,闭包函数调用 cancel 取消创建的 ctx
func propagateCancel(parent Context, child canceler) {
	done := parent.Done()
	if done == nil {
		return // parent is never canceled
	}

	select {
	case <-done:
		// parent is already canceled
		child.cancel(false, parent.Err())
		return
	default:
	}

	if p, ok := parentCancelCtx(parent); ok {
		p.mu.Lock()
		if p.err != nil {
			// parent has already been canceled
			child.cancel(false, p.err)
		} else {
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
		atomic.AddInt32(&goroutines, +1)
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}
  • parent 是一个永远不会被取消的 ctx,直接 return
  • 如果 parent 已经被取消了,把当前新创建的 cancelCtx 也取消,并 return
  • 如果 parent 是一个 cancelCtx 类型则将新创建的 cancelCtx 加入到 parent 的 children 中
  • 如果 parent 不是 cancelCtx 类型,则启动一个 goroutine 来保证 parent 取消的时候当前 cancelCtx 也被取消掉
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return // already canceled
	}
	c.err = err
	d, _ := c.done.Load().(chan struct{})
	if d == nil {
		c.done.Store(closedchan)
	} else {
		close(d)
	}
	for child := range c.children {
		// NOTE: acquiring the child's lock while holding parent's lock.
		child.cancel(false, err)
	}
	c.children = nil
	c.mu.Unlock()

	if removeFromParent {
		removeChild(c.Context, c)
	}
}
  • 前置判断
  • 将 done chan 关闭
  • 遍历 children,将所有的子 ctx 都一并取消掉
  • 如果 parent 是 cancelCtx 类型,需要将当前 ctx 从 parent 的 children 中删除

3.3.3 WithDeadline()

函数说明:传入一个父 ctx,和一个终了时间,返回一个子 ctx 和一个 cancel 函数;timerCtx 继承自 cancelCtx,拥有 cancelCtx 的一切特性

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	propagateCancel(parent, c)
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(false, Canceled) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}
  • 如果 parent 也是一个 timerCtx 并且传入的终了时间在 parent 的终了时间之后,那么新创建的 ctx 就没必要拥有定时特性,使用 WithCancel 构造一个 cancelCtx 返回即可
  • 否则创建一个 timerCtx
  • propagateCancel 传递 cancel 的特性
  • 如果 deadline 时间已经过了,直接 cancel 然后 return
  • 创建一个定时任务,定时结束触发 cancel
func (c *timerCtx) cancel(removeFromParent bool, err error) {
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// Remove this timerCtx from its parent cancelCtx's children.
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}
  • 调用 parent 的 cancel 函数
  • 如果 parent 是 cancelCtx 类型,需要将当前 ctx 从 parent 的 children 中删除
  • 定时器 stop

3.3.4 WithTimeout()

仅仅对 WithDeadline 进行了简单封装

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

3.3.5 WithValue()

func WithValue(parent Context, key, val any) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

4. 一些思考

思考1:emptyCtx 为什么不是 struct{}类型?

struct{} 作为一个空类型并不占用底层存储空间,所以它的多个不同对象有可能会使用相同的地址,无法区分出 background 和 todo 对象。

思考2:backgound 和 todo 有什么区别?

本质没有区别,都是 emptyCtx,更多的是语义上的区别,background 通常作为所有 ctx 链的最顶层。

思考3:cancelCtx 怎么保证父亲 👨 取消的同时取消儿子 👦?

机制 1:cancelCtx 有一个 children 字段记录了所有的子节点,当父节点被取消的时候会给所有子节点来一刀 🔪,依次传递最终将所有子、孙子、孙孙子都刀 🔪 了。父亲取消的时候也会通知爷爷,让爷爷从 children 中删除父亲。

机制 2:如果父亲不是一个 cancelCtx 类型,则不会有 children 属性怎么办?当使用 WithCancel()创建的时候,发现父亲不是 cancelCtx 就会启动一个守护协程判断父亲是否 Done(),如果父亲 over 了,就会干掉儿子并退出;否则儿子先挂了,也会退出。

思考4:valueCtx 可以用于数据存储吗?

valueCtx 不适合视为存储介质,存放大量的 kv 数据,它的定位类似于请求头,只适合存放少量作用域较大的全局 meta 数据: 一个 valueCtx 实例只能存一个 kv 对,因此 n 个 kv 对会嵌套 n 个 valueCtx,造成空间浪费;
基于 k 寻找 v 的过程是线性的,时间复杂度 O(N); 不支持基于 k 的去重,相同 k 可能重复存在,并基于起点的不同,返回不同的 v。

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

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

相关文章

MySQL-实验-单表、多表数据查询和嵌套查询

目录 0.简单子查询 &#xff08;1&#xff09;带比较运算符的子查询 &#xff08;2&#xff09;关键字子查询 1.多表查询 3.子查询 4.多表子查询 0.简单子查询 &#xff08;1&#xff09;带比较运算符的子查询 在右侧编辑器补充代码&#xff0c;查询大于所有平均年龄的员…

10 SQL进阶 -- 综合练习题 -- 10道经典SQL题目,配套数据与解答

1. 创建表结构和导入数据 1.1 新建数据库 1.2 执行建表语句 点击下方链接直接下载创建数据表脚本:http://tianchi-media.oss-cn-beijing.aliyuncs.com/dragonball/SQL/create_table.sql 执行建表语句执行成功查看创建的表1.3 导入数据 点击下方链接直接下载插入数据脚本:htt…

VBA脚本终章编译器崩溃

一、介绍 本篇文章为VBA脚本隐藏技术的最后一篇&#xff0c;将介绍如何在保证VBA脚本正常执行的情况下&#xff0c;使分析人员无法打开编译器。 那么为什么需要分析人员无法打开编译器呢&#xff1f; 首先&#xff0c;我们需要引入一个知识点。 在上篇《VBA隐藏技术stompin…

笔记本wifi连接外网 网线连接办公内网 设置路由实现内外网可同时访问

工作提供的办公网络是企业内网,接上企业内网网线后 通过无线在连接手机wifi ,会发现内外网无法同时访问,我自己电脑是接上内网网线 也是只能访问外网,除非把外网无线暂时关闭,才可以访问内网 频繁切换很不方便 1.查看外网无线 wifi网卡信息 IPv4 地址: 192.168.18.114 IP…

数据结构学习记录

数据结构 数组 & 链表 相连性 | 指向性 数组可以迅速定位到数组中某一个节点的位置 链表则需要通过前一个元素指向下一个元素&#xff0c;需要前后依赖顺序查找&#xff0c;效率较低 实现链表 // head > node1 > node2 > ... > nullclass Node {constructo…

AI原生时代,操作系统为何是创新之源?

一直以来&#xff0c;操作系统都是软件行业皇冠上的明珠。 从上世纪40、50年代&#xff0c;汇编语言和汇编器实现软件管理硬件&#xff0c;操作系统的雏形出现&#xff1b;到60年代&#xff0c;高级编程语言和编译器诞生&#xff0c;开发者通过操作系统用更接近人的表达方式去…

面向对象(一)

一.类与对象的定义 (1)类(设计图):是对象共同特征的描述: (2)对象:是真实存在的具体东西。 在Java中,必须先设计类&#xff0c;才能获取对象。 二.如何定义类 public class 类名{1.成员变量(代表属性,一般是名词) 2.成员方法(代表行为,一般是动词) 3.构造器 4.代码块 5.内部…

Liunx入门学习 之 基础操作指令讲解(小白必看)

股票的规律找到了&#xff0c;不是涨就是跌 一、Linux下基本指令 1.ls 指令 2.pwd 命令 3.cd 指令 4.touch 指令 5.mkdir 指令 6.rmdir指令 && rm 指令 7.man 指令 8.cp 指令 9.mv指令 10.cat 11.more 指令 12.less 指令 13.head 指令 14.tail 指令 15…

论文解读-Contiguitas: The Pursuit of Physical Memory Contiguity in Datacenters

研究背景&#xff1a; 在内存容量飞速增长的背景下&#xff0c;使用小页管理内存会带来巨大的内存管理开销&#xff08;地址转换开销高&#xff09;。近些年来不少研究尝试给应用分配大段连续区域&#xff0c;或者改善页表结构&#xff08;如使用hash结构的页表&#xff09;以降…

质谱原理与仪器2-笔记

质谱原理与仪器2-笔记 常见电离源电子轰击电离源(EI)碎片峰的产生典型的EI质谱图 化学电离源(CI)快原子轰击源(FAB)基体辅助激光解析电离(MALDI)典型的MALDI质谱图 大气压电离源(API)电喷雾离子源(ESI)大气压化学电离源(APCI)APCI的正负离子模式 大气压光电离源(APPI) 常见电离…

玄子Share-计算机网络参考模型

玄子Share-计算机网络参考模型 分层思想 利用七层参考模型&#xff0c;便于在网络通信过程中&#xff0c;快速的分析问题&#xff0c;定位问题并解决问题 将复杂的流程分解为几个功能相对单一的子过程 整个流程更加清晰&#xff0c;复杂问题简单化 更容易发现问题并针对性的…

线上频繁fullgc问题-SpringActuator的坑

整体复盘 一个不算普通的周五中午&#xff0c;同事收到了大量了cpu异常的报警。根据报警表现和通过arthas查看&#xff0c;很明显的问题就是内存不足&#xff0c;疯狂无效gc。而且结合arthas和gc日志查看&#xff0c;老年代打满了&#xff0c;gc不了一点。既然问题是内存问题&…

Python练习03

题目 解题思路 Demo58 通过字符串切片来进行反转操作 def _reverse():"""这是一个反转整数的函数"""num input("请输入想要反转的整数")print(num[::-1]) 运行结果 Demo61 首先制作一个判断边长的函数&#xff0c;通过三角形两边…

又成长了,异常掉电踩到了MySQL主从同步的坑!

&#x1f4e2;&#x1f4e2;&#x1f4e2;&#x1f4e3;&#x1f4e3;&#x1f4e3; 哈喽&#xff01;大家好&#xff0c;我是【IT邦德】&#xff0c;江湖人称jeames007&#xff0c;10余年DBA及大数据工作经验 一位上进心十足的【大数据领域博主】&#xff01;&#x1f61c;&am…

Google Earth Engine 洪水制图 - 使用 Sentinel-1 SAR GRD

Sentinel-1 提供从具有双极化功能的 C 波段合成孔径雷达 (SAR) 设备获得的信息。该数据包括地面范围检测 (GRD) 场景,这些场景已通过 Sentinel-1 工具箱进行处理,以创建经过校准和正射校正的产品。该集合每天都会更新,新获得的资产会在可用后两天内添加。 该集合包含所有 G…

《王者荣耀》Hello Kitty 小兵皮肤完整设置指南

王者荣耀与三丽鸥的联动活动上线了 Hello Kitty 小兵皮肤&#xff0c;让我们的峡谷小兵们也能穿上漂亮的衣服啦&#xff01;这款皮肤极具卡哇伊风格&#xff0c;引起了许多玩家的关注。许多小伙伴都想知道如何使用这款 Hello Kitty 小兵皮肤&#xff0c;今天小编将为大家整理出…

STC单片机与串口触摸屏通讯程序

/***串口1切换通讯测试,单片机发送数据给触摸屏***/ /***切换到3.0 3.1发送数据到串口通信软件 ***/ /***设置温度 加热时间读写EEPROM正确 ***/ #include <REG52.H> //2023 3 5 L330 CODE2667 #include <intrin…

使用JDK自带工具进行JVM内存分析之旅

进行jvm内存分析可以排查存在和潜在的问题。 通过借助jdk自带的常用工具&#xff0c;可以分析大概可能的问题定位以及确定优化方向。 JVM内存分析有很多好处。 内存泄漏排查&#xff1a;JVM 内存泄漏是指应用程序中的对象占用的内存无法被垃圾回收器释放&#xff0c;导致内存…

遥瞻智慧:排水系统远程监控的卓越解决方案

遥瞻智慧&#xff1a;排水系统远程监控的卓越解决方案 在城市脉络的深层肌理中&#xff0c;排水系统犹如一条条隐秘的生命线&#xff0c;默默承载着城市的呼吸与律动。然而&#xff0c;如何以科技之眼&#xff0c;赋予这些无形网络以实时感知、精准调控的能力&#xff0c;使之…

基于机器学习的车辆状态异常检测

基于马氏距离的车辆状态异常检测&#xff08;单一传感器&#xff09; 基于多元自动编码器的车辆状态异常检测 基于单传感器平滑马氏距离的车辆状态异常检测 工学博士&#xff0c;担任《Mechanical System and Signal Processing》等期刊审稿专家&#xff0c;擅长领域&#xff1…
最新文章