Go語言數組和切片的原理


數組和切片是 Go 語言中常見的數據結構,很多剛剛使用 Go 的開發者往往會混淆這兩個概念,數組作為最常見的集合在編程語言中是非常重要的,除了數組之外,Go 語言引入了另一個概念 — 切片,切片與數組有一些類似,但是它們的不同之處導致使用上會產生巨大的差別。

這里我們將從 Go 語言 編譯期間 的工作和運行時來介紹數組以及切片的底層實現原理,其中會包括數組的初始化以及訪問、切片的結構和常見的基本操作。

數組

數組是由相同類型元素的集合組成的數據結構,計算機會為數組分配一塊連續的內存來保存數組中的元素,我們可以利用數組中元素的索引快速訪問元素對應的存儲地址,常見的數組大多都是一維的線性數組,而多維數組在數值和圖形計算領域卻有比較常見的應用。

數組作為一種數據類型,一般情況下由兩部分組成,其中一部分表示了數組中存儲的元素類型,另一部分表示數組最大能夠存儲的元素個數,Go 語言的數組類型一般是這樣的:

[10]int
[200]interface{}

Go 語言中數組的大小在初始化之后就無法改變,數組存儲元素的類型相同,但是大小不同的數組類型在 Go 語言看來也是完全不同的,只有兩個條件都相同才是同一個類型。

func NewArray(elem *Type, bound int64) *Type {
	if bound < 0 {
		Fatalf("NewArray: invalid bound %v", bound)
	}
	t := New(TARRAY)
	t.Extra = &Array{Elem: elem, Bound: bound}
	t.SetNotInHeap(elem.NotInHeap())
	return t
}

編譯期間的數組類型 Array 就包含兩個結構,一個是元素類型 Elem,另一個是數組的大小上限 Bound,這兩個字段構成了數組類型,而當前數組是否應該在堆棧中初始化也在編譯期間就確定了。

創建

Go 語言中的數組有兩種不同的創建方式,一種是我們顯式指定數組的大小,另一種是編譯器通過源代碼自行推斷數組的大小:

arr1 := [3]int{1, 2, 3}
arr2 := [...]int{1, 2, 3}

后一種聲明方式在編譯期間就會被『轉換』成為前一種,下面我們先來介紹數組大小的編譯期推導過程。

上限推導

這兩種不同的方式會導致編譯器做出不同的處理,如果我們使用第一種方式 [10]T,那么變量的類型在編譯進行到 類型檢查 階段就會被推斷出來,在這時編譯器會使用 NewArray 創建包含數組大小的 Array 類型,而如果使用 [...]T 的方式,雖然在這一步也會創建一個 Array 類型 Array{Elem: elem, Bound: -1},但是其中的數組大小上限會是 -1 的結構,這意味着還需要后面的 typecheckcomplit 函數推導該數組的大小:

func typecheckcomplit(n *Node) (res *Node) {
	// ...

	switch t.Etype {
	case TARRAY, TSLICE:
		var length, i int64
		nl := n.List.Slice()
		for i2, l := range nl {
			i++
			if i > length {
				length = i
			}
		}

		if t.IsDDDArray() {
			t.SetNumElem(length)
		}
	}
}

這個刪減后的 typecheckcomplit 函數通過遍歷元素來推導當前數組的長度,我們能看出 [...]T 類型的聲明不是在運行時被推導的,它會在類型檢查期間就被推斷出正確的數組大小。

語句轉換

雖然 [...]T{1, 2, 3}[3]T{1, 2, 3} 在運行時是完全等價的,但是這種簡短的初始化方式也只是 Go 語言為我們提供的一種語法糖,對於一個由字面量組成的數組,根據數組元素數量的不同,編譯器會在負責初始化字面量的 anylit 函數中做兩種不同的優化:

func anylit(n *Node, var_ *Node, init *Nodes) {
	t := n.Type
	switch n.Op {
	case OSTRUCTLIT, OARRAYLIT:
		if n.List.Len() > 4 {
			vstat := staticname(t)
			vstat.Name.SetReadonly(true)

			fixedlit(inNonInitFunction, initKindStatic, n, vstat, init)

			a := nod(OAS, var_, vstat)
			a = typecheck(a, ctxStmt)
			a = walkexpr(a, init)
			init.Append(a)
			break
		}


		fixedlit(inInitFunction, initKindLocalCode, n, var_, init)
	// ...
	}
}
  1. 當元素數量小於或者等於 4 個時,會直接將數組中的元素放置在棧上;
  2. 當元素數量大於 4 個時,會將數組中的元素放置到靜態區並在運行時取出;

當數組的元素小於或者等於四個時,fixedlit 會負責在函數編譯之前將批了語法糖外衣的代碼轉換成原有的樣子:

func fixedlit(ctxt initContext, kind initKind, n *Node, var_ *Node, init *Nodes) {
	var splitnode func(*Node) (a *Node, value *Node)
	// ...

	for _, r := range n.List.Slice() {
		a, value := splitnode(r)

		a = nod(OAS, a, value)
		a = typecheck(a, ctxStmt)
		switch kind {
		case initKindStatic:
			genAsStatic(a)
		case initKindLocalCode:
			a = orderStmtInPlace(a, map[string][]*Node{})
			a = walkstmt(a)
			init.Append(a)
		default:
			Fatalf("fixedlit: bad kind %d", kind)
		}
	}
}

由於傳入的類型是 initKindLocalCode,上述代碼會將原有的初始化語法拆分成一個聲明變量的語句和 N 個用於賦值的語句:

var arr [3]int
arr[0] = 1
arr[1] = 2
arr[2] = 3

但是如果當前數組的元素大於 4 個時,anylit 方法會先獲取一個唯一的 staticname,然后調用 fixedlit 函數在靜態存儲區初始化數組中的元素並將臨時變量賦值給當前的數組:

func fixedlit(ctxt initContext, kind initKind, n *Node, var_ *Node, init *Nodes) {
	var splitnode func(*Node) (a *Node, value *Node)
	// ...

	for _, r := range n.List.Slice() {
		a, value := splitnode(r)

		setlineno(value)
		a = nod(OAS, a, value)
		a = typecheck(a, ctxStmt)
		switch kind {
		case initKindStatic:
			genAsStatic(a)
		default:
			Fatalf("fixedlit: bad kind %d", kind)
		}

	}
}

假設,我們在代碼中初始化 []int{1, 2, 3, 4, 5} 數組,那么我們可以將上述過程理解成以下的偽代碼:

var arr [5]int
statictmp_0[0] = 1
statictmp_0[1] = 2
statictmp_0[2] = 3
statictmp_0[3] = 4
statictmp_0[4] = 5
arr = statictmp_0

總結起來,如果數組中元素的個數小於或者等於 4 個,那么所有的變量會直接在棧上初始化,如果數組元素大於 4 個,變量就會在靜態存儲區初始化然后拷貝到棧上,這些轉換后的代碼才會繼續進入 中間代碼生成機器碼生成 兩個階段,最后生成可以執行的二進制文件。

訪問和賦值

無論是在棧上還是靜態存儲區,數組在內存中其實就是一連串的內存空間,表示數組的方法就是一個指向數組開頭的指針,這一片內存空間不知道自己存儲的是什么變量:

數組訪問越界的判斷也都是在編譯期間由靜態類型檢查完成的,typecheck1 函數會對訪問的數組索引進行驗證:

func typecheck1(n *Node, top int) (res *Node) {
	switch n.Op {
	case OINDEX:
		ok |= ctxExpr
		l := n.Left
		r := n.Right
		t := l.Type
		switch t.Etype {
		case TSTRING, TARRAY, TSLICE:
			why := "string"
			if t.IsArray() {
				why = "array"
			} else if t.IsSlice() {
				why = "slice"
			}

			if n.Right.Type != nil && !n.Right.Type.IsInteger() {
				yyerror("non-integer %s index %v", why, n.Right)
				break
			}

			if !n.Bounded() && Isconst(n.Right, CTINT) {
				x := n.Right.Int64()
				if x < 0 {
					yyerror("invalid %s index %v (index must be non-negative)", why, n.Right)
				} else if t.IsArray() && x >= t.NumElem() {
					yyerror("invalid array index %v (out of bounds for %d-element array)", n.Right, t.NumElem())
				} else if Isconst(n.Left, CTSTR) && x >= int64(len(n.Left.Val().U.(string))) {
					yyerror("invalid string index %v (out of bounds for %d-byte string)", n.Right, len(n.Left.Val().U.(string)))
				}
			}
		}
	//...
	}
}

無論是編譯器還是字符串,它們的越界錯誤都會在編譯期間發現,但是數組訪問操作 OINDEX 會在編譯期間被轉換成兩個 SSA 指令:

PtrIndex <t> ptr idx
Load <t> ptr mem

編譯器會先獲取數組的內存地址和訪問的下標,然后利用 PtrIndex 計算出目標元素的地址,再使用 Load 操作將指針中的元素加載到內存中。

數組的賦值和更新操作 a[i] = 2 也會生成 SSA 期間就計算出數組當前元素的內存地址,然后修改當前內存地址的內容,其實會被轉換成如下所示的 SSA 操作:

LocalAddr {sym} base _
PtrIndex <t> ptr idx
Store {t} ptr val mem

在這個過程中會確實能夠目標數組的地址,再通過 PtrIndex 獲取目標元素的地址,最后將數據存入地址中,從這里我們可以看出無論是數組的尋址還是賦值都是在編譯階段完成的,沒有運行時的參與。

切片

數組其實在 Go 語言中沒有那么常用,更加常見的數據結構其實是切片,切片其實就是動態數組,它的長度並不固定,可以追加元素並會在切片容量不足時進行擴容。

在 Golang 中,切片類型的聲明與數組有一些相似,由於切片是『動態的』,它的長度並不固定,所以聲明類型時只需要指定切片中的元素類型:

[]int
[]interface{}

從這里的定義我們其實也能推測出,切片在編譯期間的類型應該只會包含切片中的元素類型,NewSlice 就是編譯期間用於創建 Slice 類型的函數:

func NewSlice(elem *Type) *Type {
	if t := elem.Cache.slice; t != nil {
		if t.Elem() != elem {
			Fatalf("elem mismatch")
		}
		return t
	}

	t := New(TSLICE)
	t.Extra = Slice{Elem: elem}
	elem.Cache.slice = t
	return t
}

我們可以看到上述方法返回的類型 TSLICEExtra 字段是一個只包含切片內元素類型的 Slice{Elem: elem}結構,也就是說切片內元素的類型是在編譯期間確定的。

結構

編譯期間的切片其實就是一個 Slice 類型,但是在運行時切片其實由如下的 SliceHeader 結構體表示,其中 Data 字段是一個指向數組的指針,Len 表示當前切片的長度,而 Cap 表示當前切片的容量,也就是 Data 數組的大小:

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

Data 作為一個指針指向的數組其實就是一片連續的內存空間,這片內存空間可以用於存儲切片中保存的全部元素,數組其實就是一片連續的內存空間,數組中的元素只是邏輯上的概念,底層存儲其實都是連續的,所以我們可以將切片理解成一片連續的內存空間加上長度與容量標識。

與數組不同,數組中大小、其中的元素還有對數組的訪問和更新在編譯期間就已經全部轉換成了直接對內存的操作,但是切片是運行時才會確定的結構,所有的操作還需要依賴 Go 語言的運行時來完成,我們接下來就會介紹切片的一些常見操作的實現原理。

初始化

首先需要介紹的就是切片的創建過程,Go 語言中的切片總共有兩種初始化的方式,一種是使用字面量初始化新的切片,另一種是使用關鍵字 make 創建切片:

slice := []int{1, 2, 3}
slice := make([]int, 10)

字面量

我們先來介紹如何使用字面量的方式創建新的切片結構,[]int{1, 2, 3} 其實會在編譯期間由 slicelit 轉換成如下所示的代碼:

var vstat [3]int
vstat[0] = 1
vstat[1] = 2
vstat[2] = 3
var vauto *[3]int = new([3]int)
*vauto = vstat
slice := vauto[:]
  1. 根據切片中的元素數量對底層數組的大小進行推斷並創建一個數組;
  2. 將這些字面量元素存儲到初始化的數組中;
  3. 創建一個同樣指向 [3]int 類型的數組指針;
  4. 將靜態存儲區的數組 vstat 賦值給 vauto 指針所在的地址;
  5. 通過 [:] 操作獲取一個底層使用 vauto 的切片;

[:] 以及類似的操作 [:10] 其實都會在 SSA 代碼生成 階段被轉換成 OpSliceMake 操作,這個操作會接受四個參數創建一個新的切片,切片元素類型、數組指針、切片大小和容量。

關鍵字

如果使用字面量的方式創建切片,大部分的工作就都會在編譯期間完成,但是當我們使用 make 關鍵字創建切片時,在 類型檢查 期間會檢查 make『函數』的參數,調用方必須傳入一個切片的大小以及可選的容量:

func typecheck1(n *Node, top int) (res *Node) {
	switch n.Op {
	// ...
	case OMAKE:
		args := n.List.Slice()

		i := 1
		switch t.Etype {
		case TSLICE:
			if i >= len(args) {
				yyerror("missing len argument to make(%v)", t)
				return n
			}

			l = args[i]
			i++
			var r *Node
			if i < len(args) {
				r = args[i]
			}

			// ...
			if Isconst(l, CTINT) && r != nil && Isconst(r, CTINT) && l.Val().U.(*Mpint).Cmp(r.Val().U.(*Mpint)) > 0 {
				yyerror("len larger than cap in make(%v)", t)
				return n
			}

			n.Left = l
			n.Right = r
			n.Op = OMAKESLICE
		}
	// ...
	}
}

make 參數的檢查都是在 typecheck1 函數中完成的,它不僅會檢查 len,而且會保證傳入的容量 cap 一定大於或者等於 len;隨后的中間代碼生成階段會把這里的 OMAKESLICE 類型的操作都轉換成如下所示的函數調用:

makeslice(type, len, cap)

當切片的容量和大小不能使用 int 來表示時,就會實現 makeslice64 處理容量和大小更大的切片,無論是 makeslice 還是 makeslice64,這兩個方法都是在結構逃逸到堆上初始化時才需要調用的,如果當前的切片不會發生逃逸並且切片非常小的時候,make([]int, 3, 4) 才會被轉換成如下所示的代碼:

var arr [4]int
n := arr[:3]

在這時,數組的初始化和 [:3] 操作就都會在編譯階段完成大部分的工作,前者會在靜態存儲區被創建,后者會被轉換成 OpSliceMake 操作。

接下來,我們回到用於創建切片的 makeslice 函數,這個函數的實現其實非常簡單:

func makeslice(et *_type, len, cap int) unsafe.Pointer {
	mem, overflow := math.MulUintptr(et.size, uintptr(cap))
	if overflow || mem > maxAlloc || len < 0 || len > cap {
		mem, overflow := math.MulUintptr(et.size, uintptr(len))
		if overflow || mem > maxAlloc || len < 0 {
			panicmakeslicelen()
		}
		panicmakeslicecap()
	}

	return mallocgc(mem, et, true)
}

上述代碼的主要工作就是用切片中元素大小和切片容量相乘計算出切片占用的內存空間,如果內存空間的大小發生了溢出、申請的內存大於最大可分配的內存、傳入的長度小於 0 或者長度大於容量,那么就會直接報錯,當然大多數的錯誤都會在編譯期間就檢查出來,mallocgc 就是用於申請內存的函數,這個函數的實現還是比較復雜,如果遇到了比較小的對象會直接初始化在 Golang 調度器里面的 P 結構中,而大於 32KB 的一些對象會在堆上初始化。

初始化后會返回指向這片內存空間的指針,在之前版本的 Go 語言中,指針會和長度與容量一起被合成一個 slice 結構返回到 makeslice 的調用方,但是從 020a18c5 這個 commit 開始,構建結構體 SliceHeader的工作就都由上層在類型檢查期間完成了:

func typecheck1(n *Node, top int) (res *Node) {
	switch n.Op {
	// ...
	case OSLICEHEADER:
	switch 
		t := n.Type
		n.Left = typecheck(n.Left, ctxExpr)
		l := typecheck(n.List.First(), ctxExpr)
		c := typecheck(n.List.Second(), ctxExpr)
		l = defaultlit(l, types.Types[TINT])
		c = defaultlit(c, types.Types[TINT])

		n.List.SetFirst(l)
		n.List.SetSecond(c)
	// ...
	}
}

OSLICEHEADER 操作會創建一個如下所示的結構體,其中包含數組指針、切片長度和容量,它是切片在運行時的表示:

type SliceHeader struct {
	Data uintptr
	Len  int
	Cap  int
}

正是因為大多數對切片類型的操作並不需要直接操作原 slice 結構體,所以 SliceHeader 的引入能夠減少切片初始化時的開銷,這個改動能夠減少 0.2% 的 Go 語言包大小並且能夠減少 92 個 panicindex 的調用。

訪問

對切片常見的操作就是獲取它的長度或者容量,這兩個不同的函數 lencap 其實被 Go 語言的編譯器看成是兩種特殊的操作 OLENOCAP,它們會在 SSA 生成階段 被轉換成 OpSliceLenOpSliceCap 操作:

func (s *state) expr(n *Node) *ssa.Value {
	switch n.Op {
	case OLEN, OCAP:
		switch {
		case n.Left.Type.IsSlice():
			op := ssa.OpSliceLen
			if n.Op == OCAP {
				op = ssa.OpSliceCap
			}
			return s.newValue1(op, types.Types[TINT], s.expr(n.Left))
		// ...
		}
	// ...
	}
}

除了獲取切片的長度和容量之外,訪問切片中元素使用的 OINDEX 操作也都在 SSA 中間代碼生成期間就轉換成對地址的獲取操作:

func (s *state) expr(n *Node) *ssa.Value {
	switch n.Op {
	case OINDEX:
		switch {
		case n.Left.Type.IsSlice():
			p := s.addr(n, false)
			return s.load(n.Left.Type.Elem(), p)
		// ...
		}
	// ...
	}
}

切片的操作基本都是在編譯期間完成的,除了訪問切片的長度、容量或者其中的元素之外,使用 range 遍歷切片時也是在編譯期間被轉換成了形式更簡單的代碼,我們會在后面的章節中介紹 range 關鍵字的實現原理。

追加

向切片中追加元素應該是最常見的切片操作,在 Go 語言中我們會使用 append 關鍵字向切片中追加元素,追加元素會根據是否 inplace 在中間代碼生成階段轉換成以下的兩種不同流程,如果 append 之后的切片不需要賦值回原有的變量,也就是如 append(slice, 1, 2, 3) 所示的表達式會被轉換成如下的過程:

ptr, len, cap := slice
newlen := len + 3
if newlen > cap {
    ptr, len, cap = growslice(slice, newlen)
    newlen = len + 3
}
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3
return makeslice(ptr, newlen, cap)

我們會先對切片結構體進行解構獲取它的數組指針、大小和容量,如果新的切片大小大於容量,那么就會使用 growslice 對切片進行擴容並將新的元素依次加入切片並創建新的切片,但是 slice = apennd(slice, 1, 2, 3) 這種 inplace 的表達式就只會改變原來的 slice 變量:

a := &slice
ptr, len, cap := slice
newlen := len + 3
if uint(newlen) > uint(cap) {
   newptr, len, newcap = growslice(slice, newlen)
   vardef(a)
   *a.cap = newcap
   *a.ptr = newptr
}
newlen = len + 3
*a.len = newlen
*(ptr+len) = 1
*(ptr+len+1) = 2
*(ptr+len+2) = 3

上述兩段代碼的邏輯其實差不多,最大的區別在於最后的結果是不是賦值會原有的變量,不過從 inplace 的代碼可以看出 Go 語言對類似的過程進行了優化,所以我們並不需要擔心 append 會在數組容量足夠時導致發生切片的復制。

到這里我們已經了解了在切片容量足夠時如何向切片中追加元素,但是如果切片的容量不足時就會調用 growslice 為切片擴容:

func growslice(et *_type, old slice, cap int) slice {
	newcap := old.cap
	doublecap := newcap + newcap
	if cap > doublecap {
		newcap = cap
	} else {
		if old.len < 1024 {
			newcap = doublecap
		} else {
			for 0 < newcap && newcap < cap {
				newcap += newcap / 4
			}
			if newcap <= 0 {
				newcap = cap
			}
		}
	}

擴容其實就是需要為切片分配一塊新的內存空間,分配內存空間之前需要先確定新的切片容量,Go 語言根據切片的當前容量選擇不同的策略進行擴容:

  1. 如果期望容量大於當前容量的兩倍就會使用期望容量;
  2. 如果當前切片容量小於 1024 就會將容量翻倍;
  3. 如果當前切片容量大於 1024 就會每次增加 25% 的容量,直到新容量大於期望容量;

確定了切片的容量之后,我們就可以開始計算切片中新數組的內存占用了,計算的方法就是將目標容量和元素大小相乘:

	var overflow bool
	var lenmem, newlenmem, capmem uintptr
	switch {
	// ...
	default:
		lenmem = uintptr(old.len) * et.size
		newlenmem = uintptr(cap) * et.size
		capmem, overflow = math.MulUintptr(et.size, uintptr(newcap))
		capmem = roundupsize(capmem)
		newcap = int(capmem / et.size)
	}

	var p unsafe.Pointer
	if et.kind&kindNoPointers != 0 {
		p = mallocgc(capmem, nil, false)
		memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem)
	} else {
		p = mallocgc(capmem, et, true)
		if writeBarrier.enabled {
			bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem)
		}
	}
	memmove(p, old.array, lenmem)

	return slice{p, old.len, newcap}
}

如果當前切片中元素不是指針類型,那么就會調用 memclrNoHeapPointers 函數將超出當前長度的位置置空並在最后使用 memmove 將原數組內存中的內容拷貝到新申請的內存中, 不過無論是 memclrNoHeapPointers 還是 memmove 函數都使用目標機器上的匯編指令進行實現,例如 WebAssembly 使用如下的命令實現 memclrNoHeapPointers 函數:

TEXT runtime·memclrNoHeapPointers(SB), NOSPLIT, $0-16
	MOVD ptr+0(FP), R0
	MOVD n+8(FP), R1

loop:
	Loop
		Get R1
		I64Eqz
		If
			RET
		End

		Get R0
		I32WrapI64
		I64Const $0
		I64Store8 $0

		Get R0
		I64Const $1
		I64Add
		Set R0

		Get R1
		I64Const $1
		I64Sub
		Set R1

		Br loop
	End
	UNDEF

growslice 函數最終會返回一個新的 slice 結構,其中包含了新的數組指針、大小和容量,這個返回的三元組最終會改變原有的切片,幫助 append 完成元素追加的功能。

拷貝

切片的拷貝雖然不是一個常見的操作類型,但是卻是我們學習切片實現原理必須要談及的一個問題,當我們使用 copy(a, b) 的形式對切片進行拷貝時,編譯期間會被轉換成 slicecopy 函數:

func slicecopy(to, fm slice, width uintptr) int {
	if fm.len == 0 || to.len == 0 {
		return 0
	}

	n := fm.len
	if to.len < n {
		n = to.len
	}

	if width == 0 {
		return n
	}
	
	// ...

	size := uintptr(n) * width
	if size == 1 {
		*(*byte)(to.array) = *(*byte)(fm.array)
	} else {
		memmove(to.array, fm.array, size)
	}
	return n
}

上述函數的實現非常直接,它將切片中的全部元素通過 memmove 或者數組指針的方式將整塊內存中的內容拷貝到目標的內存區域:

相比於依次對元素進行拷貝,這種方式能夠提供更好的性能,但是需要注意的是,哪怕使用 memmove 對內存成塊進行拷貝,但是這個操作還是會占用非常多的資源,在大切片上執行拷貝操作時一定要注意性能影響。

總結

數組和切片是 Go 語言中重要的數據結構,所以了解它們的實現能夠幫助我們更好地理解這門語言,通過對它們實現的分析,我們知道了數組和切片的實現同時依賴編譯器和運行時兩部分。

數組的大多數操作在 編譯期間 都會轉換成對內存的直接讀寫;而切片的很多功能就都是在運行時實現的了,無論是初始化切片,還是對切片進行追加或擴容都需要運行時的支持,需要注意的是在遇到大切片擴容或者復制時可能會發生大規模的內存拷貝,一定要在使用時減少這種情況的發生避免對程序的性能造成影響。

轉載自:

Go 語言數組和切片的原理


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM