当前位置:网站首页>[Golang]从0到1写一个web服务(上)
[Golang]从0到1写一个web服务(上)
2022-08-10 20:30:00 【用户9959267】
学生时代曾和几个朋友做了一个笔记本小应用,当时我的角色是pm + dba,最近心血来潮,想把这个玩意自己实现一遍,顺便写一篇文章记录整个过程。
笔者的职业目前是一个后端程序员,最常用的语言是Golang,恰好Golang自带的的net/http包非常方便,这次就用Golang写这个服务。首先打开我心爱的GoLand,New一个Project,给项目起一个酷炫的名字。
接着写个Hello, world。同时为方便项目管理,引入git这个版本控制工具来管理代码。随着一发git init 加commit,这个项目就算诞生了; )
作为一个互联网公司的程序员,公司追求的是快速试错,迭代前进,此路不通换一条继续试验。这就需要管理PM和运营老板的预期,现在要从0到1写一个web服务,就需要详细拆解一下需求,搞一个TODO list。同时明确项目的milestone,快速迭代,到达stone立马上线,随后再慢慢地丰富细节~
那么先来搞第一个todo,Go语言自带的net/http包非常方便,写web server比较简单。让我们先把项目的slogan输出到浏览器上。
package main
import (
"log"
"net/http"
)
func Hello(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
_, err := wr.Write([]byte(`
__ _ _ ____ ____ __ _ _ ____ __ _ __ ____ ____ ____ __ ____ ____
/ _\/ )( ( __) ___)/ \( \/ | __) ( ( \/ (_ _| __) ___) / _\( _ ( _ \
/ \ /\ /) _)\___ ( O ) \/ \) _) / ( O ))( ) _)\___ \ / \) __/) __/
\_/\_(_/\_|____|____/\__/\_)(_(____) \_)__)\__/(__)(____|____/ \_/\_(__) (__)
`))
if err != nil {
return
}
}
func main() {
http.HandleFunc("/", hello)
err := http.ListenAndServe(":8010", nil)
if err != nil {
log.Fatal(err)
}
}然后启动项目,随便打开一个浏览器,输入http://localhost:8000/,可以看到这样的效果。
此时有的同学可能会讲,测试怎么还能老依赖浏览器呢,我电脑上如果没有浏览器还不能测试了吗?用浏览器测试确实不太优雅,所以让我们接着继续写一个测试文件,搞个http Client,自己跑一下效果,并来一发commit。
package main
import (
"fmt"
"io/ioutil"
"net/http"
"testing"
"time"
)
func Test_hello(t *testing.T) {
go main()
time.Sleep(time.Second) // 给main函数续一秒,确保main在http.Get之前执行
resp, err := http.Get("http://localhost:8000/")
if err != nil {
t.Error("curl failed")
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Error("read body failed")
}
// TODO 这里不优雅
fmt.Println(string(body) == `
__ _ _ ____ ____ __ _ _ ____ __ _ __ ____ ____ ____ __ ____ ____
/ _\/ )( ( __) ___)/ \( \/ | __) ( ( \/ (_ _| __) ___) / _\( _ ( _ \
/ \ /\ /) _)\___ ( O ) \/ \) _) / ( O ))( ) _)\___ \ / \) __/) __/
\_/\_(_/\_|____|____/\__/\_)(_(____) \_)__)\__/(__)(____|____/ \_/\_(__) (__)
`)
}接下来,让我们实现一下这个记事本的CURD功能让它先稍微可用起来。首先设计一下路由:
GET /note // 获取所有note
POST /note // 新增一个note
GET /note/:id // 根据id获取某个note
DELETE /note/:id // 根据id删除某个note敏锐的同学一下就可以看出来,这里我们用了早些年特别流行的RESTful路由设计。但是这里有个小问题:golang自带的net/http包里面对牛逼的RESTful支持的并不好。但是问题不大,让我们去程序员聚集的Github上找找有没有支持REST的router可以使用。
果不其然其他程序员有类似的困扰并解决了问题,这个12k星的项目完全可以cover我们的需求。httprouter可以根据HTTP方法(GET, POST, PUT, PATCH, DELETE等) build出一棵棵的压缩字典树(Radix Tree),树的节点是URL中的一个path块或path块的公共前缀,将树和 URL对应的handler函数绑定起来,就可以使用了。
要引入第三方包,就涉及到包管理问题,golang之前没有一个官方的包管理工具,一直被人所诟病。不过前些日子官方推出了Go Module这个工具,虽然目前这个工具还有一些问题,但是这是官方出品并强推的,肯定不会死,让我们通过go module把httprouter引用进来。
在package中执行一下go mod init xxx 命令,此时你会发现,项目的文件里面多了一个go.mod文件。
接着我们设置几个变量:
- GO111MODULE : 这个变量用来控制是否启用Go Modules,选auto。
- GOPROXY : 由于一些无法抵抗的原因,我们无法访问golang的proxy地址
proxy.golang.org。这里可以通过设置Proxy地址来代替官方的proxy,比如说七牛的goproxy.cn。 - GOPATH: 写go的人大概都知道这个变量,在没有go module之前,项目中import只能通过GOPATH设置的
GOPATH/src的限定路径来导入包,我们所有的go代码都得放到GOPATH之下,有了Go Modules之后,我们可以想放哪里就放哪里了。
同时,在Goland里也设置一下Go Modules。Go Modules可以使用之前的go get 命令来拉去包,比如这样:go get -u github.com/julienschmidt/httprouter。
接着,来更新一下项目的目录结构,随着项目的迭代,如果功能函数都写到main.go的话非常不利于管理,这里把代码做一下拆分。首先开一个logic目录,用来放项目的主要逻辑;接着开一个model目录,用来存放note模型,接着在main同级下写一个route目录,引入httprouter。
此时项目的布局像这样:
$ tree
.
├── go.mod
├── go.sum
├── logic
│ ├── note.go
│ └── note_test.go
├── main.go
├── main_test.go
├── model
│ └── dto.go
└── route.goroute.go文件大概长这样:
func initRouter() *httprouter.Router {
router := httprouter.New()
router.GET("/", logic.Hello)
router.GET("/note", logic.GetAll)
...
router.DELETE("/note/:id", logic.Delete)
return router
}而main文件要把router做一下初始化,并注册到HTTPHandler中。
func main() {
mux := initRouter()
err := http.ListenAndServe(":8010", mux)
if err != nil {
log.Fatal(err)
}
}在note.go中,我们把之前规划的curd功能实现一下:
package logic
import (
"crypto/md5"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"notes/model"
"time"
"github.com/julienschmidt/httprouter"
)
func Hello(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
_, err := wr.Write([]byte("This is a awesome note app!"))
if err != nil {
return
}
}
var notes = map[string]model.Note{}
func GetAll(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
data := map[string]interface{}{
"msg": "success",
"data": notes,
}
res, err := json.Marshal(data)
if err != nil {
return
}
_, err = wr.Write(res)
if err != nil {
return
}
}
func GetOne(wr http.ResponseWriter, r *http.Request, p httprouter.Params) {
id := p.ByName("id")
if _, exist := notes[id]; !exist {
return
}
data := map[string]interface{}{
"msg": "success",
"data": notes[id],
}
res, err := json.Marshal(data)
if err != nil {
return
}
_, err = wr.Write(res)
if err != nil {
return
}
}
func Add(wr http.ResponseWriter, r *http.Request, _ httprouter.Params) {
content := r.URL.Query().Get("content")
if content == "" {
return
}
id := genID(content)
note := model.Note{
ID: id,
Content: content,
StartTime: time.Now(),
UpdateTime: time.Now(),
}
notes[id] = note
data := map[string]interface{}{
"msg": "success",
"data": "",
}
res, err := json.Marshal(data)
if err != nil {
return
}
_, err = wr.Write(res)
if err != nil {
return
}
}
func Delete(wr http.ResponseWriter, r *http.Request, p httprouter.Params) {
id := r.URL.Query().Get("id")
if _, exist := notes[id]; exist {
delete(notes, id)
}
data := map[string]interface{}{
"msg": "success",
"data": "",
}
res, err := json.Marshal(data)
if err != nil {
return
}
_, err = wr.Write(res)
if err != nil {
return
}
}
// 生成一个随机id
func genID(content string) string {
rand.Seed(time.Now().UnixNano())
i := rand.Intn(10000)
return fmt.Sprintf("%x", md5.Sum([]byte(content+string(i))))
}看到这里,可能有人要喷了:
哎,你这个玩意,怎么在业务逻辑里各种拼json,写回去response中啊,这块明显是可以复用的逻辑啊!
哎,你这个玩意,获取入参的时候怎么这么挫啊,直接从URL里面拿,别人传啥也不知道,还得自己做参数校验,而且你这么写,和写动态语言有啥区别,根本看不出来入参、出参是什么!
哎,你这个玩意,怎么数据都不入数据库啊,服务一重启,数据就没了!
哎,你这个玩意,怎么连个日志都没有啊,你这线上出问题了,怎么查啊!
没错,这些都是问题,让我们来一个一个解决,首先先写个公共的handler,规范一下我们HTTP handle func 入参、出参。这里通过反射分析handleFunc这个interface来做,这样在注册路由的时候就会对我们的handler函数参数做规范:
type Controller struct {
// handleFunc 的格式需要是func(ctx context.Context, req interface{}) (interface{}, error)
handleFunc interface{}
}
func HTTPHandler(handleFunc interface{}) httprouter.Handle {
controller := &Controller{handleFunc: handleFunc}
checkHandleFunc(handleFunc)
return controller.HandleHTTP
}
func checkHandleFunc(handleFunc interface{}) {
value := reflect.ValueOf(handleFunc)
typeOf := reflect.TypeOf(handleFunc)
if value.Kind() != reflect.Func {
panic("[checkHandleFunc] handleFunc is not a func")
}
...
}我们规定函数的签名统一为:func(ctx context.Context, req interface{}) (interface{}, error),并在CheckHandlerFunc中对interface进行校验确保其符合我们的签名。这里用反射去哪接口的属性即可。
接着,我们把用户入参绑定到定义好的结构体上,让我们不用手动去parserHTTP参数,这个需求本质上是bind功能,原理就是根据不同的Content-Type去用不同的方式解析出http 参数,并通过tag用反射绑定到用户自定义的结构体上。这块自己写比较无聊,因为需要大量重复的Content-Type判断,于是再去github上找找开源库。找到两个star比较多的库:https://github.com/mholt/binding和https://github.com/martini-contrib/binding,但这两个库各有缺点:mholt/binding的问题是你需要显式实现一个FiledMap方法,把参数显示绑定到FieldMap上,这个不能忍,pass。martini-contrib/binding是Martini框架中单独抽出来的binding部分,由于其是Martini框架中抽出来的,很多类型在martini中都被预先改写了,这对对只想用binding功能的我来说不太友好,于是也被pass。
接着,我们去一些star数多的开源web框架上打打主意,gin框架里面的binding包没有上面两个包的缺点。我们可以单独把binding部分fork出来搞一个项目,地址是:https://github.com/yigenshutiao/binding。引用这个包的时候遇到了一些go module的一些小问题。解决方法也比较简单,把binding里go.mod模块的声明改一下即可。
github.com/yigenshutiao/binding: github.com/yigenshutiao/[email protected]: parsing go.mod:
module declares its path as: binding
but was required as: github.com/yigenshutiao/binding
replace binding => github.com/yigenshutiao/binding v0.0.2 // indirect有了bind之后,我们就可以非常开心把http.Request中的参数绑定到我们的结构体上,但是注意由于我们使用RESTful的方式传参,还需要把http.router中Params的参数也绑定到结构体上,这里写了一个Map2Struct的util函数,原理也是根据tag做反射绑定。
if len(p) > 0 {
for i := range p {
param[p[i].Key] = p[i].Value
}
if err := util.ConvertMapToStruct(param, request); err != nil {
return
}
}
request, err := c.bindRequest(r, request)
if err != nil {
return
}绑定了参数之后,可以对用户传进来的参数进行校验,校验要做的工作是在处理业务逻辑之前,提前看参数是否符合我们的预期,这里引入一个叫validator的东西,它的功能如同Python里中装饰器一样,但是原理不太相同。首先我们对每个请求定义一个独立的结构体,并在结构体中加入validatetag,做校验。
type Note struct {
ID string `json:"id" form:"id" validate:"gt=0"`
Content string `json:"content" form:"content" validate:"required"`
StartTime time.Time
UpdateTime time.Time
}这里继续找到了一个开源库:gopkg.in/go-playground/validator.v9。使用也比较简单:初始化一个validator,并调用其Struct方法来做校验即可。这玩意对嵌套的struct也可以做validate,原理是这样的:内部有一些自定义的校验函数,把用户定义的Struct看做一颗嵌套结构的树(如果有嵌套结构体的话),然后去遍历这个树上面的每一个节点,用反射现场去取节点的tag规则,节点的值,并调用其自定义函数进行校验,如果校验失败,直接返回false,否则继续遍历这棵树。
if err := Validate.Struct(request); err != nil {
return
}到现在,调用业务逻辑的前置工作已经做得差不多了,接着,让我们写一个比较恶心的反射函数来调用业务函数
func (c *Controller) callFunc(r *http.Request, request interface{}) (interface{}, error) {
var err error
f := reflect.ValueOf(c.handleFunc)
returnVal := f.Call([]reflect.Value{reflect.ValueOf(r.Context()), reflect.ValueOf(request)})
response := returnVal[0].Interface()
if returnVal[1].Interface() != nil {
var ok bool
err, ok = returnVal[1].Interface().(error)
if !ok {
fmt.Println(returnVal[1].Interface())
}
}
return response, err
}随后,把函数返回值Response2JSON这部分的功能也完善一下,这里就是预先定义好我们的返回格式结构体,并把数据放到data部分,并写入ResponseWriter即可。
type HTTPResponse struct {
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
func response2JSON(ctx context.Context, wr http.ResponseWriter, resp interface{}, err error) string {
respData := &HTTPResponse{
Msg: Success,
Data: resp,
}
if err != nil {
respData = &HTTPResponse{
Msg: Failed,
Data: resp,
}
}
res, err := json.Marshal(respData)
if err != nil {
respData = &HTTPResponse{
Msg: JSONMarshalFailed,
Data: nil,
}
res = []byte(form2JSON(respData))
}
if err := writeResponse(wr, res); err != nil {
logging.Errorf("[response2JSON] writeResponse err :%v", err)
return ""
}
return string(res)
}接着让我们喝一口可乐,再把数据做持久化,这就涉及到数据库以及数据库的选择,数据库有关系型数据库、kv数据库、列式数据库、NoSQL等。
我们这里选MySQL这个关系型数据库做持久化存储,MySQL采用固定的schema存储数据,支持事务,内置多种查询引擎,还有完善的Binlog供数据导出和恢复。虽然我们的应用和事务没什么关系~
接着来看一下如何与数据库交互,每种数据库都会提供一种查询DSL供用户访问数据库,其中最流行的DSL非SQL莫属,我们可以通过MySQL提供的client终端编写SQL来访问数据库里的数据,像这样:
在web程序中,有这几种方式让我们与DB进行交互:SQL、SQL Query Builder和ORM。SQL其实就是在程序中写最原始的SQL与DB进行交互,SQL Query Builder在SQL上做了一层抽象,他规范了查询模式,提供一些方法让你可以拼出SQL,由于其抽象程度不高,其实可以根据SQL builder想象出原始的SQL是什么样;ORM全名是object-relational mappers,做的事情是把数据库中的表映射为类或者结构体,然后用其自带的方法对这些类做和数据库中CRUD等价的操作。
在这几种方式中,ORM的对DB的抽象程度最高,手工管理的成本最低,SQL Builder本质是拼出一个SQL去执行,而直接写SQL的方式需要对返回结果做一些处理。ORM把很多细节都因此掉了,SQL Builder这种方式对于DBA同学来说可能还是有点不直观。比如笔者见过某DBA同学吐槽RD使用SQL builder时不管SQL查询的字段是什么,都先拷贝一段的SQL Builder代码,关键是这段代码大部分内容用不上。。。
最终在本项目中采用了介于SQL和SQL Builder之间的与DB的交互方式。这样做的好处是,写到文件最上方的SQL常量可以给DBA做SQL审计,下面对返回结果进行封装,不必写重复的代码。最终的代码像这样:
// 一段dao层的go代码
const (
getAllNotes = `SELECT * FROM %v WHERE xx=:xx`
)
func GetAllNotes(ctx context.Context) ([]model.NewNote, error) {
var (
err error
res []model.NewNote
params = map[string]interface{}{}
)
if err = GetList(ctx, sourceTableName,
getAllNotes, params, &res); err != nil {
return nil, err
}
return res, nil
}接下来让我们给项目加上日志,目前我们的程序像这张图一样:代码中出了问题根本没有排查的依据。日志是线上排查问题的重要途径。没有日志的程序就像一个黑洞一样,你根本不知道它发生了什么事情。打日志这件事情需要贯彻到具体的业务代码逻辑中,这里为了说明,所以前期没加日志,读者看到了不要模仿,没加日志不是我的本意~让我们赶紧在代码的各个关键地方把日志补上。
go语言自带的log库定义了一个名为std的默认的Logger,它只有三个种Level:Print、Panic、Fatal,我们可以用其提供的New函数自定义一个logger并简单定义一些行为:
var Logger *log.Logger
func InitLog() {
openFile, err := os.OpenFile("./log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
Logger.Printf("[InitLog] open log file failed | err:%v", err)
}
Logger = log.New(openFile, "NOTE:", log.Lshortfile|log.LstdFlags)
}
func Info(v ...interface{}) {
Logger.SetPrefix(INFO)
Logger.Println(v)
}
func Infof(fmt string, v ...interface{}) {
Logger.SetPrefix(INFO)
Logger.Printf(fmt, v)
}
...写到这里程序基本差不多了,现在让我们写个简单的脚本mock一些数据,为一会测试程序的吞吐量做准备:
import requests
def main():
for i in range(10000):
requests.post("http://127.0.0.1:8000/note",{'content': '呵呵'+str(i)})
main()跑完这个脚本之后可以看到MySQL表里满屏幕的呵呵.
现在来用wrk测试一下GET /note接口,跑个性能测试:
* ~ wrk -c 10 -d 10s -t10 http://localhost:8000/note
Running 10s test @ http://localhost:8000/note
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.22s 419.12ms 1.74s 50.00%
Req/Sec 0.09 0.30 1.00 90.91%
11 requests in 10.05s, 21.19MB read
Socket errors: connect 0, read 0, write 0, timeout 7
Requests/sec: 1.09
Transfer/sec: 2.11MB结果有些尴尬,QPS 1,延迟平均1.22s,原因是这个接口每次都会把DB里面全部的数据枚举出来。让我们改造一下接口,传入一个size, offset参数,并让结果按照create_time排序:
~ wrk -c 10 -d 10s -t10 http://localhost:8000/note?size=20&offset=100
Running 10s test @ http://localhost:8000/note?size=20&offset=100
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 55.99ms 75.48ms 455.66ms 83.92%
Req/Sec 50.52 46.93 200.00 83.09%
4252 requests in 10.10s, 9.08MB read
Requests/sec: 420.92
Transfer/sec: 0.90MB接着在表中的的create_time列建一个索引,结果有一些优化:
* ~ wrk -c 10 -d 10s -t10 http://localhost:8000/note?size=20&offset=0
Running 10s test @ http://localhost:8000/note?size=20&offset=1
10 threads and 10 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 26.54ms 31.08ms 196.73ms 84.79%
Req/Sec 61.07 56.17 252.00 80.92%
6001 requests in 10.07s, 12.37MB read
Requests/sec: 595.65
Transfer/sec: 1.23MB还可以根据业务场景把首页热点信息放入cache中,这样服务的吞吐量会进一步增加:
set note_info_0_20 "[{\"ID\":1,\"Content\":\"hello,world\",\"StartTime\":\"2020-11-17T13:19:13Z\",\"UpdateTime\":\"2020-11-17T13:19:13Z\"},\......\{\"ID\":21,\"Content\":\"\xe5\x91\xb5\xe5\x91\xb516\",\"StartTime\":\"2020-11-24T00:09:05Z\",\"UpdateTime\":\"2020-11-24T00:09:05Z\"}]"目前我们在连接db时候,连接的配置都是直接写死在代码中的,这好吗,这很不好。意味着如果你换个环境运行这份代码,还得去代码里翻连接配置,让我们把这些配置抽出来,放到一个公共的地方~
setting, err := mysql.ParseURL("root:[email protected]/notes")
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",})配置加载的原理是把配置写到json文件里面,通过实现定义好的结构体读入变量中,连接db时候去读取这些变量的值。
type MySQLConfig struct {
Database string `json:"database"`
Dsn string `json:"dsn"`
DbDriver string `json:"dbdriver"`
MaxOpenConn int `json:"maxopenconn"`
MaxIdleConn int `json:"maxidleconn"`
ConnMaxLifetime int `json:"connmaxlifetime"`
}
var config MySQLConfig
if err := confutil.LoadConfigJSON(MySQLPath, &config); err != nil {
logging.Fatal("[initDB] load config failed")
}接着,为了看清楚调用服务所花费的时间,我们完善一下日志,在HandleHTTP中defer一发记个时间。同理,如果想看清楚调用缓存花费的时间、调用MySQL花费的时间,在存储公用的调用函数中用这种方式看清即可。
start := time.Now()
defer func() {
procTimeMS := time.Since(start).Seconds() * 1000
logging.Infof("request=%v|resp=%v|proc_time%.2f", request, respStr, procTimeMS)
}()到这里,项目的milestone基本达成,让我们来加个tag
git tag v0.0.1
git push --tag接着给我们的项目加个Makefile,写Makefile个人比较倾向于直接找牛逼的开源框架,看看人家的Makefile是什么写的,然后抄就完事了。比如,之前发现这么一篇文章。https://colobu.com/2017/06/27/Lint-your-golang-code-like-a-mad-man/,这里很多工具都可以放到我们的Makefile中,如果像我这样比较懒的人,可以顺着这位大佬的博客找到他的github,找到star最多的框架,点开Makefile,哇,真是一片宝藏啊: https://github.com/smallnest/rpcx/blob/master/Makefile ;)
最终项目的目录结构像是这样:
├── Makefile
├── README.md
├── common
│ ├── bind.go
│ ├── server.go
│ ├── server_test.go
│ ├── validator.go
│ └── validator_test.go
├── config
│ ├── database.json
│ └── redis.json
├── go.mod
├── go.sum
├── init.go
├── logging
│ └── logger.go
├── logic
│ ├── note.go
│ └── note_test.go
├── main.go
├── main_test.go
├── model
│ └── dto.go
├── route.go
├── storage
│ ├── mock_data.py
│ ├── note.go
│ ├── note_test.go
│ ├── notes.sql
│ └── util
│ └── command.go
└── util
├── confutil.go
└── datautil.go至此,项目本身的代码编写就结束了。我们的标题是从0到1写一个web服务,服务还包括部署相关的内容。这里先按下不表,下篇内容再着重聊聊服务部署、golang性能调优相关的内容吧。
边栏推荐
猜你喜欢

@Autowired注解 --required a single bean, but 2 were found出现的原因以及解决方法

导入FontForge生成字体

leetcode:45. 跳跃游戏II

《分布式微服务电商》专题(一)-项目简介

设备管理中数据聚类处理

铁蛋白颗粒负载雷替曲塞/培美曲塞/磺胺地索辛/金刚烷(科研试剂)

Future-oriented IT infrastructure management architecture - Unified IaaS
![[mysql] 深入分析MySQL版本控制MVCC规则](/img/16/e28641c355d941fda50a6e8b7911ee.png)
[mysql] 深入分析MySQL版本控制MVCC规则

echart 特例-多分组X轴

OPPO Enco X2 迎来秋季产品升级 旗舰体验全面拉满
随机推荐
【语义分割】2017-PSPNet CVPR
npm WARN config global `--global`, `--local` are deprecated. Use `--location=global` instead.
Detailed explanation and use of each module of ansible
【二叉树】二叉搜索树的后序遍历序列
苹果字体查找
二级指针的简单理解
The most complete GIS related software in history (CAD, FME, ArcGIS, ArcGISPro)
(十二)STM32——NVIC中断优先级管理
Public Key Retrieval is not allowed(不允许公钥检索)【解决办法】
C语言写数据库
zip文件协议解析
leetcode 85.最大矩形 单调栈应用
【golang map】 深入了解map内部存储协议
验证码倒计时自定义hooks
Transferrin-modified vincristine-tetrandrine liposomes | transferrin-modified co-loaded paclitaxel and genistein liposomes (reagents)
【图像分类】2019-MoblieNetV3 ICCV
参天生长大模型:昇腾AI如何强壮模型开发与创新之根?
"POJ 3666" Making the Grade problem solution (two methods)
PostgreSQL 介绍
转铁蛋白修饰长春新碱-粉防己碱脂质体|转铁蛋白修饰共载紫杉醇和金雀异黄素脂质体(试剂)