Absernode
TechCats 成員/朋克程序員:看看,這就叫專業linux
go get go.etcd.io/bbolt/...
複製代碼
會 get 兩項android
$GOPATH
bolt
command line -> $GOBIN
使用 kv 數據庫都很簡單,只須要一個文件路徑便可搭建完成環境。git
package main
import (
"log"
bolt "go.etcd.io/bbolt"
)
func main() {
// Open the my.db data file in your current directory.
// It will be created if it doesn't exist. db, err := bolt.Open("my.db", 0600, nil) if err != nil { log.Fatal(err) } defer db.Close() ... } 複製代碼
這裏到 db 不支持多連接。這是由於對於 database file 一個連接保持了一個文件鎖 file lock
。程序員
若是併發,後續連接會阻塞。github
能夠爲單個連接添加 超時控制golang
db, err := bolt.Open("my.db", 0600, &bolt.Options{Timeout: 1 * time.Second})
複製代碼
與 google 的 levelDB 不一樣,bbolt 支持事務。 detail bolt 優缺點:detail 同時 bbolt 出自 bolt ,沒太多不一樣,只是 bbolt 目前還在維護。web
同時只能有redis
actions⚠️:在事務開始時,會保持一個數據視圖 這意味着事務處理過程當中不會因爲別處更改而改變
數據庫
單個事務和它所建立的全部對象(桶,鍵)都不是線程安全的。
建議加鎖 或者 每個 goroutine 併發都開啓 一個 事務
固然,從 db
這個 bbolt 的頂級結構建立 事務 是 線程安全 的
前面提到的 讀寫事務 和 只讀事務 拒絕相互依賴。固然也不能在同一個 goroutine 裏。
死鎖緣由是 讀寫事務 須要週期性從新映射 data 文件(即database
)。這在開啓只讀事務時是不可行的。
使用 db.Update
開啓一個讀寫事務
err := db.Update(func(tx *bolt.Tx) error{
···
return nil
})
複製代碼
上文提過,在一個事務中 ,數據視圖是同樣的。 (詳細解釋就是,在這個函數做用域中,數據對你呈現最終一致性)
bboltdb 根據你的返回值判斷事務狀態,你能夠添加任意邏輯並認爲出錯時返回 return err
bboltdb 會回滾,若是 return nil
則提交你的事務。
建議永遠檢查 Update
的返回值,由於他會返回如 硬盤壓力 等形成事務失敗的信息(這是在你的邏輯以外的狀況)
⚠️:你自定義返回 error 的 error 信息一樣會被傳遞出來。
使用 db.View
來新建一個 只讀事務
err := db.View(func(tx *bolt.Tx) error {
···
return nil
})
複製代碼
同上,你會得到一個一致性的數據視圖。
固然,只讀事務 只能檢索信息,不會有任何更改。(btw,可是你能夠 copy 一個 database 的副本,畢竟這隻須要讀數據)
讀寫事務 db.Update
最後須要對 database
提交更改,這會等待硬盤就緒。
每一次文件讀寫都是和磁盤交互。這不是一個小開銷。
你可使用 db.Batch
開啓一個 批處理事務。他會在最後批量提交(實際上是多個 goroutines 開啓 db.Batch
事務時有機會合並以後一塊兒提交)從而減少了開銷。 ⚠️:db.Batch
只對 goroutine 起效
使用 批處理事務 須要作取捨,用 冪等函數 換取 速度 ⚠️: db.Batch
在一部分事務失敗的時候會嘗試屢次調用那些事務函數,若是不是冪等會形成不可預知的非最終一致性。
例:使用事務外的變量來使你的日誌不那麼奇怪
var id uint64
err := db.Batch(func(tx *bolt.Tx) error {
// Find last key in bucket, decode as bigendian uint64, increment
// by one, encode back to []byte, and add new key.
...
id = newValue
return nil
})
if err != nil {
return ...
}
fmt.Println("Allocated ID %d", id)
複製代碼
能夠手動進行事務的 開啓 ,回滾,新建對象,提交等操做。由於自己 db.Update
和 db.View
就是他們的包裝 ⚠️:手動事務記得 關閉 (Close)
開啓事務使用 db.Begin(bool)
同時參數表明着是否能夠寫操做。以下:
// Start a writable transaction.
tx, err := db.Begin(true)
if err != nil {
return err
}
defer tx.Rollback()
// Use the transaction...
_, err := tx.CreateBucket([]byte("MyBucket"))
if err != nil {
return err
}
// Commit the transaction and check for error.
if err := tx.Commit(); err != nil {
return err
}
複製代碼
桶是鍵值對的集合。在一個桶中,鍵值惟一。
使用 Tx.CreateBucket()
和 Tx.CreateBucketIfNotExists()
創建一個新桶(推薦使用第二個) 接受參數是 桶的名字
使用 Tx.DeleteBucket()
根據桶的名字來刪除
func main() {
db, err := bbolt.Open("./data", 0666, nil)
if err != nil {
log.Fatal(err)
}
defer db.Close()
db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("MyBucket"))
if err != nil {
return fmt.Errorf("create bucket: %v", err)
}
if err = tx.DeleteBucket([]byte("MyBucket")); err != nil {
return err
}
return nil
})
}
複製代碼
最重要的部分,就是 kv 存儲怎麼使用了,首先須要一個 桶 來存儲鍵值對。
使用Bucket.Put()
來存儲鍵值對,接收兩個 []byte
類型的參數
db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("MyBucket"))
err := b.Put([]byte("answer"), []byte("42"))
return err
})
複製代碼
很明顯,上面的例子設置了 Pair: key:answer value:42
使用 Bucket.Get()
來查詢鍵值。參數是一個 []byte
(別忘了此次咱們只是查詢,可使用 只讀事務)
db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("MyBucket"))
v := b.Get([]byte("answer"))
fmt.Printf("The answer is: %s\n", v)
return nil
})
複製代碼
細心會注意到,Get
是不會返回 error
的,這是由於 Get()
必定能正常工做(除非系統錯誤),相應的,當返回 nil
時,查詢的鍵值對不存在。 ⚠️:注意 0 長度的值 和 不存在鍵值對 的行爲是不同的。(一個返回是 nil, 一個不是)
func main() {
db, err := bolt.Open("./data.db", 0666, nil)
if err != nil {
log.Fatal(err)
}
defer db.Close()
err = db.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("MyBucket"))
if err != nil {
return fmt.Errorf("create bucket: %v", err)
}
if err = b.Put([]byte("answer"), []byte("42")); err != nil {
return err
}
if err = b.Put([]byte("zero"), []byte("")); err != nil {
return err
}
return nil
})
db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("MyBucket"))
v := b.Get([]byte("noexists"))
fmt.Println(reflect.DeepEqual(v, nil)) // false
fmt.Println(v == nil) // true
v = b.Get([]byte("zero"))
fmt.Println(reflect.DeepEqual(v, nil)) // false
fmt.Println(v == nil) // true
return nil
})
}
複製代碼
使用 Bucket.Delete()
刪除鍵值對
db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("MyBucket"))
fmt.Println(b.Get([]byte("answer")))
err := b.Delete([]byte("answer"))
if err != nil {
return err
}
return nil
})
複製代碼
⚠️: Get()
獲取到的字節切片值只在當前事務(當前函數做用域)有效,若是要在其餘事務中使用須要使用 copy()
將其拷貝到其餘的字節切片
使用 NextSequence()
來建立自增鍵,見下例
// CreateUser saves u to the store. The new user ID is set on u once the data is persisted.
func (s *Store) CreateUser(u *User) error {
return s.db.Update(func(tx *bolt.Tx) error {
// Retrieve the users bucket.
// This should be created when the DB is first opened.
b := tx.Bucket([]byte("users"))
// Generate ID for the user.
// This returns an error only if the Tx is closed or not writeable.
// That can't happen in an Update() call so I ignore the error check. id, _ := b.NextSequence() u.ID = int(id) // Marshal user data into bytes. buf, err := json.Marshal(u) if err != nil { return err } // Persist bytes to users bucket. return b.Put(itob(u.ID), buf) }) } // itob returns an 8-byte big endian representation of v. func itob(v int) []byte { b := make([]byte, 8) binary.BigEndian.PutUint64(b, uint64(v)) return b } type User struct { ID int ... } 複製代碼
很簡單的,桶能夠實現嵌套存儲
func (*Bucket) CreateBucket(key []byte) (*Bucket, error)
func (*Bucket) CreateBucketIfNotExists(key []byte) (*Bucket, error)
func (*Bucket) DeleteBucket(key []byte) error
複製代碼
假設您有一個多租戶應用程序,其中根級別存儲桶是賬戶存儲桶。該存儲桶內部有一系列賬戶的序列,這些賬戶自己就是存儲桶。在序列存儲桶(子桶)中,可能有許多相關的存儲桶(Users,Note等)。
// createUser creates a new user in the given account.
func createUser(accountID int, u *User) error {
// Start the transaction.
tx, err := db.Begin(true)
if err != nil {
return err
}
defer tx.Rollback()
// Retrieve the root bucket for the account.
// Assume this has already been created when the account was set up.
root := tx.Bucket([]byte(strconv.FormatUint(accountID, 10)))
// Setup the users bucket.
bkt, err := root.CreateBucketIfNotExists([]byte("USERS"))
if err != nil {
return err
}
// Generate an ID for the new user.
userID, err := bkt.NextSequence()
if err != nil {
return err
}
u.ID = userID
// Marshal and save the encoded user.
if buf, err := json.Marshal(u); err != nil {
return err
} else if err := bkt.Put([]byte(strconv.FormatUint(u.ID, 10)), buf); err != nil {
return err
}
// Commit the transaction.
if err := tx.Commit(); err != nil {
return err
}
return nil
}
複製代碼
在桶中,鍵值對根據 鍵 的 值是有字節序的。 使用 Bucket.Cursor()
對其進行迭代
db.View(func(tx *bolt.Tx) error {
// Assume bucket exists and has keys
b := tx.Bucket([]byte("MyBucket"))
c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() {
fmt.Printf("key=%s, value=%s\n", k, v)
}
return nil
})
複製代碼
Cursor 有 5 種方法進行迭代
First()
Move to the first key.
Last()
Move to the last key.
Seek()
Move to a specific key.\
Next()
Move to the next key.\
Prev()
Move to the previous key.
每個方法都返回 (key []byte, value []byte)
兩個值 當方法所指值不存在時返回 兩個 nil
值,發生在如下狀況:
Cursor.Next()
Cursor.Prev()
Next()
和 5. Prev()
方法而未使用 1.First()
2.Last()
3. Seek()
指定初始位置時⚠️特殊狀況:當 key
爲 非 nil
但 value
是 nil
是,說明這是嵌套桶,value
值是子桶,使用 Bucket.Bucket()
方法訪問 子桶,參數是 key
值
db.View(func(tx *bolt.Tx) error {
c := b.Cursor()
fmt.Println(c.First())
k, v := c.Prev()
fmt.Println(k == nil, v == nil) // true,true
if k != nil && v == nil {
subBucket := b.Bucket()
// doanything
}
return nil
})
複製代碼
經過使用 Cursor
咱們可以作到一些特殊的遍歷,如:遍歷擁有特定前綴的 鍵值對
db.View(func(tx *bolt.Tx) error {
// Assume bucket exists and has keys
c := tx.Bucket([]byte("MyBucket")).Cursor()
prefix := []byte("1234")
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
fmt.Printf("key=%s, value=%s\n", k, v)
}
return nil
})
複製代碼
在一個範圍裏遍歷,如:使用可排序的時間編碼(RFC3339)能夠遍歷特定日期範圍的數據
db.View(func(tx *bolt.Tx) error {
// Assume our events bucket exists and has RFC3339 encoded time keys.
c := tx.Bucket([]byte("Events")).Cursor()
// Our time range spans the 90's decade. min := []byte("1990-01-01T00:00:00Z") max := []byte("2000-01-01T00:00:00Z") // Iterate over the 90's.
for k, v := c.Seek(min); k != nil && bytes.Compare(k, max) <= 0; k, v = c.Next() {
fmt.Printf("%s: %s\n", k, v)
}
return nil
})
複製代碼
⚠️:Golang 實現的 RFC3339Nano 是不可排序的
在桶中有值的狀況下,可使用 ForEach()
遍歷
db.View(func(tx *bolt.Tx) error {
// Assume bucket exists and has keys
b := tx.Bucket([]byte("MyBucket"))
b.ForEach(func(k, v []byte) error {
fmt.Printf("key=%s, value=%s\n", k, v)
return nil
})
return nil
})
複製代碼
⚠️:在 ForEach()
中遍歷的鍵值對須要copy()
到事務外才能在事務外使用
boltdb 是一個單一的文件,因此很容易備份。你可使用Tx.writeto()
函數寫一致的數據庫。若是從只讀事務調用這個函數,它將執行熱備份,而不會阻塞其餘數據庫的讀寫操做。
默認狀況下,它將使用一個常規文件句柄,該句柄將利用操做系統的頁面緩存。
有關優化大於RAM數據集的信息,請參見[Tx](https://link.zhihu.com/?target=https%3A//godoc.org/go.etcd.io/bbolt%23Tx)
文檔。
一個常見的用例是在HTTP上進行備份,這樣您就可使用像cURL
這樣的工具來進行數據庫備份:
func BackupHandleFunc(w http.ResponseWriter, req *http.Request) {
err := db.View(func(tx *bolt.Tx) error {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", `attachment; filename="my.db"`)
w.Header().Set("Content-Length", strconv.Itoa(int(tx.Size())))
_, err := tx.WriteTo(w)
return err
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
複製代碼
而後您可使用此命令進行備份:
$ curl http://localhost/backup > my.db
或者你能夠打開你的瀏覽器以http://localhost/backup,它會自動下載。
若是你想備份到另外一個文件,你可使用TX.copyfile()
輔助功能。
數據庫對運行的許多內部操做保持一個運行計數,這樣您就能夠更好地瞭解發生了什麼。經過捕捉兩個時間點數據的快照,咱們能夠看到在這個時間範圍內執行了哪些操做。
例如,咱們能夠用一個 goroutine 裏記錄統計每個 10 秒:
go func() {
// Grab the initial stats.
prev := db.Stats()
for {
// Wait for 10s.
time.Sleep(10 * time.Second)
// Grab the current stats and diff them.
stats := db.Stats()
diff := stats.Sub(&prev)
// Encode stats to JSON and print to STDERR.
json.NewEncoder(os.Stderr).Encode(diff)
// Save stats for the next loop.
prev = stats
}
}()
複製代碼
將這些信息經過管道輸出到監控也頗有用。
能夠開啓只讀模式防止錯誤更改
db, err := bolt.Open("my.db", 0666, &bolt.Options{ReadOnly: true})
if err != nil {
log.Fatal(err)
}
複製代碼
如今使用 db.Update()
等開啓讀寫事務 將會阻塞
移動端支持由 gomobile 工具提供
Create a struct that will contain your database logic and a reference to a *bolt.DB
with a initializing constructor that takes in a filepath where the database file will be stored. Neither Android nor iOS require extra permissions or cleanup from using this method.
func NewBoltDB(filepath string) *BoltDB {
db, err := bolt.Open(filepath+"/demo.db", 0600, nil)
if err != nil {
log.Fatal(err)
}
return &BoltDB{db}
}
type BoltDB struct {
db *bolt.DB
...
}
func (b *BoltDB) Path() string {
return b.db.Path()
}
func (b *BoltDB) Close() {
b.db.Close()
}
複製代碼
Database logic should be defined as methods on this wrapper struct. To initialize this struct from the native language (both platforms now sync their local storage to the cloud. These snippets disable that functionality for the database file):
String path;
if (android.os.Build.VERSION.SDK_INT >=android.os.Build.VERSION_CODES.LOLLIPOP){
path = getNoBackupFilesDir().getAbsolutePath();
} else{
path = getFilesDir().getAbsolutePath();
}
Boltmobiledemo.BoltDB boltDB = Boltmobiledemo.NewBoltDB(path)
複製代碼
- (void)demo {
NSString* path = [NSSearchPathForDirectoriesInDomains(NSLibraryDirectory,
NSUserDomainMask,
YES) objectAtIndex:0];
GoBoltmobiledemoBoltDB * demo = GoBoltmobiledemoNewBoltDB(path);
[self addSkipBackupAttributeToItemAtPath:demo.path];
//Some DB Logic would go here
[demo close];
}
- (BOOL)addSkipBackupAttributeToItemAtPath:(NSString *) filePathString
{
NSURL* URL= [NSURL fileURLWithPath: filePathString];
assert([[NSFileManager defaultManager] fileExistsAtPath: [URL path]]);
NSError *error = nil;
BOOL success = [URL setResourceValue: [NSNumber numberWithBool: YES]
forKey: NSURLIsExcludedFromBackupKey error: &error];
if(!success){
NSLog(@"Error excluding %@ from backup %@", [URL lastPathComponent], error);
}
return success;
}
複製代碼
For more information on getting started with Bolt, check out the following articles:
關係數據庫將數據組織成行,而且只能經過使用SQL進行訪問。這種方法在存儲和查詢數據方面提供了靈活性,可是在解析和計劃SQL語句時也會產生開銷。Bolt經過字節切片鍵訪問全部數據。這使得Bolt能夠快速地經過鍵讀取和寫入數據,可是不提供將值鏈接在一塊兒的內置支持。 大多數關係數據庫(SQLite除外)都是獨立於應用程序運行的獨立服務器。這使您的系統具備將多個應用程序服務器鏈接到單個數據庫服務器的靈活性,但同時也增長了在網絡上序列化和傳輸數據的開銷。Bolt做爲應用程序中包含的庫運行,所以全部數據訪問都必須通過應用程序的過程。這使數據更接近您的應用程序,但限制了對數據的多進程訪問。
LevelDB及其派生類(RocksDB,HyperLevelDB)與Bolt相似,由於它們是捆綁到應用程序中的庫,可是它們的底層結構是日誌結構的合併樹(LSM樹)。LSM樹經過使用預寫日誌和稱爲SSTables的多層排序文件來優化隨機寫入。Bolt在內部使用B +樹,而且僅使用一個文件。兩種方法都須要權衡。 若是您須要較高的隨機寫入吞吐量(> 10,000 w / sec),或者須要使用旋轉磁盤,那麼LevelDB多是一個不錯的選擇。若是您的應用程序是大量讀取或進行大量範圍掃描,那麼Bolt多是一個不錯的選擇。 另外一個重要的考慮因素是LevelDB沒有事務。它支持鍵/值對的批量寫入,而且支持讀取快照,但不能使您安全地執行比較和交換操做。Bolt支持徹底可序列化的ACID事務。
Bolt最初是LMDB的端口,所以在架構上類似。二者都使用B +樹,具備ACID語義和徹底可序列化的事務,並支持使用單個寫入器和多個讀取器的無鎖MVCC。 這兩個項目有些分歧。LMDB專一於原始性能,而Bolt專一於簡單性和易用性。例如,出於性能考慮,LMDB容許執行幾種不安全的操做,例如直接寫入。Bolt選擇禁止可能使數據庫處於損壞狀態的操做。Bolt惟一的例外是DB.NoSync
。 API也有一些區別。打開LMDB時須要最大的mmap大小,mdb_env
而Bolt會自動處理增量mmap的大小。LMDB使用多個標誌來重載getter和setter函數,而Bolt將這些特殊狀況拆分爲本身的函數。
選擇合適的工具來完成這項工做很重要,而Bolt也不例外。在評估和使用Bolt時,須要注意如下幾點:
Bolt很是適合讀取密集型工做負載。順序寫入性能也很快,可是隨機寫入可能會很慢。您可使用DB.Batch()
或添加預寫日誌來幫助緩解此問題。\
Bolt在內部使用B + tree,所以能夠有不少隨機頁面訪問。與旋轉磁盤相比,SSD能夠顯着提升性能。\
嘗試避免長時間運行的讀取事務。Bolt使用寫時複製功能,所以在舊事務使用舊頁時沒法回收這些舊頁。\
從Bolt返回的字節片僅在事務期間有效。一旦事務被提交或回滾,它們所指向的內存就能夠被新頁面重用,或者能夠從虛擬內存中取消映射,unexpected fault address
訪問時會出現恐慌。\
Bolt在數據庫文件上使用排他寫鎖定,所以不能被多個進程共享。\
使用時要當心Bucket.FillPercent
。爲具備隨機插入的存儲桶設置較高的填充百分比將致使數據庫的頁面利用率很是差。\
一般使用較大的水桶。較小的存儲桶一旦超過頁面大小(一般爲4KB),就會致使頁面利用率降低。\
批量加載大量隨機寫入新的存儲桶可能很慢,由於在提交事務以前頁面不會拆分。建議不要在單個事務中將100,000個以上的鍵/值對隨機插入到一個新的存儲桶中。\
Bolt使用內存映射文件,所以底層操做系統能夠處理數據的緩存。一般,操做系統將在內存中緩存儘量多的文件,並根據須要將內存釋放給其餘進程。這意味着在使用大型數據庫時,Bolt可能會顯示很高的內存使用率。可是,這是預料之中的,操做系統將根據須要釋放內存。只要Bolt的內存映射適合進程虛擬地址空間,它就能夠處理比可用物理RAM大得多的數據庫。在32位系統上可能會出現問題。\
Bolt數據庫中的數據結構是內存映射的,所以數據文件將是特定於字節序的。這意味着您沒法將Bolt文件從小字節序計算機複製到大字節序計算機並使其正常工做。對於大多數用戶而言,這不是問題,由於大多數現代CPU的字節序都不多。\
因爲頁面在磁盤上的佈局方式,Bolt沒法截斷數據文件並將可用頁面返回到磁盤。取而代之的是,Bolt會在其數據文件中維護未使用頁面的空閒列表。這些空閒頁面能夠被之後的事務重用。因爲數據庫一般會增加,所以這在許多用例中效果很好。可是,請務必注意,刪除大塊數據將不容許您回收磁盤上的該空間。 有關頁面分配的更多信息,請參見此註釋。\
對於嵌入式,可序列化的事務性鍵/值數據庫,Bolt是一個相對較小的代碼庫(<5KLOC),所以對於那些對數據庫的工做方式感興趣的人來講,Bolt多是一個很好的起點。
最佳起點是Bolt的主要切入點:
Open()
-初始化對數據庫的引用。它負責建立數據庫(若是不存在),得到文件的排他鎖,讀取元頁面以及對文件進行內存映射。\
DB.Begin()
-根據writable
參數的值啓動只讀或讀寫事務。這須要短暫得到「元」鎖以跟蹤未結交易。一次只能存在一個讀寫事務,所以在讀寫事務期間將得到「 rwlock」。\
Bucket.Put()
-將鍵/值對寫入存儲桶。驗證參數以後,使用光標將B +樹遍歷到將鍵和值寫入的頁面和位置。找到位置後,存儲桶會將基礎頁面和頁面的父頁面具體化爲「節點」到內存中。這些節點是在讀寫事務期間發生突變的地方。提交期間,這些更改將刷新到磁盤。\
Bucket.Get()
-從存儲桶中檢索鍵/值對。這使用光標移動到鍵/值對的頁面和位置。在只讀事務期間,鍵和值數據將做爲對基礎mmap文件的直接引用返回,所以沒有分配開銷。對於讀寫事務,此數據能夠引用mmap文件或內存節點值之一。\
Cursor
-該對象僅用於遍歷磁盤頁或內存節點的B +樹。它能夠查找特定的鍵,移至第一個或最後一個值,也能夠向前或向後移動。光標對最終用戶透明地處理B +樹的上下移動。\
Tx.Commit()
-將內存中的髒節點和可用頁面列表轉換爲要寫入磁盤的頁面。而後寫入磁盤分爲兩個階段。首先,髒頁被寫入磁盤並fsync()
發生。其次,寫入具備遞增的事務ID的新元頁面,而後fsync()
發生另外一個頁面 。這兩個階段的寫入操做確保崩潰時會忽略部分寫入的數據頁,由於指向它們的元頁不會被寫入。部分寫入的元頁面是無效的,由於它們是用校驗和寫入的。\
若是您還有其餘可能對他人有用的註釋,請經過請求請求將其提交。
如下是使用Bolt的公共開源項目的列表:
If you are using Bolt in a project please send a pull request to add it to the list.