【Go语言从入门到实战】反射编程、Unsafe篇
反射编程
reflect.TypeOf vs reflect.ValueOf

func TestTypeAndValue(t *testing.T) {var a int64 = 10t.Log(reflect.TypeOf(a), reflect.ValueOf(a))t.Log(reflect.ValueOf(a).Type())
}

判断类型 - Kind()
当我们需要对反射回来的类型做判断时,Go 语言内置了一个枚举,可以通过 Kind() 来返回这个枚举值:
const (Invalid Kind = iotaBoolIntInt8Int16Int32Int64UintUint8Uint16Uint32Uint64// ...
)
package reflectimport ("fmt""reflect""testing"
)// 检查反射类型
// 用空接口接收任意类型
func CheckType(v interface{}) {t := reflect.TypeOf(v)switch t.Kind() {case reflect.Int, reflect.Int32, reflect.Int64:fmt.Println("Int")case reflect.Float32, reflect.Float64:fmt.Println("Float")default:fmt.Println("unknown type")}
}func TestBasicType(t *testing.T) {var f float32 = 1.23CheckType(f)
}

利用反射编写灵活的代码

reflect.TypeOf() 和 reflect.ValueOf() 都有 FieldByName() 方法。
// s必须是一个 struct 类型// reflect.ValueOf()只会返回一个值
reflect.ValueOf(s).FieldByName("Name")// reflect.TypeOf()可以返回两个值,第二个值可以用来判断这个值有没有;
reflect.TypeOf(s).FieldByName("Name")
FieldByName() 方法返回的是一个 StructField 类型的值。

我们可以通过这个 StructField 来访问 Struct Tag:
type StructField struct {// Name是字段的名字。PkgPath是非导出字段的包路径,对导出字段该字段为""。// 参见http://golang.org/ref/spec#Uniqueness_of_identifiersName stringPkgPath stringType Type // 字段的类型Tag StructTag // 字段的标签Offset uintptr // 字段在结构体中的字节偏移量Index []int // 用于Type.FieldByIndex时的索引切片Anonymous bool // 是否匿名字段
}
FieldByName() 方法调用者必须是一个 struct,而不是指针,源码如下:

// 访问 MethodByName() 必须是指针类型
reflect.ValueOf(&s).MethodByName("method_name").Call([]reflect.Value{reflect.ValueOf("new_value")})
type Employee struct {EmployeeID string// 注意后面的 struct tag 的写法,详情见第5点讲解Name string `format:"normal"`Age int
}// 更新名字,注意这里的 e 是指针类型
func (e *Employee) UpdateName(newVal string) {e.Name = newVal
}// 通过反射调用结构体的方法
func TestInvokeByName(t *testing.T) {e := Employee{"1", "Jane", 18}// reflect.TypeOf()可以返回两个值,第二个值可以用来判断这个值有没有;// 而reflect.ValueOf()只会返回一个值t.Logf("Name: value(%[1]v), Type(%[1]T)", reflect.ValueOf(e).FieldByName("Name"))if nameField, ok := reflect.TypeOf(e).FieldByName("Name"); !ok {t.Error("Failed to get 'Name' field")} else {// 获取反射取到的字段的 tag 的值t.Log("Tag:Format", nameField.Tag.Get("format"))}// 访问 MethodByName() 必须是指针类型 reflect.ValueOf(&e).MethodByName("UpdateName").Call([]reflect.Value{reflect.ValueOf("Mike")})t.Log("After update name: ", e)
}

Elem()
因为 FieldByName() 必须要结构体才能调用,如果参数是一个指向结构体的指针,我们需要用到 Elem() 方法,它会帮你获得指针指向的结构。
Elem()用来获取指针指向的值- 如果参数不是指针,会报 panic 错误
- 如果参数值是 nil,获取的值为 0
// reflect.ValueOf(demoPtr)).Elem() 返回的是字段的值
reflect.ValueOf(demoPtr).Elem()// reflect.ValueOf(st)).Elem().Type() 返回的是字段类型
reflect.ValueOf(demoPtr).Elem().Type()// 传递指针类型参数调用 FieldByName() 方法
reflect.ValueOf(demoPtr).Elem().FieldByName("Name")// 传递指针类型参数调用 FieldByName() 方法
reflect.ValueOf(demoPtr).Elem().Type().FieldByName("Name")
Struct Tag
结构体里面可以对某些字段做特殊的标记,它是一个 `key: “value”` 的格式。

type Demo struct {// 先用这个符号(``)包起来,然后写上 key: value 的格式Name string `format:"normal"`
}
Go 内置的 Json 解析会用到 tag 来做一些标记。
反射是把双刃剑
反射是一个强大并富有表现力的工具,能让我们写出更灵活的代码。但是反射不应该被滥用,原因有以下三个:
- 基于反射的代码是极其脆弱的,反射中的类型错误会在真正运行的时候才会引发 panic,那很可能是在代码写完的很长时间之后。
- 大量使用反射的代码通常难以理解。
- 反射的性能低下,基于反射实现的代码通常比正常代码运行速度慢一到两个数量级。
万能程序
DeepEqual
我们都知道两个 map 类型之间是不能互相比较的,两个 slice 类型之间也不能进行比较,但是反射包中的 DeepEqual() 可以帮我们实现这个功能。
用 DeepEqual() 比较 map
// 用 DeepEqual() 比较两个 map 类型
func TestMapComparing(t *testing.T) {m1 := map[int]string{1: "one", 2: "two", 3: "three"}m2 := map[int]string{1: "one", 2: "two", 3: "three"}if reflect.DeepEqual(m1, m2) {t.Log("yes")} else {t.Log("no")}
}

用 DeepEqual() 比较 slice
// 用 DeepEqual() 比较两个切片类型
func TestSliceComparing(t *testing.T) {s1 := []int{1, 2, 3, 4}s2 := []int{1, 2, 3, 5}if reflect.DeepEqual(s1, s2) {t.Log("yes")} else {t.Log("no")}
}

用反射实现万能程序
场景:我们有 Employee 和 Customer 两个结构体,二者有两个相同的字段(Name 和 Age),我们希望写一个通用的程序,可以同时填充这两个不同的结构体。
type Employee struct {EmployeeId intName stringAge int
}type Customer struct {CustomerId intName stringAge int
}// 用同一个数据填充不同的结构体
// 思路:既然是不同的结构体,那么要想通用,所以参数必须是一个空接口才行。
// 因为是空接口,所有我们需要对参数类型写断言
func fillDifferentStructByData(st interface{}, data map[string]interface{}) error {// 先判断传过来的类型是不是指针if reflect.TypeOf(st).Kind() != reflect.Ptr {return errors.New("第一个参数必须传一个指向结构体的指针")}// 再判断指针指向的类型是否为结构体// Elem() 用来获取指针指向的值// 如果参数不是指针,会报 panic 错误// 如果参数值是 nil, 获取的值为 0if reflect.TypeOf(st).Elem().Kind() != reflect.Struct {return errors.New("第一个参数必须是一个结构体类型")}if data == nil {return errors.New("填充用的数据不能为nil")}var (field reflect.StructFieldok bool)for key, val := range data {// 如果结构体里面没有 key 这个字段,则跳过// reflect.ValueOf(st)).Elem().Type() 返回的是字段类型// reflect.ValueOf(st)).Elem().Type() 等价于 reflect.TypeOf(st)).Elem()if field, ok = reflect.TypeOf(st).Elem().FieldByName(key); !ok {continue}// 如果字段的类型相同,则用 data 的数据填充这个字段的值if field.Type == reflect.TypeOf(val) {// reflect.ValueOf(st)).Elem() 返回的是字段的值reflect.ValueOf(st).Elem().FieldByName(key).Set(reflect.ValueOf(val))}}return nil
}// 填充姓名和年龄
func TestFillNameAndAge(t *testing.T) {// 声明一个 map,用来存放数据,这些数据将会填充到 Employee 和 Customer 这两个结构体中data := map[string]interface{}{"Name": "Jane", "Age": 18}e := Employee{}// 传给通用的填充方法if err := fillDifferentStructByData(&e, data); err != nil {t.Fatal(err)}c := Customer{}// 传给通用的填充方法if err := fillDifferentStructByData(&c, data); err != nil {t.Fatal(err)}t.Log(e)t.Log(c)
}

两个结构体的 name 和 age 都填充上了,符合预期。
不安全编程-UnSafe
不安全编程指的是 go 语言中有一个 package 叫:unsafe,它的使用场景一般是要和外部 c 程序实现的一些高效的库来进行交互。
“不安全行为”的危险性

Go 语言中是不支持强制类型转换的,而我们一旦使用 unsafe.Pointer 拿到指针后,我们可以将它转换为任意类型的指针,这样我们是否能利用它来实现强制类型转换呢?我们可以用代码来测试一下:
func TestUnsafe(t *testing.T) {i := 10f := *(*float64)(unsafe.Pointer(&i))t.Log(unsafe.Pointer(&i))t.Log(f)
}

可以看到结果根本不是 10,是一串数字字母的组合,所以这是非常危险的。
合理的类型转换
在 Go 语言中,不同类型的指针是不允许相互赋值的,但是通过合理地使用 unsafe 包,则可以打破这种限制。
例如:int 类型是可以进行转换赋值的。
func TestConvert1(t *testing.T) {var num int = 10var uintNum uint = *(*uint)(unsafe.Pointer(&num))var int32Num int32 = *(*int32)(unsafe.Pointer(&num))t.Log(num, uintNum, int32Num)t.Log(reflect.TypeOf(num), reflect.TypeOf(uintNum), reflect.TypeOf(int32Num))
}

访问修改结构体私有成员变量
type User struct {name stringid int
}func TestOperateStruct(t *testing.T) {user := new(User)user.name = "张三"fmt.Printf("%+v\n", user)// 突破第一个私有变量,因为是结构体的第一个字段,所以不需要额外的指针计算*(*string)(unsafe.Pointer(user)) = "李四"fmt.Printf("%+v\n", user)// 突破第二个私有变量,因为是第二个成员字段,需要偏移一个字符串占用的长度即 16 个字节*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(user)) + uintptr(16))) = 1fmt.Printf("%+v\n", user)
}

当然我们可以更简单的获取到结构体变量的偏移量,这样就不需要自己计算了:
type Person struct {Name stringAge intHeight float64
}func TestUnSafeOffSet(t *testing.T) {nameOffset := unsafe.Offsetof(Person{}.Name)ageOffset := unsafe.Offsetof(Person{}.Age)heightOffset := unsafe.Offsetof(Person{}.Height)t.Log(nameOffset, ageOffset, heightOffset) // 输出字段的偏移量
}

实现 []byte 和字符串的零拷贝转换
通过查看源码,可以发现 slice 切片类型和 string 字符串类型具有类似的结构。
// runtime/slice.go
type slice struct {array unsafe.Pointer // 底层数组指针,真正存放数据的地方len int // 切片长度,通过 len(slice) 返回cap int // 切片容量,通过 cap(slice) 返回
}// runtime/string.go
type stringStruct struct {str unsafe.Pointer // 底层数组指针len int // 字符串长度,可以通过 len(string) 返回
}
看到这里,你是不是发现很神奇,这两个数据结构底层实现基本相同,而 slice 只是多了一个cap 字段。可以得出结论:slice 和 string 在内存布局上是对齐的,我们可以直接通过 unsafe 包进行转换,而不需要申请额外的内存空间。
代码实现
func StringToBytes(str string) []byte {var b []byte// 切片的底层数组、len字段,指向字符串的底层数组,len字段*(*string)(unsafe.Pointer(&b)) = str// 切片的 cap 字段赋值为 len(str) 的长度,切片的指针、len 字段各占8个字节,直接偏移16个字节*(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&b)) + 2*uintptr(8))) = len(str)return b
}func BytesToString(data []byte) string {// 直接转换return *(*string)(unsafe.Pointer(&data))
}func TestStringAndBytesConvert(t *testing.T) {str := "hello"b := StringToBytes(str)t.Log(reflect.TypeOf(b), b)// 此时 b 已经是切片类型,我们再将它转换为string类型s := BytesToString(b)t.Log(reflect.TypeOf(s), s)
}

符合预期。
原子类型操作
我们会用到 golang 内置 package 中的 atomic 原子操作,它提供了指针的原子操作,通常用在并发读写一块共享缓存时,保证线程安全。
我们在写数据的时候写在另外一块空间,完全写完之后,我们使用原子操作把读的指针和写的指针指向我们新写入的空间,保证下次再读的时候就是新写好的内容了。指针的切换要具有线程安全的特性。
func TestAtomic(t *testing.T) {var shareBufPtr unsafe.Pointer// 写方法writeDataFn := func() {data := []int{}for i := 0; i < 9; i++ {data = append(data, i)}// 使用原子操作将data的指针指向shareBufPtratomic.StorePointer(&shareBufPtr, unsafe.Pointer(&data))}// 读方法readDataFn := func() {data := atomic.LoadPointer(&shareBufPtr) // 使用原子操作读取shareBufPtrfmt.Println(data, *(*[]int)(data)) // 打印shareBufPtr中的数据}var wg sync.WaitGroupwriteDataFn()// 启动3个读协程,3个写协程,每个协程执行3次读/写操作for i := 0; i < 3; i++ {wg.Add(1)go func() {for i := 0; i < 3; i++ {writeDataFn()time.Sleep(time.Microsecond * 100)}wg.Done()}()wg.Add(1)go func() {for i := 0; i < 3; i++ {readDataFn()time.Sleep(time.Microsecond * 100)}wg.Done()}()}wg.Wait()
}

使用 atomic + unsafe 来实现共享 buffer 安全的读写。
总结
通过 unsafe 包,我们可以绕过 golang 编译器的检查,直接操作地址,实现一些高效的操作。但正如 golang 官方给它的命名一样,它是不安全的,滥用的话可能会导致程序意外的崩溃。关于 unsafe 包,我们应该更关注于它的用法,生产环境不建议使用!!!
- 笔记整理自极客时间视频教程:Go语言从入门到实战
- UnSafe部分内容参考:go unsafe包使用指南
相关文章:
【Go语言从入门到实战】反射编程、Unsafe篇
反射编程 reflect.TypeOf vs reflect.ValueOf func TestTypeAndValue(t *testing.T) {var a int64 10t.Log(reflect.TypeOf(a), reflect.ValueOf(a))t.Log(reflect.ValueOf(a).Type()) }判断类型 - Kind() 当我们需要对反射回来的类型做判断时,Go 语言内置了一个…...
vue实现对话框指定某个对话内容的滚动到指定位置(滚动到可视区域的中间位置)
1、使用el-scrollbar实现定位滚动(elementui组件库) 如何滚动:参考链接 比如说指定某条对话内容滚动到可视区域的中间 html结构: <div class"chat-list" id"chat-list"><el-scrollbar ref"scro…...
【RTP】2:RtpPacket、RtpPacketToSend 创建、修改的简要分析
【RTP】1: RTPSenderAudio::SendAudio继续对如何做修改,比如修改扩展 做分析。查找扩展 一个已知的已经在packet中存在的扩展bool RtpPacket::IsExtensionReserved(ExtensionType type) const {uint8_t id = extensions_.GetId(type);...
汽车租聘管理与推荐系统Python+Django网页界面+协同过滤推荐算法
一、介绍 汽车租聘管理与推荐系统。本系统使用Python作为主要编程语言,前端采用HTML、CSS、BootStrap等技术搭建前端界面,后端采用Django框架处理用户的请求。创新点:使用协同过滤推荐算法实现对当前用户个性化推荐。 其主要功能如下&#x…...
qt pdf 模块简介
文章目录 1. 技术平台2. Qt pdf 模块3. cmake 使用模块4. 许可证5. 简单示例5.1 CMakeLists.txt5.2 main.cpp 6. 总结 1. 技术平台 项目说明OSwin10 x64Qt6.6compilermsvc2022构建工具cmake 2. Qt pdf 模块 Qt PDF模块包含用于呈现PDF文档的类和函数。 QPdfDocument 类加载P…...
Spring Boot WebSocket 客户端
介绍 WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,它可以提供实时的、双向的数据传输。Spring Boot 提供了对 WebSocket 的支持,我们可以使用 Spring Boot WebSocket 客户端来连接到 WebSocket 服务器,并进行实时通信。 本文将…...
第五题-kotori和素因子【第六届传智杯程序设计挑战赛解题分析详解复盘】(JavaPythonC++实现)
🚀 欢迎来到 ACM 算法题库专栏 🚀 在ACM算法题库专栏,热情推崇算法之美,精心整理了各类比赛题目的详细解法,包括但不限于ICPC、CCPC、蓝桥杯、LeetCode周赛、传智杯等等。无论您是刚刚踏入算法领域,还是经验丰富的竞赛选手,这里都是提升技能和知识的理想之地。 ✨ 经典…...
【服务器能干什么】二十分钟搭建一个属于自己的 RSS 服务
如果大家不想自己捣鼓,只是想尝尝鲜,可以在下面留言,我后台帮大家开几个账号玩一玩。 哔哩哔哩【高清版本可以点击去吐槽到 B 站观看】:【VPS服务器到底能干啥】信息爆炸的年代,如何甄别出优质的内容?你可能需要自建一个RSS服务!_哔哩哔哩_bilibili 前言 RSS 服务 市…...
热门免费api接口:含核验API,物流api,短信api,天气api。。。
热门免费api接口:含核验API,物流api,短信api,天气api。。。 银行卡二要素:检测输入的姓名、银行卡号是否一致。毫秒级响应、直联保障,支持全国所有银联卡。银行卡三要素:检测输入的姓名、身份证号码、银行卡号是否一致。毫秒级响…...
基于AC6969的蓝牙控制RGB彩灯
程序的实现思路:单片机与手机app之间通过蓝牙实现通讯,通过点击屏幕上的对应色块然后app会把对应的RGB值发送到单片机。然后单片机会对数据进行解析然后把数字量转换为模拟量,然后通过PWM控制IO口输出不同的电压以此来达到控制RGB灯 RGB彩灯原…...
【C++高阶(五)】哈希思想--哈希表哈希桶
💓博主CSDN主页:杭电码农-NEO💓 ⏩专栏分类:C从入门到精通⏪ 🚚代码仓库:NEO的学习日记🚚 🌹关注我🫵带你学习C 🔝🔝 哈希结构 1. 前言2. unordered系列容器3. 哈希概…...
45、Flink 的指标体系介绍及验证(1)-指标类型及指标实现示例
Flink 系列文章 1、Flink 部署、概念介绍、source、transformation、sink使用示例、四大基石介绍和示例等系列综合文章链接 13、Flink 的table api与sql的基本概念、通用api介绍及入门示例 14、Flink 的table api与sql之数据类型: 内置数据类型以及它们的属性 15、Flink 的ta…...
SAP创建ODATA服务-Structure
SAP创建ODATA服务-Structure 1、创建数据字典 进入se11创建透明表ZRICO_USR,并创建对应字段 2、创建OData service 首先创建Gateway service project,事务码:SEGW,点击Create Project 按钮 Gateway service Project分四个部分:…...
【开源】基于JAVA的车险自助理赔系统
项目编号: S 018 ,文末获取源码。 \color{red}{项目编号:S018,文末获取源码。} 项目编号:S018,文末获取源码。 目录 一、摘要1.1 项目介绍1.2 项目录屏 二、功能模块2.1 数据中心模块2.2 角色管理模块2.3 车…...
单例模式-C++实现
目录 饿汉式懒汉式双检查锁,线程安全的版本什么是reorder?解决内存读写reorder不安全方法代码解释懒汉式的优缺点 单例模式是一种设计模式,用于确保一个类只有一个实例,并提供一个全局的访问点来获取该实例。它常用于需要在整个应…...
一种模板类实现和声明分开在生成的.a文件被使用时出现undefined reference时的一种解决方法
一种模板类实现和声明分开在生成的.a文件被使用时出现undefined reference时的一种解决方法 模板类头文件格式如下: test.h // test.h namespace test { namespace _testspace { class base { public: base(); ~base(); };template<bool T> class base_impl…...
js用到的算法
1.对象数组中,对象中有对象,数组根据对象中的对象打平 [{indexValueMap: { 68443: 0, 68457: 0 },rowName1: 固定收益类,rowName2: 交易类,rowName3: 次级},{indexValueMap: { 68443: 0, 68457: 0 },rowName1: 固定收益类,rowName2: 交易类,rowName3: 中…...
【科技素养】蓝桥杯STEMA 科技素养组模拟练习试卷9
1、商标也属于知识产权的一种。一个商标在注册之后,将会在()的时间受到保护 A、20 年内 B、50 年内 C、直至注册人去世 D、10 年内 答案:D 2、人类史上第一位进入太空的宇航员是(),他/她是…...
如何使用抖音直播调试入口扫码进行调试
使用抖音直播调试入口扫码进行调试的步骤如下: 确保你已经安装了抖音调试助手。打开调试助手,并在主界面点击“连接”按钮。在连接向导页面,根据提示连接你的抖音直播间。请确保你已经获取了直播间的token和scheme。连接成功后,你…...
AI智能人机对话小程序系统源码 附带完整的搭建教程
移动互联网的普及和快速发展,小程序已经成为了一种非常流行的应用形态。小程序具有即用即走、轻量级的特点,非常适合用于提供各种便捷服务。下面罗峰来给大家分享一款AI智能人机对话小程序系统源码,带有完整的搭建教程。 以下是部分代码示例…...
【算法训练营Day07】字符串part1
文章目录 反转字符串反转字符串II替换数字 反转字符串 题目链接:344. 反转字符串 双指针法,两个指针的元素直接调转即可 class Solution {public void reverseString(char[] s) {int head 0;int end s.length - 1;while(head < end) {char temp …...
【2025年】解决Burpsuite抓不到https包的问题
环境:windows11 burpsuite:2025.5 在抓取https网站时,burpsuite抓取不到https数据包,只显示: 解决该问题只需如下三个步骤: 1、浏览器中访问 http://burp 2、下载 CA certificate 证书 3、在设置--隐私与安全--…...
[Java恶补day16] 238.除自身以外数组的乘积
给你一个整数数组 nums,返回 数组 answer ,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积 。 题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。 请 不要使用除法,且在 O(n) 时间复杂度…...
基于matlab策略迭代和值迭代法的动态规划
经典的基于策略迭代和值迭代法的动态规划matlab代码,实现机器人的最优运输 Dynamic-Programming-master/Environment.pdf , 104724 Dynamic-Programming-master/README.md , 506 Dynamic-Programming-master/generalizedPolicyIteration.m , 1970 Dynamic-Programm…...
浪潮交换机配置track检测实现高速公路收费网络主备切换NQA
浪潮交换机track配置 项目背景高速网络拓扑网络情况分析通信线路收费网络路由 收费汇聚交换机相应配置收费汇聚track配置 项目背景 在实施省内一条高速公路时遇到的需求,本次涉及的主要是收费汇聚交换机的配置,浪潮网络设备在高速项目很少,通…...
QT3D学习笔记——圆台、圆锥
类名作用Qt3DWindow3D渲染窗口容器QEntity场景中的实体(对象或容器)QCamera控制观察视角QPointLight点光源QConeMesh圆锥几何网格QTransform控制实体的位置/旋转/缩放QPhongMaterialPhong光照材质(定义颜色、反光等)QFirstPersonC…...
iview框架主题色的应用
1.下载 less要使用3.0.0以下的版本 npm install less2.7.3 npm install less-loader4.0.52./src/config/theme.js文件 module.exports {yellow: {theme-color: #FDCE04},blue: {theme-color: #547CE7} }在sass中使用theme配置的颜色主题,无需引入,直接可…...
k8s从入门到放弃之HPA控制器
k8s从入门到放弃之HPA控制器 Kubernetes中的Horizontal Pod Autoscaler (HPA)控制器是一种用于自动扩展部署、副本集或复制控制器中Pod数量的机制。它可以根据观察到的CPU利用率(或其他自定义指标)来调整这些对象的规模,从而帮助应用程序在负…...
AxureRP-Pro-Beta-Setup_114413.exe (6.0.0.2887)
Name:3ddown Serial:FiCGEezgdGoYILo8U/2MFyCWj0jZoJc/sziRRj2/ENvtEq7w1RH97k5MWctqVHA 注册用户名:Axure 序列号:8t3Yk/zu4cX601/seX6wBZgYRVj/lkC2PICCdO4sFKCCLx8mcCnccoylVb40lP...
【实施指南】Android客户端HTTPS双向认证实施指南
🔐 一、所需准备材料 证书文件(6类核心文件) 类型 格式 作用 Android端要求 CA根证书 .crt/.pem 验证服务器/客户端证书合法性 需预置到Android信任库 服务器证书 .crt 服务器身份证明 客户端需持有以验证服务器 客户端证书 .crt 客户端身份…...
