golang:从零开始手写godis,深层理解redis底层

golang:从零开始手写godis,深层理解redis底层

  • 回顾
    • 回顾redis中的数据结构
    • 回顾redis中的io网络模型
  • 分析流程
    • 先看看我项目中的结构
    • 通过main函数分析要实现哪些内容
  • ae文件
    • 常量定义及数据结构设计
    • AddFileHandler
    • AddTimeEvent
    • AeMain
      • Aewait
      • AeProcess
  • obj文件
  • List文件
  • dict文件
    • dict的结构
    • dict的创建、
    • rehash
  • net文件
  • godis文件
    • 各种结构
      • 常量部分
      • server结构
      • client结构
    • initServer
    • tcpAddceptHandler
    • ReadQueryFromClient
    • SendReplyToClient
  • 至此大体流程就结束了,还有一些工作在后面慢慢完善,跑起来试一下

本篇从一个初学者的视角手写redis,如有纰漏请各位大佬指出

回顾

回顾redis中的数据结构

主要有这五种,string,list,set,zset,hash
数据结构方面,本文并不着重讨论。

回顾redis中的io网络模型

redis官方主要提供了linux系统下的版本,而linux版本下io多路复用机制是epoll,本文也只实现linux系统下的epoll
详细的epoll解析可以参考这位大佬的

https://blog.csdn.net/JMW1407/article/details/107963618

好接下来我们开始吧!

分析流程

先看看我项目中的结构


其中godis中的main函数是整个程序的入口

通过main函数分析要实现哪些内容

func main() {
	//这里是获取配置文件路径,为了方便测试我直接写死了
	//path := os.Args[1]
	path := "./config.json"
	config, err := LoadConfig(path)
	if err != nil {
		log.Printf("load config err:%v\n", err)
	}
	//初始化server
	err = initServer(config)
	if err != nil {
		log.Printf("init server err:%v\n", err)
	}
	//注册监听事件
	server.aeLoop.AddFileEvent(server.fd, AE_READABLE, AcceptHandler, nil)
	//注册定时事件
	server.aeLoop.AddTimeEvent(AE_NORMAL, 100, ServerCron, nil)
	log.Println("godis server is up...")
	server.aeLoop.AeMain()
}

其中主要涉及initServer,AcceptHander,ServerCron三个函数
下面我简要阐述一下这三个函数分别是做什么的
1,initServer顾名思义就是初始化一个server,其中主要做了俩件事:一,创建AeLoop。二,创建一个tcp监听端口
2,AcceptHandler是fileEvent的回调函数,处理客户端连接,这里我想引用黑马redis原理篇的ppt,如下
在这里插入图片描述
就是右上角tcpAcceptHandler
3,serverCron并不在上图中,熟悉redis的人知道,redis对于过期键的处理并不是立即删除的,而是定时检查一批过期键删除,或者在查询到过期键时删除,详细见

https://blog.csdn.net/weixin_42201180/article/details/129150967

这里的这个函数就是定期删除key的
后续这三个函数还回在godis文件中详细说明
接下来我们看看具体实现

ae文件

这里会用到"golang.org/x/sys/unix"这个包,其中主要封装了一些系统调用

常量定义及数据结构设计

type FeType int

const (
	AE_READABLE FeType = 1
	AE_WRITABLE FeType = 2
)

type TeType int

const (
	AE_NORMAL TeType = 1
	AE_ONCE   TeType = 2
)

type FileProc func(loop *AeLoop, fd int, extra interface{})
type TimeProc func(loop *AeLoop, fd int, extra interface{})

type AeFileEvent struct {
	fd    int //文件描述符
	mask  FeType //记录事件类型,可读,可写,可读写?
	proc  FileProc //回调函数,如刚才看到的AcceptHandler
	extra interface{} //go中用空接口实现的泛型效果
}

type AeTimeEvent struct {
	id       int
	mask     TeType //重复执行?还是只执行一次?
	when     int64 //ms
	interval int64 //ms //重复执行的事件间隔
	proc     TimeProc //同上
	extra    interface{}
	next     *AeTimeEvent
}

type AeLoop struct {
	FileEvents      map[int]*AeFileEvent
	TimeEvents      *AeTimeEvent
	fileEventFd     int	//文件描述符
	timeEventNextId int
	stop            bool //是否停止程序
}
//用于将自定义的文件事件类型映射到 epoll 的事件类型
var fe2ep [3]uint32 = [3]uint32{0, unix.EPOLLIN, unix.EPOLLOUT}

AddFileHandler

func (loop *AeLoop) AddFileEvent(fd int, mask FeType, proc FileProc, extra interface{}) {
	//epoll ctl
	ev := loop.getEpollMask(fd)
	if ev&fe2ep[mask] != 0 {
		//event is registered
		return
	}
	//这里的情况是如果同一个fd下有读,添加写或有写,添加读就modify,完全没注册就add操作
	op := unix.EPOLL_CTL_ADD
	if ev != 0 {
		op = unix.EPOLL_CTL_MOD
	}
	ev |= fe2ep[mask]
	//添加或修改读写
	err := unix.EpollCtl(loop.fileEventFd, op, fd, &unix.EpollEvent{Fd: int32(fd), Events: ev})
	if err != nil {
		log.Printf("EpollCtl(%d,%d): %v", fd, mask, err)
		return
	}
	//添加新事件
	var fe AeFileEvent
	fe.fd = fd
	fe.mask = mask
	fe.proc = proc
	fe.extra = extra
	loop.FileEvents[getFeKey(fd, mask)] = &fe
	log.Printf("ae add file event fd:%v,mask:%v\n", fd, mask)
}

这里贴出其中调用的函数

func getFeKey(fd int, mask FeType) int {
	//可读+ 反之- 简化流程
	if mask == AE_READABLE {
		return fd
	} else {
		return fd * -1
	}
}

func (loop *AeLoop) getEpollMask(fd int) uint32 {
	var ev uint32
	if loop.FileEvents[getFeKey(fd, AE_READABLE)] != nil {
		ev |= fe2ep[AE_READABLE]
	}
	if loop.FileEvents[getFeKey(fd, AE_WRITABLE)] != nil {
		ev |= fe2ep[AE_WRITABLE]
	}
	return ev
}

我来解释一下其中的几个位运算
首先得知道
var fe2ep [3]uint32 = [3]uint32{0, unix.EPOLLIN, unix.EPOLLOUT}
unix.EPOLLIN = 1,unix.EPOLLOUT = 100(二进制下)
用 |= 可以表示可读可写可读写三种情况
ev & fe2ep[mask] !=说明该文件描述符已经有对应的事件所以op是mod不是add

AddTimeEvent

func GetMsTime() int64 {
	return time.Now().UnixNano() / 1e6
}

func (loop *AeLoop) AddTimeEvent(mask TeType, interval int64, proc TimeProc, extra interface{}) int {
	id := loop.timeEventNextId
	loop.timeEventNextId++
	var te AeTimeEvent
	te.id = id
	te.mask = mask
	te.interval = interval
	te.when = GetMsTime() + interval
	te.proc = proc
	te.extra = extra
	te.next = loop.TimeEvents
	loop.TimeEvents = &te
	return id
}

介于篇幅有限remove的相关逻辑我就先不贴出来了

AeMain

func (loop *AeLoop) AeMain() {
	for loop.stop != true {
		tes, fes := loop.AeWait()
		loop.AeProcess(tes, fes)
	}
}

简单来说
其中AeWait是等待事件发生
AeProcess是具体事件调用各自的回调函数
具体逻辑如下

Aewait

func (loop *AeLoop) AeWait() (tes []*AeTimeEvent, fes []*AeFileEvent) {
	timeout := loop.nearestTime() - GetMsTime()
	if timeout <= 0 {
		timeout = 10
	}
	var events [128]unix.EpollEvent
	n, err := unix.EpollWait(loop.fileEventFd, events[:], int(timeout))
	//忽略系统中断
	if err != nil && err != unix.EINTR {
		log.Printf("EpollWait Warnning: %v", err)
	}
	if n > 0 {
		log.Printf("ae get %v epoll events", n)
	}
	for i := 0; i < n; i++ {
		if events[i].Events&unix.EPOLLIN != 0 {
			fe := loop.FileEvents[getFeKey(int(events[i].Fd), AE_READABLE)]
			if fe != nil {
				fes = append(fes, fe)
			}
		}
		if events[i].Events&unix.EPOLLOUT != 0 {
			fe := loop.FileEvents[getFeKey(int(events[i].Fd), AE_WRITABLE)]
			if fe != nil {
				fes = append(fes, fe)
			}
		}
	}
	now := GetMsTime()
	p := loop.TimeEvents
	for p != nil {
		if p.when < now {
			tes = append(tes, p)
		}
		p = p.next
	}
	return
}

AeProcess

func (loop *AeLoop) AeProcess(tes []*AeTimeEvent, fes []*AeFileEvent) {
	for _, te := range tes {
		te.proc(loop, te.id, te.extra)
		if te.mask == AE_ONCE {
			loop.RemoveTimeEvent(te.id)
		} else {
			te.when = GetMsTime() + te.interval
		}
	}
	if len(fes) > 0 {
		log.Println("ae is processing file events")
		for _, fe := range fes {
			fe.proc(loop, fe.fd, fe.extra)
		}
	}
}

obj文件

Gobj结构

type Gtype uint8

const (
	GSTR  Gtype = 0x00
	GLIST Gtype = 0x01
	GSET  Gtype = 0x02
	GZSET Gtype = 0x03
	GDICT Gtype = 0x04
)

type Gval interface{}
type Gobj struct {
	//类型
	Type_    Gtype
	//具体的值
	Val_     Gval
	//引用计数,计数为0时释放空间
	refCount int
}

List文件

我的实现就是简单的list,这里就不展开说了

dict文件

dict因为是涉及到渐进式hash的,所以这里展开讲讲

dict的结构

type Entry struct {
	Key  *Gobj
	Val  *Gobj
	next *Entry
}

type htable struct {
	table []*Entry
	size  int64
	mask  int64
	used  int64
}
type DictType struct {
	//用于计算hash值的函数
	HashFunc  func(key *Gobj) int64
	//用于比较值的函数
	EqualFunc func(key1, key2 *Gobj) bool
}

type Dict struct {
	DictType
	//俩个table是为了方便迁移
	hts       [2]*htable
	//记录rehash的进度,创建时为—1
	rehashidx int64
}

dict的创建、

func DictCreate(dictType DictType) *Dict {
	var dict Dict
	dict.DictType = dictType
	dict.rehashidx = -1
	return &dict
}

rehash

func (dict *Dict) isRehashing() bool {
	return dict.rehashidx != -1
}

func (dict *Dict) rehashStep() {
	//TODO check iterators
	dict.rehash(DEFAULT_STEP)
}
//判断是否需要扩容
func (dict *Dict) expandIfNeeded() error {
	//正在扩容不需要再判断了
	if dict.isRehashing() {
		return nil
	}
	//刚创建的dict
	if dict.hts[0] == nil {
		return dict.expend(INIT_SIZE)
	}
	//可以扩容的情况
	if (dict.hts[0].used > dict.hts[0].size) && (dict.hts[0].used/dict.hts[0].size > FORCE_RATIO) {
		return dict.expend(dict.hts[0].size * GROW_RATIO)
	}
	return nil
}

func nextPower(size int64) int64 {
	//寻找合适的扩容后的大小
	for i := INIT_SIZE; i < math.MaxInt64; i *= 2 {
		if i >= size {
			return i
		}
	}
	return -1
}

func (dict *Dict) expend(size int64) error {
	sz := nextPower(size)
	//虽然正常来说满足以下条件的不会执行expand的函数
	//因为在expandIfNeed那一步就过滤了这个情况
	//为了代码的健壮性这里还是判断一下
	if dict.isRehashing() || (dict.hts[0] != nil && dict.hts[0].size >= sz) {
		return EX_ERR
	}
	//为rehash后的table分配空间
	var ht htable
	ht.size = sz
	ht.mask = sz - 1
	ht.table = make([]*Entry, sz)
	ht.used = 0
	//检查是否初始化
	if dict.hts[0] == nil {
		dict.hts[0] = &ht
		return nil
	}
	//开始rehash
	dict.hts[1] = &ht
	dict.rehashidx = 0
	return nil
}

func (dict *Dict) rehash(step int) {
	 //step是渐进式rehash每次迁移的元素的个数
	for step > 0 {
		//证明原表中没有了数据	
		if dict.hts[0].used == 0 {
			dict.hts[0] = dict.hts[1]
			dict.hts[1] = nil
			dict.rehashidx = -1
			return
		}
		for dict.hts[0].table[dict.rehashidx] == nil {
			dict.rehashidx++
		}
		//迁移所有所有元素
		entry := dict.hts[0].table[dict.rehashidx]
		for entry != nil {
			ne := entry.next
			idx := dict.HashFunc(entry.Key) & dict.hts[1].mask
			entry.next = dict.hts[1].table[idx]
			dict.hts[1].table[idx] = entry
			dict.hts[0].used -= 1
			dict.hts[1].used += 1
			entry = ne
		}
		dict.hts[0].table[dict.rehashidx] = nil
		dict.rehashidx += 1
		step -= 1
	}
}

rehash流程;当在添加一个键值对的时候调用 expendIfNeed(判断是否需要扩容) ——> expand(设置rehash的idx为0开始rehash)——> rehash(结束时将ht[1]挂到ht[0]上,rehashidx重新设为-1)

之后每次执行插入更新删除操作时都会

	if dict.isRehashing() {
		dict.rehashStep()
	}

这就是渐进式rehash
如果正在扩容,查询时也会在俩个ht中遍历

	for i := 0; i <= 1; i++ {
		idx := h & dict.hts[i].mask
		e := dict.hts[i].table[idx]
		for e != nil {
			if dict.EqualFunc(e.Key, key) {
				return e
			}
			e = e.next
		}
		//只有正在扩容的dict才有俩个ht
		if !dict.isRehashing() {
			break
		}
	}

net文件

这里主是对unix包中syscall的再一次封装

import (
	"golang.org/x/sys/unix"
	"log"
)

const BACKLOG int = 64

func Accept(fd int) (int, error) {
	nfd, _, err := unix.Accept(fd)
	//ignore client addr for now
	return nfd, err
}

func Connect(host [4]byte, port int) (int, error) {
	s, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)
	if err != nil {
		log.Printf("init socket error: %v\n", err)
		return -1, err
	}
	var addr unix.SockaddrInet4
	addr.Addr = host
	addr.Port = port
	err = unix.Connect(s, &addr)
	if err != nil {
		log.Printf("connect error: %v\n", err)
		return -1, err
	}
	return s, nil
}

func Read(fd int, p []byte) (n int, err error) {
	return unix.Read(fd, p)
}

func Write(fd int, p []byte) (n int, err error) {
	return unix.Write(fd, p)
}

func Close(fd int) {
	unix.Close(fd)
}

func TcpServer(port int) (int, error) {
	s, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)
	if err != nil {
		log.Printf("init socket error: %v\n", err)
		return -1, nil
	}
	err = unix.SetsockoptInt(s, unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
	if err != nil {
		log.Printf("set socket error: %v\n", err)
		unix.Close(s)
		return -1, err
	}
	var addr unix.SockaddrInet4
	//golang.syscall will handle htons
	addr.Port = port
	err = unix.Bind(s, &addr)
	if err != nil {
		log.Printf("bind error: %v\n", err)
		unix.Close(s)
		return -1, err
	}
	err = unix.Listen(s, BACKLOG)
	if err != nil {
		log.Printf("listen error: %v\n", err)
		unix.Close(s)
		return -1, err
	}
	return s, nil
}

godis文件

这里对着这个ppt看
在这里插入图片描述
我这里先说说我的godis中的函数分别对应图上哪里
initServer是注册serverSocket监听
clientSocket由文章开头提到的tcpAddeptHandler负责注册
io多路复用由linux系统实现(这部分在ae包中)
readQueryFromClient,处理命令请求
sendReplyToClient

各种结构

常量部分

const (
	COMMAND_UNKNOWN CmdType = 0x00
	//处理类似 SET key val 的命令
	COMMAND_INLINE  CmdType = 0x01
	//处理resp协议命令
	COMMAND_BULK    CmdType = 0x02
)

const (
	GODIS_IO_BUF     int = 1024 * 16
	GODIS_MAX_BULK   int = 1024 * 4
	GODIS_MAX_INLINE int = 1024 * 4
)

server结构

type GodisDB struct {
	//存储键值
	data   *Dict
	//存储键的过期时间
	expire *Dict
}

type GodisServer struct {
	//监听套接字
	fd      int
	//端口
	port    int
	//存储数据
	db      *GodisDB
	//连接的客户端
	clients map[int]*GodisClient
	aeLoop  *AeLoop
}

client结构

type GodisClient struct {
	//连接套接字
	fd       int
	db       *GodisDB
	//解析命令
	args     []*Gobj
	reply    *List
	sentLen  int
	queryBuf []byte
	queryLen int
	//set get exp?
	cmdTy    CmdType
	//处理RESP命令
	bulkNum  int
	bulkLen  int
}

initServer

在函数初始化时调用initServer

func initServer(config *Config) error {
	//server.port = config.Port
	server.port = 6767
	server.clients = make(map[int]*GodisClient)
	server.db = &GodisDB{
		data:   DictCreate(DictType{HashFunc: GStrHash, EqualFunc: GStrEqual}),
		expire: DictCreate(DictType{HashFunc: GStrHash, EqualFunc: GStrEqual}),
	}
	var err error
	if server.aeLoop, err = AeLoopCreate(); err != nil {
		return err
	}
	server.fd, err = TcpServer(server.port)
	return err
}
//这个在ae包中
func AeLoopCreate() (*AeLoop, error) {
	epollFd, err := unix.EpollCreate1(0)
	if err != nil {
		return nil, err
	}
	return &AeLoop{
		FileEvents:      make(map[int]*AeFileEvent),
		fileEventFd:     epollFd,
		timeEventNextId: 1,
		stop:            false,
	}, nil
}
//这个在net包中
func TcpServer(port int) (int, error) {
	s, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)
	if err != nil {
		log.Printf("init socket error: %v\n", err)
		return -1, nil
	}
	err = unix.SetsockoptInt(s, unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
	if err != nil {
		log.Printf("set socket error: %v\n", err)
		unix.Close(s)
		return -1, err
	}
	var addr unix.SockaddrInet4
	addr.Port = port
	err = unix.Bind(s, &addr)
	if err != nil {
		log.Printf("bind error: %v\n", err)
		unix.Close(s)
		return -1, err
	}
	err = unix.Listen(s, BACKLOG)
	if err != nil {
		log.Printf("listen error: %v\n", err)
		unix.Close(s)
		return -1, err
	}
	return s, nil
}

tcpAddceptHandler

func AcceptHandler(loop *AeLoop, fd int, extra interface{}) {
	cfd, err := Accept(fd)
	if err != nil {
		log.Printf("client %v accept err:%v\n", fd, err)
		return
	}
	client := CreateClient(cfd)
	//TODO ; check max clients limit
	server.clients[cfd] = client
	//发现注册客户端
	server.aeLoop.AddFileEvent(cfd, AE_READABLE, ReadQueryFromClient, client)
	log.Printf("client accept %v\n", cfd)
}

可读事件是为了在客户端发来信息时服务端高敏感的感知,当客户端发送数据时,内核会检测到数据到达接收缓冲区,并通知服务端处理。
可写事件,让内核监听套接字的 可写状态。当内核发送缓冲区有空闲空间时(即网络可以接受新数据),通知服务端写入数据。在后面回复客户端才会注册可写事件
CreateClient就是为client中的字段分配空间并复制
这里传入回调函数的ReadQueryFromClient是读取命令的关键

ReadQueryFromClient

func ReadQueryFromClient(loop *AeLoop, fd int, extra interface{}) {
	client := extra.(*GodisClient)
	//当没读入回车且小于缓冲区的容量时继续读
	if len(client.queryBuf)-client.queryLen < GODIS_MAX_BULK {
		client.queryBuf = append(client.queryBuf, make([]byte, GODIS_MAX_BULK)...)
	}
	n, err := Read(fd, client.queryBuf[client.queryLen:])
	if err != nil {
		log.Printf("client %v read err %v", fd, err)
		//如果出错释放客户端
		freeClient(client)
		return
	}
	client.queryLen += n
	log.Printf("read %v bytes from client:%v\n", n, client.fd)
	log.Printf("ReadQueryFromClient,queryBuf:%x\n", client.queryBuf[:client.queryLen])
	//处理读在queryBuf中的内容并执行对应操作
	err = ProcessQueryBuf(client)
	if err != nil {
		log.Printf("process query buf err:%v\n", err)
		freeClient(client)
		return
	}
}

func ProcessQueryBuf(client *GodisClient) error {
	for client.queryLen > 0 {
		if client.cmdTy == COMMAND_UNKNOWN {
			if client.queryBuf[0] == '*' {
				//*3
				//$3
				//set
				//$3
				//key
				//$5
				//value
				client.cmdTy = COMMAND_BULK
			} else {
				//set key value
				client.cmdTy = COMMAND_INLINE
			}
		}
		var ok bool
		var err error
		if client.cmdTy == COMMAND_INLINE {
			ok, err = handleInlineBuf(client)
		} else if client.cmdTy == COMMAND_BULK {
			ok, err = handleBulkBuf(client)
		} else {
			return errors.New("unknown command type")
		}
		//到现在已经解析出queryBuf中的内容存在c.args中了
		if err != nil {
			return err
		}
		if ok {
			if len(client.args) == 0 {
				//命令无效,重置命令
				resetClient(client)
			} else {
				//处理命令
				ProcessCommand(client)
			}
		} else {
			//cmd in completed
			break
		}
	}
	return nil
}

func ProcessCommand(c *GodisClient) {
	cmdStr := c.args[0].StrVal()
	log.Println("process command:", cmdStr)
	if cmdStr == "exit" {
		freeClient(c)
		return
	}
	//var cmd *GodisCommand
	cmd := lookupCommand(cmdStr)
	if cmd == nil {
		c.AddReplyStr("-ERR: unknown command\r\n")
		resetClient(c)
		return
	} else if cmd.arity != len(c.args) {
		c.AddReplyStr("-ERR: wrong number of args\r\n")
		resetClient(c)
		return
	}
	//处理命令并将回复加入client.reply中
	cmd.proc(c)
	//重置命令
	resetClient(c)
}
//遍历找到对应的命令
func lookupCommand(cmdStr string) *GodisCommand {
	for _, c := range cmdTable {
		if c.name == cmdStr {
			return &c
		}
	}
	return nil
}
//篇幅有限就举个get命令的例子吧
func getCommand(c *GodisClient) {
	key := c.args[1]
	val := findKeyRead(key)
	if val == nil {
		//TODO : extract shared.strings
		c.AddReplyStr("$-1\r\n")
	} else if val.Type_ != GSTR {
		c.AddReplyStr("-ERR: wrong type\r\n")
	} else {
		str := val.StrVal()
		c.AddReplyStr(fmt.Sprintf("$%d\r\n%v\r\n", len(str), str))
	}
}
//在AddReplyStr中会注册可写事件
func (c *GodisClient) AddReply(o *Gobj) {
	c.reply.Append(o)
	o.IncrRefCount()
	server.aeLoop.AddFileEvent(c.fd, AE_WRITABLE, SendReplyToClient, c)
}

SendReplyToClient

func SendReplyToClient(loop *AeLoop, fd int, extra interface{}) {
	client := extra.(*GodisClient)
	log.Printf("send reply to client:%v\n,reply len:%v", client.fd, client.reply.Length())
	for client.reply.Length() > 0 {
		rep := client.reply.First()
		buf := []byte(rep.Val.StrVal())
		bufLen := len(buf)
		if client.sentLen < bufLen {
			n, err := Write(fd, buf[client.sentLen:])
			if err != nil {
				log.Printf("client send err:%v\n", err)
				freeClient(client)
				return
			}
			client.sentLen += n
			log.Printf("send %v bytes to client:%v\n", n, client.fd)
			if client.sentLen == bufLen {
				client.reply.DelNode(rep)
				rep.Val.DecrRefCount()
				client.sentLen = 0
			} else {
				break
			}
		}
	}
	if client.reply.Length() == 0 {
		//client.sentLen = 0
		loop.RemoveFileEvent(fd, AE_WRITABLE)
	}
}

至此大体流程就结束了,还有一些工作在后面慢慢完善,跑起来试一下

在这里插入图片描述
在这里插入图片描述
本菜鸟的代码仓库https://github.com/xiaobaicai66695/godis
博主大一,很多不规范的地方还请大家多多包含指正

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值