断断续续地在看 Go 语言的语法,看到空接口这块,感觉有些意思,记录一下。
空接口
Go 语言中空接口的表示方式是 interface{},它最大的作用就是可以存储任意类型的值,可以理解为 TypeScript 中的 any。
作为一个前端开发,Go 中这种表示任意类型的方式让我觉得有些怪异,但它确实是很有用的。比如我想存储一组用户信息,但具体多少信息是不确定的,我希望可以无限被扩展,那我可以这样写:
func main() {
userInfo := make(map[string]interface{}) // 信息的值是可以任意类型的
userInfo["name"] = "Tom"
userInfo["isAdmin"] = true
userInfo["age"] = 18
userInfo["hobby"] = []string{"reading", "running"}
}
如果不使用空接口,那么我可能需要定义一个结构体来存储这些信息:
type UserInfo struct {
name string
isAdmin bool
age int
hobby []string
}
func main() {
userInfo := UserInfo{
name: "Tom",
isAdmin: true,
age: 18,
hobby: []string{"reading", "running"},
}
}
这样每当我想要新增一个字段,就需要去结构体中添加一个字段类型,虽说加类型不算什么大问题,但无法做到添加未知类型的字段。所以空接口的作用在这个场景下就体现出来了。其实空接口在 Go 标准库的源码中也是被大量使用的。
类型断言
说到空接口,那不得不提下类型断言,一些被定义为空接口的值,如果想要访问其身上的特定方法时,就必须要用到类型断言。
还是上面的例子,如果想要访问 hobby 中的第一个值,我们的直觉应该是通过索引去访问,如:
func main() {
userInfo := make(map[string]interface{})
userInfo["hobby"] = []string{"reading", "running"}
fmt.Println(userInfo["hobby"][0]) // 通过索引访问
}
但这样写是错误的,会报错 invalid operation: cannot index userInfo["hobby"] (map index expression of type interface{})。
其实仔细想想也很正常,userInfo["hobby"] 可能是任意一种类型的值,它不一定具备索引访问的特性。所以这里就需要用到类型断言,必须先断言出它的具体类型,才可以去访问它的值。
不过 Go 中的类型断言写法,也是让我觉得有点怪异,写法是 n.(type),n 代表要断言的变量,type 代表断言的类型。如上我想要获取 hobby 中的第一个值,可以这样写:
v, ok := userInfo["hobby"].([]string)
if ok {
fmt.Println(v[0])
} else {
fmt.Println("no hobby")
}
这样写可能看着有些繁琐,但确实能提高程序的健壮性,作为初学者,我只能去适应接受。
日常开发中的并发请求,一般不会去特地控制,直接依次请求就好,或者使用 Promise.all() 包装一层,等待所有成功的结果。但是当并发请求的数量大起来后,如果不处理并发量,可能会造成较严重的后果。比如大文件上传的场景,一般大文件上传需要切片分传,如果文件较大,可能会被分成100个小块分别请求,那这种同时100条的并发请求,大概率会有以下几个问题:
浏览器会限制,首先浏览器会限制同一域名下的并发请求数量,100个请求是不会立刻并发执行的,会排队处理,可能导致部分请求等待较长时间才开始;
服务器压力,即使绕过了浏览器的并发请求数量,短时间内大量的请求也会给服务器带来较大压力;
网络拥堵,大量并发请求会占用较多的网络带宽,可能导致其他网络活动受到影响,影响用户体验。
所以我们需要对大并发请求做个控制,即限制每次并发的请求量,比如每次只请求5条,100条请求分20次完成。那么怎么实现这种效果呢?
我看好多人会用 Promise.all() 来处理,比如将100条异步请求分成20个数组,每个数组有5个请求,每次将一个请求数组传入 Promise.all()中,当这组数据请求完,再传入下一个数组,以此类推直至请求结束。这种做法在理论上确实可以解决并发问题,之所以是理论上,主要有两个问题:
Promise.all() 不能保证所有的请求结束后再给出结束状态,一旦有一个请求错误,会直接返回错误信息,不管后面的成员还在不在请求中。这样本质上还是无法准确控制同时并发量;
即使每次传入的一组请求,都是成功状态返回,或者使用Promise.allSettled,保证所有的请求结束后才返回状态。那么还是有效率问题,因为它需要等待这组数据全部请求完才能开启下一轮,如果这一组数据有一个接口需要10秒,那么这组数据请求就需要多等10秒,显然这问题是很大的。
所以最终还是需要我们自己手动来封装一个并发请求控制,主要实现两个功能:
可以灵活控制并发数量;
请求应该是队列形式,当一组请求中的某一条请求结束了,应自动加入下一条请求,以保证最大的效率。
最后,根据需求,参考网上资料,我简单封装了一个并发请求方法,代码如下:
function concurRequest(urls, maxNum) {
return new Promise((resolve) => {
if (urls.length === 0) {
resolve([])
}
// 请求的 url 索引
let index = 0
// 已完成请求的数量
let count = 0
// 请求到的结果,每项都是 promise
const result = []
// 请求函数
async function request() {
// 缓存原始的下标
const i = index
// 当前要请求的 url
const url = urls[index]
// 获取完当前的请求地址后,递增下标,为下一次请求准备
index++
try {
const res = await fetch(url)
result[i] = res
} catch (err) {
result[i] = err
} finally {
count++
// 如果已请求的数量等于总请求数量,那么就返回结果
if (count === urls.length) {
resolve(result)
}
if (index < urls.length) {
request()
}
}
}
// 第一次触发请求
for (let i = 0; i < Math.min(maxNum, urls.length); i++) {
request()
}
})
}
concurRequest 方法可以传入两个参数,接口请求路径数组urls和并发数量maxNum,方法会按照传入的最大请求数来控制同时并发量,当某一个请求结束后,会按照接口数组中的顺序自动请求下一条,直至结束。当然这只是一个范例,我们可以根据实际开发需求改动,比如可以传入一组定义好的请求方法,这样每个请求方法的内部都可以灵活控制。