记得大学刚毕业那年看了侯俊杰的《深入浅出MFC》,就对深入浅出这四个字特别偏好,并且成为了自己对技术的要求标准——对于技术的理解要足够的深刻以至于可以用很浅显的道理给别人讲明白。以下内容为个人见解,如有雷同,纯属巧合,如有错误,烦请指正。

本文基于kubernetes1.11版本,后续会根据kubernetes版本更新及时更新文档,所有代码引用为了简洁都去掉了日志打印相关的代码,尽量只保留有价值的内容。

在开始本文内容前,请先阅读《深入浅出kubernetes之client-go的indexer》。


目录

DeltaFIFO简介

DeltaFIFO实现


DeltaFIFO简介

Informer是client-go的重要组成部分,在了解client-go之前,了解一下Informer的实现是很有必要的,下面引用了官方的图,可以看到Informer在client-go中的位置。

由于Informer比较庞大,所以我们把它拆解成接独立的模块分析,本文分析的就是DeltaFIFO模块。在理解DeltaFIFO前,我们需要知道什么是Delta。学过微积分的同学肯定都比较好理解,可以简单的理解为变化。那我们看看client-go是如何定义Delta的:

// 代码源自client-go/tools/cache/delta_fifo.go,下面类型出现顺序是为了方便读者理解
type Delta struct {Type   DeltaType               // Delta类型,比如增、减,后面有详细说明Object interface{}             // 对象,Delta的粒度是一个对象
}
type DeltaType string              // Delta的类型用字符串表达
const ( Added   DeltaType = "Added"    // 增加Updated DeltaType = "Updated"  // 更新Deleted DeltaType = "Deleted"  // 删除Sync DeltaType = "Sync"        // 同步
)
type Deltas []Delta                // Delta数组

Delta其实就是kubernetes系统中对象的变化(增、删、改、同步),FIFO比较好理解,是一个先入先出的队列,那么DeltaFIFO就是一个按序的(先入先出)kubernetes对象变化的队列,这就非常符合上面图中DeltaFIFO所在位置的功能了。

既然说到了DeltaFIFO,我们再说一说如下几个类型,因为他们定义在DeltaFIFO的文件中,而且在很多地方应用:

// 代码源自client-go/tools/cache/delta_fifo.go
// 这是一个非常通用的接口类型,只定义了一个接口函数,就是返回所有的keys。
type KeyLister interface {ListKeys() []string
}
// 这也是一个非常通用的接口类型,只定义了一个接口函数,就是通过key获取对象
type KeyGetter interface {GetByKey(key string) (interface{}, bool, error)
}
// 这个接口类型就是上面两个接口类型的组合了
type KeyListerGetter interface {KeyListerKeyGetter
}

为什么要提这几个类型的,首先是后面的章节会用到, 同时也是对《深入浅出kubernetes之client-go的indexer》的补充。有没有发现上面两个接口在client-go.tools.cache.Store这个接口类型中也存在,也就是说实现了Store接口的类型同时也实现了上面三个接口,golang这种没有显式的多继承一时半会儿好难接受。上面三个接口基本上就是kv的标准接口,但凡是通过kv方式访问的对象(存储、队列、索引等)多半具备以上接口。肯定有人会问直接使用具体的类型不就完了么,定义这些有什么用?答案很简单,当你需要对kv的对象只读但是不关心具体实现时就用上了~

接下来再来认识一个类型:

// 代码源自client-go/tools/cache/fifo.go
// 这个才是FIFO的抽象,DeltaFIFO只是FIFO的一种实现。
type Queue interface {Store                                    // 实现了存储接口,这个很好理解,FIFO也是一种存储Pop(PopProcessFunc) (interface{}, error) // 在存储的基础上增加了Pop接口,用于弹出对象AddIfNotPresent(interface{}) error       // 对象如果不在队列中就添加HasSynced() bool                         // 通过Replace()放入第一批对象到队列中并且已经被Pop()全部取走Close()                                  // 关闭队列
}

《深入浅出kubernetes之client-go的indexer》已经对Store做了详细说明,读者可以先进行了解再继续本文的内容。Queue是在Store基础上扩展了Pop接口可以让对象有序的弹出,Indexer是在Store基础上建立了索引,可以快速检索对象。

DeltaFIFO实现

我们先来看看DeltaFIFO的类型定义:

// 代码源自client-go/tools/cache/delta_fifo.go
type DeltaFIFO struct {lock sync.RWMutex             // 读写锁,因为涉及到同时读写,读写锁性能要高cond sync.Cond                // 给Pop()接口使用,在没有对象的时候可以阻塞,内部锁复用读写锁items map[string]Deltas       // 这个应该是Store的本质了,按照kv的方式存储对象,但是存储的是对象的Deltas数组queue []string                // 这个是为先入先出实现的,存储的就是对象的键populated bool                // 通过Replace()接口将第一批对象放入队列,或者第一次调用增、删、改接口时标记为trueinitialPopulationCount int    // 通过Replace()接口将第一批对象放入队列的对象数量keyFunc KeyFunc               // 对象键计算函数,在Indexer那篇文章介绍过knownObjects KeyListerGetter  // 前面介绍就是为了这是用,该对象指向的就是Indexer,closed     bool               // 是否已经关闭的标记closedLock sync.Mutex         // 专为关闭设计的所,为什么不复用读写锁?
}

看过《深入浅出kubernetes之client-go的indexer》再看上面的定义就比较理解,DeltaFIFO的计算对象键的函数略有不同,即便创建DeltaFIFO需要给计算对象键的函数:

// 代码源自client-go/tools/cache/delta_fifo.go
func (f *DeltaFIFO) KeyOf(obj interface{}) (string, error) {// 先用Deltas做一次强行转换if d, ok := obj.(Deltas); ok {if len(d) == 0 {return "", KeyError{obj, ErrZeroLengthDeltasObject}}// 只用最新版本的对象就可以了obj = d.Newest().Object}// 后面的我们在《深入浅出kubernetes之client-go的indexer》介绍了,此处不赘述if d, ok := obj.(DeletedFinalStateUnknown); ok {return d.Key, nil}return f.keyFunc(obj)
}

DeltaFIFO的计算对象键的方式为什么要先做一次Deltas的类型转换呢?原因很简单,那就是从DeltaFIFO.Pop()出去的对象很可能还要再添加进来(比如处理失败需要再放进来),此时添加的对象就是已经封装好的Deltas。

既然DeltaFIFO是Store的一种实现,简单过一过DeltaFIFO相应的函数实现(简单的函数放在一起介绍,重点函数专门介绍):

// 代码源自client-go/tools/cache/delta_fifo.go
// 假设读者已经度过《深入浅出kubernetes之client-go的indexer》,注释变得清爽一点// 添加对象接口
func (f *DeltaFIFO) Add(obj interface{}) error {f.lock.Lock()defer f.lock.Unlock()f.populated = true  // 队列第一次写入操作都要设置标记return f.queueActionLocked(Added, obj)
}
// 更新对象接口
func (f *DeltaFIFO) Update(obj interface{}) error {f.lock.Lock()defer f.lock.Unlock()f.populated = true  // 队列第一次写入操作都要设置标记return f.queueActionLocked(Updated, obj)
}
// 删除对象接口,这个函数貌似有点大,就注释多点吧
func (f *DeltaFIFO) Delete(obj interface{}) error {id, err := f.KeyOf(obj)if err != nil {return KeyError{obj, err}}f.lock.Lock()defer f.lock.Unlock()f.populated = true  // 队列第一次写入操作都要设置标记// 此处是需要注意的,knownObjects就是Indexer,里面存有已知全部的对象if f.knownObjects == nil {// 在没有Indexer的条件下只能通过自己存储的对象查一下if _, exists := f.items[id]; !exists {return nil}} else {// 自己和Indexer里面有任何一个有这个对象多算存在_, exists, err := f.knownObjects.GetByKey(id)_, itemsExist := f.items[id]if err == nil && !exists && !itemsExist {return nil}}return f.queueActionLocked(Deleted, obj)
}
// 列举对象键接口
func (f *DeltaFIFO) ListKeys() []string {f.lock.RLock()defer f.lock.RUnlock()list := make([]string, 0, len(f.items))for key := range f.items {list = append(list, key)}return list
}
// 列举对象接口
func (f *DeltaFIFO) List() []interface{} {f.lock.RLock()defer f.lock.RUnlock()return f.listLocked()
}
// 列举对象的具体实现
func (f *DeltaFIFO) listLocked() []interface{} {list := make([]interface{}, 0, len(f.items))for _, item := range f.items {item = copyDeltas(item)list = append(list, item.Newest().Object)}return list
}
// 获取对象接口,这个有意思哈,用对象获取对象?如果说用Service对象获取Pod对象是不是就能接受了?
// 因为他们的对象键是相同的
func (f *DeltaFIFO) Get(obj interface{}) (item interface{}, exists bool, err error) {key, err := f.KeyOf(obj)if err != nil {return nil, false, KeyError{obj, err}}return f.GetByKey(key)
}
// 通过对象键获取对象
func (f *DeltaFIFO) GetByKey(key string) (item interface{}, exists bool, err error) {f.lock.RLock()defer f.lock.RUnlock()d, exists := f.items[key]if exists {d = copyDeltas(d)}return d, exists, nil
}
// 判断是否关闭
func (f *DeltaFIFO) IsClosed() bool {f.closedLock.Lock()defer f.closedLock.Unlock()if f.closed {return true}return false
}

上面的实现因为比较简单,而且大部分函数都用到了queueActionLocked()函数,所以我要对这个函数做比较细致的说明:

// 代码源自client-go/tools/cache/delta_fifo.go
// 从函数名称来看把“动作”放入队列中,这个动作就是DeltaType,而且已经加锁了
func (f *DeltaFIFO) queueActionLocked(actionType DeltaType, obj interface{}) error {// 前面提到的计算对象键的函数id, err := f.KeyOf(obj)if err != nil {return KeyError{obj, err}}// 如果是同步,并且对象未来会被删除,那么就直接返回,没必要记录这个动作了// 肯定有人会问为什么Add/Delete/Update这些动作可以,因为同步对于已经删除的对象是没有意义的// 已经删除的对象后续跟添加、更新有可能,因为同名的对象又被添加了,删除也是有可能// 删除有些复杂,后面会有说明if actionType == Sync && f.willObjectBeDeletedLocked(id) {return nil}// 同一个对象的多次操作,所以要追加到Deltas数组中newDeltas := append(f.items[id], Delta{actionType, obj})// 合并操作,去掉冗余的deltanewDeltas = dedupDeltas(newDeltas)// 判断对象是否已经存在_, exists := f.items[id]// 合并后操作有可能变成没有Delta么?后面的代码分析来看应该不会,所以暂时不知道这个判断目的if len(newDeltas) > 0 {// 如果对象没有存在过,那就放入队列中,如果存在说明已经在queue中了,也就没必要再添加了if !exists {f.queue = append(f.queue, id)}// 更新Deltas数组,通知所有调用Pop()的人f.items[id] = newDeltasf.cond.Broadcast()} else if exists {// 直接把对象删除,这段代码我不知道什么条件会进来,因为dedupDeltas()肯定有返回结果的// 后面会有dedupDeltas()详细说明delete(f.items, id)}return nil
}

首先我们想想为什么每个对象一个Deltas而不是Delta?对一个对象的多个操作,什么操作可以合并?

  1. DeltaFIFO生产者和消费者是异步的,如果同一个目标的频繁操作,前面操作还缓存在队列中的时候,那么队列就要缓冲对象的所有操作,那可以将多个操作合并么?这是下面讨论的了;

  2. 对于更新这种类型的操作在没有全量基础的情况下是没法合并的,同时我们还不知道具体是什么类型的对象,所以能合并的也就是有添加/删除,两个添加/删除操作其实可以视为一个;

那我们就开始看看合并操作的具体实现:

// 代码源自client-go/tools/cache/delta_fifo.go
func dedupDeltas(deltas Deltas) Deltas {// 小于2个delta,那就是1个呗,没啥好合并的n := len(deltas)if n < 2 {return deltas}// 取出最后两个a := &deltas[n-1]b := &deltas[n-2]// 判断如果是重复的,那就删除这两个delta把合并后的追加到Deltas数组尾部if out := isDup(a, b); out != nil {d := append(Deltas{}, deltas[:n-2]...)return append(d, *out)}return deltas
}
// 判断两个Delta是否是重复的
func isDup(a, b *Delta) *Delta {// 只有一个判断,只能判断是否为删除类操作,和我们上面的判断相同// 这个函数的本意应该还可以判断多种类型的重复,当前来看只能有删除这一种能够合并if out := isDeletionDup(a, b); out != nil {return out}return nil
}
// 判断是否为删除类的重复
func isDeletionDup(a, b *Delta) *Delta {// 二者都是删除那肯定有一个是重复的if b.Type != Deleted || a.Type != Deleted {return nil}// 理论上返回最后一个比较好,但是对象已经不再系统监控范围,前一个删除状态是好的if _, ok := b.Object.(DeletedFinalStateUnknown); ok {return a}return b
}

因为系统对于删除的对象有DeletedFinalStateUnknown这个状态,所以会存在两次删除的情况,但是两次添加同一个对象由于apiserver可以保证对象的唯一性,所以处理中就没有考虑合并两次添加操作。

接下来我们来看看Replace()函数的实现,这个也是Store定义的接口:

// 代码源自client-go/tools/cache/delta_fifo.go
func (f *DeltaFIFO) Replace(list []interface{}, resourceVersion string) error {f.lock.Lock()defer f.lock.Unlock()keys := make(sets.String, len(list))// 遍历所有的输入目标for _, item := range list {// 计算目标键key, err := f.KeyOf(item)if err != nil {return KeyError{item, err}}// 记录处理过的目标键,采用set存储,是为了后续快速查找keys.Insert(key)// 因为输入是目标全量,所以每个目标相当于重新同步了一次if err := f.queueActionLocked(Sync, item); err != nil {return fmt.Errorf("couldn't enqueue object: %v", err)}}// 如果没有存储的话,自己存储的就是所有的老对象,目的要看看那些老对象不在全量集合中,那么就是删除的对象了if f.knownObjects == nil {// 遍历所有的元素for k, oldItem := range f.items {// 这个目标在输入的对象中存在就可以忽略if keys.Has(k) {continue}// 输入对象中没有,说明对象已经被删除了。var deletedObj interface{}if n := oldItem.Newest(); n != nil {deletedObj = n.Object}// 终于看到哪里用到DeletedFinalStateUnknown了,队列中存储对象的Deltas数组中// 可能已经存在Delete了,避免重复,采用DeletedFinalStateUnknown这种类型if err := f.queueActionLocked(Deleted, DeletedFinalStateUnknown{k, deletedObj}); err != nil {return err}}// 如果populated还没有设置,说明是第一次并且还没有任何修改操作执行过if !f.populated {f.populated = truef.initialPopulationCount = len(list)  // 记录第一次通过来的对象数量}return nil}// 下面处理的就是检测某些目标删除但是Delta没有在队列中// 从存储中获取所有对象键knownKeys := f.knownObjects.ListKeys()queuedDeletions := 0for _, k := range knownKeys {// 对象还存在那就忽略if keys.Has(k) {continue}// 获取对象deletedObj, exists, err := f.knownObjects.GetByKey(k)if err != nil {deletedObj = nilglog.Errorf("Unexpected error %v during lookup of key %v, placing DeleteFinalStateUnknown marker without object", err, k)} else if !exists {deletedObj = nilglog.Infof("Key %v does not exist in known objects store, placing DeleteFinalStateUnknown marker without object", k)}// 累积删除的对象数量queuedDeletions++// 把对象删除的Delta放入队列if err := f.queueActionLocked(Deleted, DeletedFinalStateUnknown{k, deletedObj}); err != nil {return err}    }// 和上面的代码差不多,只是计算initialPopulationCount值的时候增加了删除对象的数量if !f.populated {f.populated = truef.initialPopulationCount = len(list) + queuedDeletions}return nil
}

从Replace()的实现来看,主要用于实现对象的全量更新。这个可以理解为DeltaFIFO在必要的时刻做一次全量更新,这个时刻可以是定期的,也可以是事件触发的。由于DeltaFIFO对外输出的就是所有目标的增量变化,所以每次全量更新都要判断对象是否已经删除,因为在全量更新前可能没有收到目标删除的请求。这一点与cache不同,cache的Replace()相当于重建,因为cache就是对象全量的一种内存映射,所以Replace()就等于重建。

那我来问题一个非常有水平的问题,为什么knownObjects为nil时需要对比队列和对象全量来判断对象是否删除,而knownObjects不为空的时候就不需要了?如果读者想判断自己是否已经全部理解可以不看下面自己想想。

我们前面说过,knownObjects就是Indexer(具体实现是cache),而开篇的那副图已经非常明确的描述了二者以及使用之间的关系。也就是说knownObjects有的对象就是使用者知道的所有对象,此时即便队列(DeltaFIFO)中有相应的对象,在更新的全量对象中又被删除了,那就没必要通知使用者对象删除了,这种情况可以假想为系统短时间添加并删除了对象,对使用者来说等同于没有这个对象。

现在,我们来看看Queue相对于Stored扩展的Pop接口:

// 代码源自client-go/tools/cache/delta_fifo.go
func (f *DeltaFIFO) Pop(process PopProcessFunc) (interface{}, error) {f.lock.Lock()defer f.lock.Unlock()for {// 队列中有数据么?for len(f.queue) == 0 {// 看来是先判断的是否有数据,后判断是否关闭,这个和chan像if f.IsClosed() {return nil, FIFOClosedError}// 没数据那就等待把f.cond.Wait()}// 取出第一个对象id := f.queue[0]// 数组缩小,相当于把数组中的第一个元素弹出去了,这个不多解释哈f.queue = f.queue[1:]// 取出对象,因为queue中存的是对象键item, ok := f.items[id]// 同步对象计数减一,当减到0就说明外部已经全部同步完毕了if f.initialPopulationCount > 0 {f.initialPopulationCount--}// 对象不存在,这个是什么情况?貌似我们在合并对象的时候代码上有这个逻辑,估计永远不会执行if !ok {continue}// 把对象删除delete(f.items, id)// Pop()需要传入一个回调函数,用于处理对象err := process(item)// 如果需要重新入队列,那就重新入队列if e, ok := err.(ErrRequeue); ok {f.addIfNotPresent(id, item)err = e.Err}return item, err}
}

上面分析的函数基本上就算是把DeltaFIFO核心逻辑分析完毕了,下面我们就把其他的接口函数简单过一下结束本文章内容:

// 代码源自client-go/tools/cache/delta_fifo.go
func (f *DeltaFIFO) HasSynced() bool {f.lock.Lock()defer f.lock.Unlock()// 这里就比较明白了,一次同步全量对象后,并且全部Pop()出去才能算是同步完成// 其实这里所谓的同步就是全量内容已经进入Indexer,Indexer已经是系统中对象的全量快照了return f.populated && f.initialPopulationCount == 0
}
// 添加不存在的对象
func (f *DeltaFIFO) AddIfNotPresent(obj interface{}) error {// 这个要求放入的必须是Deltas数组,就是通过Pop()弹出的对象deltas, ok := obj.(Deltas)if !ok {return fmt.Errorf("object must be of type deltas, but got: %#v", obj)}// 多个Delta都是一个对象,所以用最新的就可以了id, err := f.KeyOf(deltas.Newest().Object)if err != nil {return KeyError{obj, err}}// 后面有实现f.lock.Lock()defer f.lock.Unlock()f.addIfNotPresent(id, deltas)return nil
}
// 这个是添加不存在对象的实现
func (f *DeltaFIFO) addIfNotPresent(id string, deltas Deltas) {f.populated = true// 这里判断的对象是否存在if _, exists := f.items[id]; exists {return}// 放入队列中f.queue = append(f.queue, id)f.items[id] = deltasf.cond.Broadcast()
}
// 重新同步,这个在cache实现是空的,这里面有具体实现
func (f *DeltaFIFO) Resync() error {f.lock.Lock()defer f.lock.Unlock()// 如果没有Indexer那么重新同步是没有意义的,因为连同步了哪些对象都不知道if f.knownObjects == nil {return nil}// 列举Indexer里面所有的对象键keys := f.knownObjects.ListKeys()// 遍历对象键,为每个对象产生一个同步的Deltafor _, k := range keys {// 具体实现后面有介绍if err := f.syncKeyLocked(k); err != nil {return err}}return nil
}
// 具体对象同步实现接口
func (f *DeltaFIFO) syncKeyLocked(key string) error {// 获取对象obj, exists, err := f.knownObjects.GetByKey(key)if err != nil {glog.Errorf("Unexpected error %v during lookup of key %v, unable to queue object for sync", err, key)return nil} else if !exists {glog.Infof("Key %v does not exist in known objects store, unable to queue object for sync", key)return nil}// 计算对象的键值,有人会问对象键不是已经传入了么?那个是存在Indexer里面的对象键,可能与这里的计算方式不同id, err := f.KeyOf(obj)if err != nil {return KeyError{obj, err}}// 对象已经在存在,说明后续会通知对象的新变化,所以再加更新也没意义if len(f.items[id]) > 0 {return nil}// 添加对象同步的这个Deltaif err := f.queueActionLocked(Sync, obj); err != nil {return fmt.Errorf("couldn't queue object: %v", err)}return nil
}

总结

分析完代码后我我有如下几个设问:

  1. 判断是否已同步populated和initialPopulationCount这两个变量存在的目的是什么?我的理解是否已同步指的是第一次从apiserver获取全量对象是否已经全部通知到外部,也就是通过Pop()被取走。所谓的同步就是指apiserver的状态已经同步到缓存中了,也就是Indexer中;
  2. 接口AddIfNotPresent()存在的目的是什么,只有在Pop()函数中使用了一次,但是在调用这个接口的时候已经从map中删除了,所以肯定不存在。这个接口在我看来主要用来保险的,因为Pop()本身就存在重入队列的可能,外部如果判断返回错误重入队列就可能会重复;

最后,我们还是用一幅图来总结一下:

深入浅出kubernetes之client-go的DeltaFIFO相关推荐

  1. 深入浅出kubernetes之client-go的SharedInformer

    记得大学刚毕业那年看了侯俊杰的<深入浅出MFC>,就对深入浅出这四个字特别偏好,并且成为了自己对技术的要求标准--对于技术的理解要足够的深刻以至于可以用很浅显的道理给别人讲明白.以下内容为 ...

  2. 深入浅出kubernetes之client-go的SharedInformerFactory

    记得大学刚毕业那年看了侯俊杰的<深入浅出MFC>,就对深入浅出这四个字特别偏好,并且成为了自己对技术的要求标准--对于技术的理解要足够的深刻以至于可以用很浅显的道理给别人讲明白.以下内容为 ...

  3. 开放下载!阿里云《深入浅出Kubernetes.pdf》

    前言 <深入浅出Kubernetes>电子书独家下载来啦!本书分为理论篇和实践篇,12篇技术文章帮你了解集群控制.集群伸缩原理.镜像拉取等理论,实现从基础概念的准确理解到上手实操的精准熟练 ...

  4. Kubernetes Python Client

    一.概述 Kubernetes官方维护的Python客户端client-python, 地址:https://github.com/kubernetes-client/python 安装模块 pip3 ...

  5. 深入浅出 Kubernetes 1.11 之 device-plugins

    导读:记得大学刚毕业那年看了侯俊杰的<深入浅出MFC>,就对深入浅出这四个字特别偏好,并且成为了自己对技术的要求标准--对于技术的理解要足够的深刻以至于可以用很浅显的道理给别人讲明白.以下 ...

  6. 深入浅出kubernetes之client-go的workqueue

    记得大学刚毕业那年看了侯俊杰的<深入浅出MFC>,就对深入浅出这四个字特别偏好,并且成为了自己对技术的要求标准--对于技术的理解要足够的深刻以至于可以用很浅显的道理给别人讲明白.以下内容为 ...

  7. 深入浅出Netty之四 Client请求处理

    前2篇分析了echo server端的运行机制,本篇同样以echo client为例,分析netty的nio客户端的运行机制. 总体来说client端和server端的处理是类似的,NioWorker ...

  8. 深入浅出 Kubernetes 网络模型基础指南

    Kubernetes 是为运行分布式集群而建立的,分布式系统的本质使得网络成为 Kubernetes 的核心和必要组成部分,了解 Kubernetes 网络模型可以使你能够正确运行.监控和排查应用程序 ...

  9. 从入门到深入!深入浅出kubernetes(K8S)指南

    分享第一份Java基础-中级-高级面试集合 Java基础(对象+线程+字符+接口+变量+异常+方法) Java中级开发(底层+Spring相关+Redis+分布式+设计模式+MySQL+高并发+锁+线 ...

最新文章

  1. android webview 填充,从Android使用WebView自动填充表格
  2. 阿里云Kubernetes服务 - Service Broker快速入门指南
  3. ViewConfiguration.getScaledTouchSlop () 用法
  4. Bash:把粘贴板上的内容拷贝的文件中。(脚本)
  5. Spring Boot 是什么,有什么用。
  6. Qt工作笔记-使用Qt中QProcess与iostream中system调用外部进程
  7. 53. Maximum Subarray 题解
  8. 简述arm汇编和c语言混合编程,ARM汇编C语言混合编程
  9. Spring中Bean管理操作基于XML配置文件方法实现
  10. 计算机组成原理05章在线测试,《计算机组成原理》第05章在线测试.docx
  11. 下载安装VS Code以及简单的配置使用
  12. 网页上的文本不让你复制下载?老司机教你几招,轻松免费复制
  13. 抓包工具charles下载安装(破解版)
  14. 【染上你的颜色】MMD动作+镜头下载
  15. python体测成绩数据分析_Python+Excel数据分析实战:军事体能考核成绩评定(二)基本框架和年龄计算...
  16. 新一批交通强国试点工作启动
  17. 阿里P7晒出1月工资单:狠补了这个,真香...
  18. 我的、新的、纯粹的:触摸荣耀长大后的面庞
  19. 【青少年编程】【二级】小瓢虫找妈妈
  20. python笔记---(实验二)

热门文章

  1. 部标808协议 java_基于部标JT/T 808协议及数据格式的GPS服务器 开发
  2. 回忆那年那月(1997~2003)起篇---高复班的残余
  3. 【js与jquery】html中onsubmit事件的用法
  4. 韩国人韩语网络聊天常用初声/字母缩略词集合
  5. 记录一下第四次数学建模,最后一次
  6. python画桃花_pyecharts是一款将Python与Echarts结合的强大的数据可视化工具。
  7. (10) 朴素贝叶斯
  8. js原型链中this
  9. 计算机在材料科学中应用展,计算机在材料科学中的应用.pptx
  10. c++自带的排序函数sort