golang拾遺:實現一個不可複製類型

来源:https://www.cnblogs.com/apocelipes/archive/2023/02/20/17137206.html
-Advertisement-
Play Games

這是golang拾遺系列的第六篇。這個系列主要用來記錄一些平時不常見的知識點,偶爾也會實現些有意思的小功能,比如這篇。 golang拾遺系列目錄: golang拾遺:指針和介面 golang拾遺:為什麼我們需要泛型 golang拾遺:嵌入類型 golang拾遺:內置函數len的小知識 golang拾 ...


這是golang拾遺系列的第六篇。這個系列主要用來記錄一些平時不常見的知識點,偶爾也會實現些有意思的小功能,比如這篇。

golang拾遺系列目錄:

  1. golang拾遺:指針和介面
  2. golang拾遺:為什麼我們需要泛型
  3. golang拾遺:嵌入類型
  4. golang拾遺:內置函數len的小知識
  5. golang拾遺:自定義類型和方法集
  6. golang拾遺:實現一個不可複製類型

在本篇中我們將實現一個無法被覆制的類型,順便加深對引用類型、值傳遞以及指針的理解。

閱讀本文前需要你擁有一定的前置知識,包括掌握基本的golang語法,能理解並應用介面,對sync包下的內容有粗略的瞭解。如果你準備好了,就可以接著往下看了。

本文索引

如何複製一個對象

不考慮IDE提供的代碼分析和go vet之類的靜態分析工具,golang里幾乎所有的類型都能被覆制。

// 基本標量類型和指針
var i int = 1
iCopy := i
str := "string"
strCopy := str

pointer := &i
pointerCopy := pointer
iCopy2 := *pointer // 解引用後進行複製

// 結構體和數組
arr := [...]int{1, 2, 3}
arrCopy := arr

type Obj struct {
    i int
}
obj := Obj{}
objCopy := obj

除了這些,golang還有函數和引用類型(slice、map、interface),這些類型也可以被覆制,但稍有不同:

func f() {...}
f1 := f
f2 := f1

fmt.Println(f1, f2) // 0xabcdef 0xabcdef 列印出來的值是一樣的
fmt.Println(&f1 == &f2) // false 雖然值一樣,但確實是兩個不同的變數

這裡並沒有真正複製處三份f的代碼,f1f2均指向f,f的代碼始終只會有一份。map、slice和interface與之類似:

m := map[int]string{
    0: "a",
    1: "b",
}
mCopy := m // 兩者引用同樣的數據
mCopy[0] := "unknown"
m[0] == "unknown" // True
// slice的複製和map相同

interface是比較另類的,它的行為要分兩種情況:

s := "string"
var i1 any = s
var i2 any = s
// 當把非指針和介面類型的值賦值給interface,會導致原來的對象被覆制一份

s := "string"
var i1 any = s
var i2 any = i2
// 當把介面賦值給介面,底層引用的數據不會被覆制,i1會複製s,i2此時和i1共有一個s的副本

ss := "string but pass by pointer"
var i3 any = &ss
var i4 any = i3
// i3和i4均引用ss,此時ss沒有被覆制,但指向ss的指針的值被覆制了兩次

上面的結果會一定程度上被編譯優化干擾,比如少數情況下編譯器可以確認賦值給介面的值從來沒被修改並且生命周期不比源對象長,則可能不會進行複製。

所以這裡有個小提示:如果要賦值給介面的數據比較大,那麼最好以指針的形式賦值給介面,複製指針比複製大量的數據更高效。

為什麼要禁止複製

從上一節可以看到,允許複製時會在某些情況下“闖禍”。比如:

  1. 淺拷貝的問題很容易出現,比如例子里的map和slice的淺拷貝問題,這可能會導致數據被意外修改
  2. 意外複製了大量數據,導致性能問題
  3. 在需要共用狀態的地方錯誤的使用了副本,導致狀態不一致從而產生嚴重問題,比如sync.Mutex,複製一個鎖並使用其副本會導致死鎖
  4. 根據業務或者其他需求,某類型的對象只允許存在一個實例,這時複製顯然是被禁止的

顯然在一些情況下禁止複製是合情合理的,這也是為什麼我會寫這篇文章。

但具體情況具體分析,不是說複製就是萬惡之源,什麼時候該支持複製,什麼時候應該禁止,應該結合自己的實際情況。

運行時檢測實現禁止複製

想在別的語言禁止某個類型被覆制,方法有很多,用c++舉一例:

struct NoCopy {
    NoCopy(const NoCopy &) = delete;
    NoCopy &operator=(const NoCopy &) = delete;
};

可惜在golang里不支持這麼做。

另外,因為golang沒有運算符重載,所以很難在賦值的階段就進行攔截,所以我們的側重點在於“複製之後可以儘快檢測到”。

所以我們先實現在對象被覆制後報錯的功能。雖然不如c++編譯期就可以禁止複製那樣優雅,但也算實現了功能,至少不什麼都沒有要強一些。

初步嘗試

那麼如何直到對象是否被覆制了?很簡單,看它的地址就行了,地址一樣那必然是同一個對象,不一樣了那說明複製出一個新的對象了。

順著這個思路,我們需要一個機制來保存對象第一次創建時的地址,併在後續進行比較,於是第一版代碼誕生了:

import "unsafe"

type noCopy struct {
	p uintptr
}

func (nc *noCopy) check() {
	if uintptr(unsafe.Pointer(nc)) != nc.p {
		panic("copied")
	}
}

邏輯比較清晰,每次調用check來檢查當前的調用者的地址和保存地址是否相同,如果不同就panic。

為什麼沒有創建這個類型的方法?因為我們沒法得知自己被其他類型創建時的地址,所以這塊得讓其他使用noCopy的類型代勞。

使用的時候需要把noCopy嵌入自己的struct,註意不能以指針的形式嵌入:

type SomethingCannotCopy struct {
	noCopy
    ...
}

func (s *SomethingCannotCopy) DoWork() {
	s.check()
	fmt.Println("do something")
}

func NewSomethingCannotCopy() *SomethingCannotCopy {
	s := &SomethingCannotCopy{
        // 一些初始化
    }
    // 綁定地址
	s.noCopy.p = unsafe.Pointer(&s.noCopy)
	return s
}

註意初始化部分的代碼,在這裡我們需要把noCopy對象的地址綁定進去。現在可以實現運行時檢測了:

func main() {
    s1 := NewSomethingCannotCopy()
    pointer := s1
    s1Copy := *s1 // 這裡實際上進行了複製,但需要調用方法的時候才能檢測到
    pointer.DoWork() // 正常列印出信息
    s1Copy.DoWork() // panic
}

解釋下原理:當SomethingCannotCopy被覆制的時候,noCopy也會被覆制,因此複製出來的noCopy的地址和原先的那個是不一樣的,但他們內部記錄的p是一樣的,這樣當被覆制出來的noCopy對象調用check方法的時候就會觸發panic。這也是為什麼不要用指針形式嵌入它的原因。

功能實現了,但代碼實在是太醜,而且耦合嚴重:只要用了noCopy,就必須在創建對象的同時初始化noCopy的實例,noCopy的初始化邏輯會侵入到其他對象的初始化邏輯中,這樣的設計是不能接受的。

更好的實現

那麼有沒有更好的實現?答案是有的,而且在標準庫里。

標準庫的信號量sync.Cond是禁止複製的,而且比Mutex更為嚴格,因為複製它比複製鎖更容易導致死鎖和崩潰,所以標準庫加上了運行時的動態檢查。

主要代碼如下:

type Cond struct {
    // L is held while observing or changing the condition
    L Locker
    ...
    // 複製檢查
    checker copyChecker
}

// NewCond returns a new Cond with Locker l.
func NewCond(l Locker) *Cond {
        return &Cond{L: l}
}

func (c *Cond) Signal() {
    // 檢查自己是否被覆制
    c.checker.check()
    runtime_notifyListNotifyOne(&c.notify)
}

checker實現了運行時檢測是否被覆制,但初始化的時候並不需要特殊處理這個checker,這是用了什麼手法做到的呢?

看代碼:

type copyChecker uintptr

func (c *copyChecker) check() {
    if uintptr(*c) != uintptr(unsafe.Pointer(c)) && // step 1
            !atomic.CompareAndSwapUintptr((*uintptr)(c), 0, uintptr(unsafe.Pointer(c))) && // step 2
            uintptr(*c) != uintptr(unsafe.Pointer(c)) { //step 3
        panic("sync.Cond is copied")
    }
}

看著很複雜,連原子操作都來了,這都是啥啊。但別怕,我給你捋一捋就明白了。

首先是checker初始化之後第一次調用:

  1. 當check第一次被調用,c的值肯定是0,而這時候c是有真實的地址的,所以step 1失敗,進入step 2
  2. 用原子操作把c的值設置成自己的地址值,註意只有c的值是0的時候才能完成設置,因為這裡c的值是0,所以交換成功,step 2False,判斷流程直接結束;
  3. 因為不排除還有別的goroutine拿著這個checker在做檢測,所以step 2是會失敗的,這是要進入step 3
  4. step 3再次比較c的值和它自己的地址是否相同,相同說明多個goroutine共用了一個checker,沒有發生複製,所以檢測通過不會panic。
  5. 如果step 3的比較發現不相等,那麼說明被覆制了,直接panic

然後我們再看其他情況下checker的流程:

  1. 這時候c的值不是0,如果沒發生複製,那麼step 1的結果是False,判斷流程結束,不會panic;
  2. 如果c的值和自己的地址不一樣,會進入step 2,因為這裡c的值不為0,所以表達式結果一定是True,所以進入step 3
  3. step 3step 1一樣,結果是True,地址不同說明被覆制,這時候if裡面的語句會執行,因此panic。

搞得這麼麻煩,其實就是為了能幹乾凈凈地初始化。這樣任何類型都只需要帶上checker作為自己的欄位就行,不用關心它是這麼初始化的。

還有個小問題,為什麼設置checker的值需要原子操作,但讀取就不用呢?

因為讀取一個uintptr的值,在現代的x86和arm處理器上只要一個指令,所以要麼讀到過時的值要麼讀到最新的值,不會讀到錯誤的或者寫了一半的不完整的值,對於讀到舊值的情況(主要出現在第一次調用check的時候),還有step 3做進一步的檢查,因此不會影響整個檢測邏輯。而“比較並交換”顯然一條指令做不完,如果在中間步驟被打斷那麼整個操作的結果很可能就是錯的,從而影響整個檢測邏輯,所以必須要用原子操作才行。

那麼在讀取的時候也使用atomic.Load行嗎?當然行,但一是這麼做仍然避免不了step 3的檢測,可以思考下是為什麼;二是原子操作相比直接讀取會帶來性能損失,在這裡不使用原子操作也能保證正確性的情況下這是得不償失的。

性能

因為是運行時檢測,所以我們得看看會對性能帶來多少影響。我們使用改進版的checker。

type CheckBench struct {
    num uint64
    checker copyChecker
}

func (c *CheckBench) CheckCopy() {
	c.checker.check()
	c.num++
}

// 不進行檢測
func (c *CheckBench) NoCheck() {
	c.num++
}

func BenchmarkCheckBench_NoCheck(b *testing.B) {
	c := CheckBench{}
	for i := 0; i < b.N; i++ {
		for j := 0; j < 50; j++ {
			c.NoCheck()
		}
	}
}

func BenchmarkCheckBench_WithCheck(b *testing.B) {
	c := CheckBench{}
	for i := 0; i < b.N; i++ {
		for j := 0; j < 50; j++ {
			c.CheckCopy()
		}
	}
}

測試結果如下:

cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkCheckBench_NoCheck-8           17689137                68.36 ns/op
BenchmarkCheckBench_WithCheck-8         17563833                66.04 ns/op

幾乎可以忽略不計,因為我們這裡沒有發生複製,所以幾乎每次檢測都是通過的,這對cpu的分支預測非常友好,所以性能損耗幾乎可以忽略。

所以我們給cpu添點堵,讓分支預測沒那麼容易:

func BenchmarkCheckBench_WithCheck(b *testing.B) {
	for i := 0; i < b.N; i++ {
		c := &CheckBench{}
		for j := 0; j < 50; j++ {
			c.CheckCopy()
		}
	}
}

func BenchmarkCheckBench_NoCheck(b *testing.B) {
	for i := 0; i < b.N; i++ {
		c := &CheckBench{}
		for j := 0; j < 50; j++ {
			c.NoCheck()
		}
	}
}

現在分支預測沒那麼容易了而且要多付出初始化時使用atomic的代價,測試結果會變成這樣:

cpu: Intel(R) Core(TM) i5-10200H CPU @ 2.40GHz
BenchmarkCheckBench_WithCheck-8         15552717                74.84 ns/op
BenchmarkCheckBench_NoCheck-8           26441635                44.74 ns/op

差不多會慢40%。當然,實際的代碼不會有這麼極端,所以最壞可能也只會產生20%的影響,通常不太會成為性能瓶頸,運行時檢測是否有影響還需結核profile。

優點和缺點

優點:

  1. 只要調用check,肯定能檢查出是否被覆制
  2. 簡單

缺點:

  1. 所有的方法里都需要調用check,新加方法忘了調用的話就無法檢測
  2. 只能在被覆制出來的新對象那檢測到複製操作,原先那個對象上check始終是沒問題的,這樣不是嚴格禁止了複製,但大多數時間沒問題,可以接受
  3. 如果只複製了對象沒調用任何對象上的方法,也無法檢測到複製,這種情況比較少見
  4. 有潛在性能損耗,雖然很多時候可以得到充分優化損耗沒那麼誇張

靜態檢測實現禁止複製

動態檢測的缺點不少,能不能像c++那樣編譯期就禁止複製呢?

利用Locker介面不可複製實現靜態檢測

也可以,但得配合靜態代碼檢測工具,比如自帶的go vet。看下代碼:

// 實現sync.Locker介面
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}

type SomethingCannotCopy struct {
    noCopy
}

這樣就行了,不需要再添加其他的代碼。解釋下原理:任何實現了sync.Locker的類型都不應該被拷貝,靜態代碼檢測會檢測出這些情況並報錯。

所以類似下邊的代碼都是無法通過靜態代碼檢測的:

func f(s SomethingCannotCopy) {
    // 報錯,因為參數會導致複製
    // 返回SomethingCannotCopy也是不行的
}

func (s SomethingCannotCopy) Method() {
    // 報錯,因為非指針類型接收器會導致複製
}

func main() {
    s := SomethingCannotCopy{}
    sCopy := s // 報錯
    sInterface := any(s) // 報錯
    sPointer := &s // OK
    sCopy2 := *sPointer // 報錯
    sInterface2 := any(sPointer) // OK
    sCopy3 := *(sInterface2.(*SomethingCannotCopy)) // 報錯
}

基本上涵蓋了所以會產生複製操作的地方,基本能在編譯期完成檢測。

如果跳過go vet,直接使用go run或者go build,那麼上面的代碼可以正常編譯並運行。

優點和缺點

因為只有靜態檢測,因此沒有什麼運行時開銷,所以性能這節就不需要費筆墨了。主要來看下這種方案的優缺點。

優點:

  1. 實現非常簡單,代碼很簡練,基本無侵入性
  2. 依賴靜態檢測,不影響運行時性能
  3. golang自帶檢測工具:go vet
  4. 可檢測到的case比運行時檢測多

缺點:

  1. 最大的缺點,儘管靜態檢測會報錯,但仍然可以正常編譯執行
  2. 不是每個測試環境和CI都配備了靜態檢測,所以很難強制保證類型沒有被覆制
  3. 會導致類型實現sync.Locker,然而很多時候我們的類型並不是類似鎖的資源,使用這個介面只是為了靜態檢測,這會帶來代碼被誤用的風險

標準庫也使用的這套方案,建議仔細閱讀這個issue里的討論。

更進一步

看過運行時檢測和靜態檢測兩種方案之後,我們會發現這些做法多少都有些問題,不盡如人意。

所以我們還是要追求一種更好用的,更符合golang風格的做法。幸運的是,這樣的做法是存在的。

利用package和interface進行封裝

首先我們創建一個worker包,裡面定義一個Worker介面,包中的數據對外以Worker介面的形式提供:

package worker

import (
	"fmt"
)

// 對外只提供介面來訪問數據
type Worker interface {
	Work()
}

// 內部類型不導出,以介面的形式供外部使用
type normalWorker struct {
	// data members
}
func (*normalWorker) Work() {
	fmt.Println("I am a normal worker.")
}
func NewNormalWorker() Worker {
	return &normalWorker{}
}

type specialWorker struct {
	// data members
}
func (*specialWorker) Work() {
	fmt.Println("I am a special worker.")
}
func NewSpecialWorker() Worker {
	return &specialWorker{}
}

worker包對外只提供Worker介面,用戶可以使用NewNormalWorkerNewSpecialWorker來生成不同種類的worker,用戶不需要關心具體的返回類型,只要使用得到的Worker介面即可。

這麼做的話,在worker包之外是看不到normalWorkerspecialWorker這兩個類型的,所以沒法靠反射和類型斷言取出介面引用的數據;因為我們傳給介面的是指針,因此源數據不會被覆制;同時我們在第一節提到過,把一個介面賦值給另一個介面(worker包之外你只能這麼做),底層被引用的數據不會被覆制,因此在包外始終不會在這兩個類型上產生複製的行為。

因此下麵這樣的代碼是不可能通過編譯的:

func main() {
    w := worker.NewSpecialWorker()
    // worker.specialWorker 在worker包以外不可見,因此編譯錯誤
    wCopy := *(w.(*worker.specialWorker))
    wCopy.Work()
}

優點和缺點

這樣就實現了worker包之外的禁止複製,下麵來看看優缺點。

優點:

  1. 不需要額外的靜態檢查工具在編譯代碼前執行檢查
  2. 不需要運行時動態檢測是否被覆制
  3. 不會實現自己不需要的介面類型導致污染方法集
  4. 符合golang開發中的習慣做法

缺點:

  1. 並沒有讓類型本身不可複製,而是靠封裝屏蔽了大部分可能導致複製的情況
  2. 這些worker類型在包內是可見的,如果在包內修改代碼時不註意可能會導致複製這些類型的值,所以要麼包內也都用Woker介面,要麼參考上一節添加靜態檢查
  3. 有些場景下不需要介面或者因為性能要求苛刻而使用不了介面,這種做法就行不通了,比如標準庫sync里的類型為了性能大部分都是暴露出來給外部直接使用的

綜合來說,這種方案是實現成本最低的。

總結

現在我們有三種方式防止我們的類型被覆制:

  1. 運行時檢測
  2. 靜態代碼檢測
  3. 通過介面封裝避免暴露類型,從而避免被覆制

一共三種方案,選擇困難症仿佛要發作了。彆著急,我們一起看看標準庫是怎麼做的:

  1. 標準庫的sync.Cond同時使用了方案一和方案二,因為設計者確實很不希望條件變數被覆制
  2. sync.Mutexsync.Poolsync.WaitGroup使用了方案二,需要配合go vet
  3. 方案三在標準庫中應用最廣泛,然而多數是處於設計和封裝的考慮,並不是為了禁止copy,但複製crypto包下的那些HashCipher確實沒什麼意義會帶來誤用,正好藉著方案三避免了這些問題

綜合來看首選的應該是方案三;但也有需要使用方案二的時候,比如sync包中的那些同步機構;使用最少的是方案一,儘可能地不要設計出類似的代碼。

還有一點需要註意,如果你的類型里有欄位是sync.Poolsync.WaitGroupsync.RWMutexsync.Mutexsync.Condsync.Mapsync.Once,那麼這個類型本身也是不可複製的,也不需要額外實現禁止複製的功能,因為那些欄位自帶了。

最後,我只想說golang的語言技能實在是太簡陋了,想只依賴語言特性實現禁止複製的功能不太現實,更多的還是需要靠“設計”。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 教程簡介 Microsoft Dynamics CRM初學者教程 - 從簡單和簡單的步驟學習Microsoft Dynamics CRM,從基本到高級概念,包括基本概念到高級概念,包括概述,環境,功能模塊,實體和記錄,欄位,表單,搜索,Web資源,JScript Web資源,HTML Web資源,x ...
  • 本文已收錄至Github,推薦閱讀 👉 Java隨想錄 微信公眾號:Java隨想錄 CSDN: 碼農BookSea 當我們的應用單實例不能支撐用戶請求時,此時就需要擴容,從一臺伺服器擴容到兩台、幾十臺、幾百台。此時我們就需要負載均衡,進行流量的轉發。下麵介紹幾種負載均衡的方案。 DNS負載均衡 一 ...
  • 定義常量 const與#define的區別: A. const常量具有類型,編譯器可以進行安全檢查,#define沒有類型,只是簡單替換字元串 B. const只能定義整數或枚舉的常量 const修飾變數 必須初始化 不可修改 指針與const const位於*左側(const int * a):c ...
  • 排序是一個Java開發者,在日常開發過程中隨處可見的開發內容,Java中有豐富的API可以調用使用。在Java語言中,作為集合工具類的排序方法,必定要做到通用、高效、實用這幾點特征。主要探討java中排序方法所使用的演算法,以及那些是值得我們學習和借鑒的內容。文中如有理解和介紹的錯誤,一起學習,一起探... ...
  • Java基礎語法:註釋、數據類型、位元組 註釋 單行註釋:// 多行註釋:/* 註釋 */ 文檔註釋:/** 註釋 */ 數據類型分為兩大類:基本類型和引用類型 八大基本數據類型 整數類型 byte(占1個位元組範圍:-128~127) int(占4個位元組範圍) short(占2個位元組範圍) long( ...
  • 魔幻的2022年中中斷了寫學習筆記的工作。孩子去澳洲上學去了,再次入坑寫寫學習筆記。 孩子在大學中需要用R語言,我也跟著學習起來。 R語言主要用於學術研究中的統計、數據挖掘等數據科學,用熱門的ChatGPT得到與Python的區別的回答如下: ChatGPT回答內容 R語言和Python語言是兩種常 ...
  • 來源:eamonyin.blog.csdn.net 一、 單元測試的概念 概念: 單元測試(unit testing),是指對軟體中的最小可測試單元進行檢查和驗證。在Java中單元測試的最小單元是類。 單元測試是開發者編寫的一小段代碼,用於檢驗被測代碼的一個很小的、很明確的功能是否正確。執行單元測試 ...
  • 本文介紹基於Python語言,對神經網路模型的結構進行可視化繪圖的方法。 最近需要進行神經網路結構模型的可視化繪圖工作。查閱多種方法後,看到很多方法都比較麻煩,例如單純利用graphviz模塊,就需要手動用DOT語言進行圖片描述,比較花時間;最終,發現利用第三方的ann_visualizer模塊,可 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...