• 设为首页
  • 点击收藏
  • 手机版
    手机扫一扫访问
    迪恩网络手机版
  • 关注官方公众号
    微信扫一扫关注
    公众号

深入浅出kubernetes之client-go的Indexer

原作者: [db:作者] 来自: [db:来源] 收藏 邀请

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

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


目录

Indexer功能介绍

Indexer实现之cache

ThreadSafeStore

cache的实现

kubernetes中主要的索引函数

总结


Indexer功能介绍

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

                             

由于Informer比较庞大,所以我们把它拆解成接独立的模块分析,本文分析的就是Indexer模块。Indexer是什么,从字面上看是索引器,他所在的位置就是Informer的LocalStore。肯定有人会问索引和存储有啥关系,那数据库建索引不也是存储和索引建立了关系么?索引构建在存储之上,使得按照某些条件查询速度会非常快。我先从代码上证实client-go中的Indexer就是存储:

 
  1. // 代码源自client-go/tools/cache/index.go

  2. type Indexer interface {

  3. Store // 此处继承了Store这个interface,定义在cliet-go/tool/cache/store.go中

  4. ......

  5. }

Indexer在Store基础上扩展了索引能力,那Indexer是如何实现索引的呢?让我们来看看几个非常关键的类型:

 
  1. // 代码源自client-go/tools/cache/index.go

  2. type IndexFunc func(obj interface{}) ([]string, error) // 计算索引的函数,传入对象,输出字符串索引,注意是数组哦!

  3. type Indexers map[string]IndexFunc // 计算索引的函数有很多,用名字分类

  4. type Indices map[string]Index // 由于有多种计算索引的方式,那就又要按照计算索引的方式组织索引

  5. type Index map[string]sets.String // 每种计算索引的方式会输出多个索引(数组)

  6. // 而多个目标可能会算出相同索引,所以就有了这个类型

我相信很多人初次(不要看我的注释)看到上面的定义肯定懵逼了,不用说别的,就类型命名根本看不出是干啥的,而且还相似~我在这里给大家解释一下定义这些类型的目的。何所谓索引,索引目的就是为了快速查找。比如,我们需要查找某个节点上的所有Pod,那就要Pod按照节点名称排序,对应上面的Index类型就是map[nodename]sets.podname。我们可能有很多种查找方式,这就是Indexers这个类型作用了。下面的图帮助读者理解,不代表真正实现:

                                   

Indexers和Indices都是按照IndexFunc(名字)分组, 每个IndexFunc输出多个IndexKey,产生相同IndexKey的多个对象存储在一个集合中。注意:上图中不代表Indexers和Indices都指向了同一个数据,只是示意都用相同的IndexFunc的名字

为了方便后面内容的开展,我们先统一一些概念:

  1. IndexFunc1.....这些都是索引函数的名称,我们称之为索引类,大概意思就是把索引分类了;
  2. IndexKey1....这些是同一个对象在同一个索引类中的多个索引键值,我们称为索引键,切记索引键有多个;
  3. ObjKey1.....这些是对象键,每个对象都有唯一的名称;

有了上面的基础,我们再来看看Indexer与索引相关的接口都定义了哪些?

 
  1. // 代码源自client-go/tools/cache/index.go

  2. type Indexer interface {

  3. // 集成了存储的接口,前面提到了,后面会有详细说明

  4. Store

  5. // indexName索引类,obj是对象,计算obj在indexName索引类中的索引键,通过索引键把所有的对象取出来

  6. // 基本就是获取符合obj特征的所有对象,所谓的特征就是对象在索引类中的索引键

  7. Index(indexName string, obj interface{}) ([]interface{}, error)

  8. // indexKey是indexName索引类中一个索引键,函数返回indexKey指定的所有对象键

  9. // 这个对象键是Indexer内唯一的,在添加的时候会计算,后面讲具体Indexer实例的会讲解

  10. IndexKeys(indexName, indexKey string) ([]string, error)

  11. // 获取indexName索引类中的所有索引键

  12. ListIndexFuncValues(indexName string) []string

  13. // 这个函数和Index类似,只是返回值不是对象键,而是所有对象

  14. ByIndex(indexName, indexKey string) ([]interface{}, error)

  15. // 返回Indexers

  16. GetIndexers() Indexers

  17. // 添加Indexers,就是增加更多的索引分类

  18. AddIndexers(newIndexers Indexers) error

  19. }

我相信通过我的注释很多人已经对Indexer有了初步认识,我们再来看看Store这个interface有哪些接口:

 
  1. // 代码源自client-go/tools/cache/store.go

  2. type Store interface {

  3. // 添加对象

  4. Add(obj interface{}) error

  5. // 更新对象

  6. Update(obj interface{}) error

  7. // 删除对象

  8. Delete(obj interface{}) error

  9. // 列举对象

  10. List() []interface{}

  11. // 列举对象键

  12. ListKeys() []string

  13. // 返回obj相同对象键的对象,对象键是通过对象计算出来的字符串

  14. Get(obj interface{}) (item interface{}, exists bool, err error)

  15. // 通过对象键获取对象

  16. GetByKey(key string) (item interface{}, exists bool, err error)

  17. // 用[]interface{}替换Store存储的所有对象,等同于删除全部原有对象在逐一添加新的对象

  18. Replace([]interface{}, string) error

  19. // 重新同步

  20. Resync() error

  21. }

从Store的抽象来看,要求每个对象都要有唯一的键,至于键的计算方式就看具体实现了。我们看了半天的各种抽象,是时候讲解一波具体实现了。

Indexer实现之cache

cache是Indexer的一种非常经典的实现,所有的对象缓存在内存中,而且从cache这个类型的名称来看属于包内私有类型,外部无法直接使用,只能通过专用的函数创建。其实cache的定义非常简单,如下所以:

 
  1. // 代码源自client-go/tools/cache/store.go

  2. type cache struct {

  3. cacheStorage ThreadSafeStore // 线程安全的存储

  4. keyFunc KeyFunc // 计算对象键的函数

  5. }

  6. // 计算对象键的函数

  7. type KeyFunc func(obj interface{}) (string, error)

这里可以看出来cache有一个计算对象键的函数,创建cache对象的时候就要指定这个函数了。

ThreadSafeStore

从cache的定义来看,所有的功能基本是通过ThreadSafeStore这个类型实现的,keyFunc就是用来计算对象键的。所以,我们在分析cache之前,分析ThreadSafeStore是非常重要的,接下来就看看这个类型是如何定义的:

 
  1. // 代码源自client-go/tools/cache/thread_safe_store.go

  2. type ThreadSafeStore interface {

  3. Add(key string, obj interface{})

  4. Update(key string, obj interface{})

  5. Delete(key string)

  6. Get(key string) (item interface{}, exists bool)

  7. List() []interface{}

  8. ListKeys() []string

  9. Replace(map[string]interface{}, string)

  10. Index(indexName string, obj interface{}) ([]interface{}, error)

  11. IndexKeys(indexName, indexKey string) ([]string, error)

  12. ListIndexFuncValues(name string) []string

  13. ByIndex(indexName, indexKey string) ([]interface{}, error)

  14. GetIndexers() Indexers

  15. AddIndexers(newIndexers Indexers) error

  16. Resync() error

  17. }

我为什么没有对ThreadSafeStore做注释呢?乍一看和Indexer这个itnerface基本一样,但还是有差别的,就是跟存储相关的接口。Indexer因为继承了Store,存储相关的增删改查输入都是对象,而ThreadSafeStore是需要提供对象键的。所以ThreadSafeStore和Indexer基本一样,也就没必要再写一遍注释,我们可以把精力主要放在具体的实现类上:

 
  1. // 代码源自client-go/tools/cache/thread_safe_store.go

  2. type threadSafeMap struct {

  3. lock sync.RWMutex // 读写锁,毕竟读的多写的少,读写锁性能要更好

  4. items map[string]interface{} // 存储对象的map,对象键:对象

  5. indexers Indexers // 这个不用多解释了把,用于计算索引键的函数map

  6. indices Indices // 快速索引表,通过索引可以快速找到对象键,然后再从items中取出对象

  7. }

看了具体的实现类是不是感觉很简单?其实就是很简单,如果没有经过系统的梳理,如此简单的实现也不见的很容易看明白。我还是要在此强调一次,索引键和对象键是两个重要概念,索引键是用于对象快速查找的,经过索引建在map中排序查找会更快;对象键是为对象在存储中的唯一命名的,对象是通过名字+对象的方式存储的。

后续内容会简单很多,所以会把多个函数放在一起注释,下面就是和存储相关的函数的统一说明:

 
  1. // 代码源自client-go/tools/cache/thread_safe_store.go

  2. // 添加对象函数

  3. func (c *threadSafeMap) Add(key string, obj interface{}) {

  4. // 加锁,因为是写操作,所以是全部互斥的那种

  5. c.lock.Lock()

  6. defer c.lock.Unlock()

  7. // 把老的对象取出来

  8. oldObject := c.items[key]

  9. // 写入新的对象

  10. c.items[key] = obj

  11. // 由于对象的添加就要更新索引

  12. c.updateIndices(oldObject, obj, key)

  13. }

  14. // 更新对象函数,和添加对象一模一样,所以就不解释了,为啥Add函数不直接调用Update呢?

  15. func (c *threadSafeMap) Update(key string, obj interface{}) {

  16. c.lock.Lock()

  17. defer c.lock.Unlock()

  18. oldObject := c.items[key]

  19. c.items[key] = obj

  20. c.updateIndices(oldObject, obj, key)

  21. }

  22. // 删除对象

  23. func (c *threadSafeMap) Delete(key string) {

  24. // 加锁,因为是写操作,所以是全部互斥的那种

  25. c.lock.Lock()

  26. defer c.lock.Unlock()

  27. // 判断对象是否存在?

  28. if obj, exists := c.items[key]; exists {

  29. // 删除对象的索引

  30. c.deleteFromIndices(obj, key)

  31. // 删除对象本身

  32. delete(c.items, key)

  33. }

  34. }

  35. // 获取对象

  36. func (c *threadSafeMap) Get(key string) (item interface{}, exists bool) {

  37. // 此处只用了读锁

  38. c.lock.RLock()

  39. defer c.lock.RUnlock()

  40. // 利用对象键取出对象

  41. item, exists = c.items[key]

  42. return item, exists

  43. }

  44. // 列举对象

  45. func (c *threadSafeMap) List() []interface{} {

  46. // 此处只用了读锁

  47. c.lock.RLock()

  48. defer c.lock.RUnlock()

  49. // 直接遍历对象map就可以了

  50. list := make([]interface{}, 0, len(c.items))

  51. for _, item := range c.items {

  52. list = append(list, item)

  53. }

  54. return list

  55. }

  56. // 列举对象键

  57. func (c *threadSafeMap) ListKeys() []string {

  58. // 此处只用了读锁

  59. c.lock.RLock()

  60. defer c.lock.RUnlock()

  61. // 同样是遍历对象map,但是只输出对象键

  62. list := make([]string, 0, len(c.items))

  63. for key := range c.items {

  64. list = append(list, key)

  65. }

  66. return list

  67. }

  68. // 取代所有对象,相当于重新构造了一遍threadSafeMap

  69. func (c *threadSafeMap) Replace(items map[string]interface{}, resourceVersion string) {

  70. // 此处必须要用全局锁,因为有写操作

  71. c.lock.Lock()

  72. defer c.lock.Unlock()

  73. // 直接覆盖以前的对象

  74. c.items = items

  75.  
  76. // 重建索引

  77. c.indices = Indices{}

  78. for key, item := range c.items {

  79. c.updateIndices(nil, item, key)

  80. }

  81. // 发现没有,resourceVersion此处没有用到,估计是其他的Indexer实现有用

  82. }

下面就是跟索引相关的函数了,也是我主要讲解的内容,所以每个函数都是独立注释的,我们一个一个的过:

 
  1. // 代码源自client-go/tools/cache/thread_safe_store.go

  2. // 这个函数就是通过指定的索引函数计算对象的索引键,然后把索引键的对象全部取出来

  3. func (c *threadSafeMap) Index(indexName string, obj interface{}) ([]interface{}, error) {

  4. // 只读,所以用读锁

  5. c.lock.RLock()

  6. defer c.lock.RUnlock()

  7. // 取出indexName这个分类索引函数

  8. indexFunc := c.indexers[indexName]

  9. if indexFunc == nil {

  10. return nil, fmt.Errorf("Index with name %s does not exist", indexName)

  11. }

  12. // 计算对象的索引键

  13. indexKeys, err := indexFunc(obj)

  14. if err != nil {

  15. return nil, err

  16. }

  17. // 取出indexName这个分类所有索引

  18. index := c.indices[indexName]

  19.  
  20. // 返回对象的对象键的集合

  21. returnKeySet := sets.String{}

  22. // 遍历刚刚计算出来的所有索引键

  23. for _, indexKey := range indexKeys {

  24. // 取出索引键的所有对象键

  25. set := index[indexKey]

  26. // 把所有的对象键输出到对象键的集合中

  27. for _, key := range set.UnsortedList() {

  28. returnKeySet.Insert(key)

  29. }

  30. }

  31. // 通过对象键逐一的把对象取出

  32. list := make([]interface{}, 0, returnKeySet.Len())

  33. for absoluteKey := range returnKeySet {

  34. list = append(list, c.items[absoluteKey])

  35. }

  36.  
  37. return list, nil

  38. }

这个函数比较有意思,利用一个对象计算出来的索引键,然后把所有具备这些索引键的对象全部取出来,为了方便理解我都是这样告诉自己的:比如取出一个Pod所在节点上的所有Pod,这样理解就会非常方便,但是kubernetes可能就不这么用。如果更抽象一点,就是符合对象某些特征的所有对象,而这个特征就是我们指定的索引函数计算出来的。

好啦,下一个函数:

 
  1. // 代码源自client-go/tools/cache/thread_safe_store.go

  2. // 这个函数和上面的函数基本一样,只是索引键不用再计算了,使用者提供

  3. func (c *threadSafeMap) ByIndex(indexName, indexKey string) ([]interface{}, error) {

  4. // 同样是读锁

  5. c.lock.RLock()

  6. defer c.lock.RUnlock()

  7. // 判断indexName这个索引分类是否存在

  8. indexFunc := c.indexers[indexName]

  9. if indexFunc == nil {

  10. return nil, fmt.Errorf("Index with name %s does not exist", indexName)

  11. }

  12. // 取出索引分类的所有索引

  13. index := c.indices[indexName]

  14. // 再出去索引键的所有对象键

  15. set := index[indexKey]

  16. // 遍历对象键输出

  17. list := make([]interface{}, 0, set.Len())

  18. for _, key := range set.List() {

  19. list = append(list, c.items[key])

  20. }

  21.  
  22. return list, nil

  23. }

 这个函数相比于上一个函数功能略微简单一点,获取的是一个具体索引键的全部对象。Come on,没几个函数了!

 
  1. // 代码源自client-go/tools/cache/thread_safe_store.go

  2. // 你会发现这个函数和ByIndex()基本一样,只是输出的是对象键

  3. func (c *threadSafeMap) IndexKeys(indexName, indexKey string) ([]string, error) {

  4. // 同样是读锁

  5. c.lock.RLock()

  6. defer c.lock.RUnlock()

  7. // 判断indexName这个索引分类是否存在

  8. indexFunc := c.indexers[indexName]

  9. if indexFunc == nil {

  10. return nil, fmt.Errorf("Index with name %s does not exist", indexName)

  11. }

  12. // 取出索引分类的所有索引

  13. index := c.indices[indexName]

  14. // 直接输出索引键内的所有对象键

  15. set := index[indexKey]

  16. return set.List(), nil

  17. }

还有最后一个(其他的对外接口函数太简单了,读者自行分析就好了):

 
  1. // 代码源自client-go/tools/cache/thread_safe_store.go

  2. // 这个函数用来获取索引分类内的所有索引键的

  3. func (c *threadSafeMap) ListIndexFuncValues(indexName string) []string {

  4. // 依然是读锁

  5. c.lock.RLock()

  6. defer c.lock.RUnlock()

  7. // 获取索引分类的所有索引

  8. index := c.indices[indexName]

  9. // 直接遍历后输出索引键

  10. names := make([]string, 0, len(index))

  11. for key := range index {

  12. names = append(names, key)

  13. }

  14. return names

  15. }

至于AddIndexers()和GetIndexers()因为没有难度,而且不影响理解核心逻辑,所以此处不再浪费文字了。看了半天代码是不是感觉缺点什么?为什么没有看到索引是如何组织的?那就对了,因为还有两个最为重要的私有函数没有分析呢!

 
  1. // 代码源自client-go/tools/cache/thread_safe_store.go

  2. // 当有对象添加或者更新是,需要更新索引,因为代用该函数的函数已经加锁了,所以这个函数没有加锁操作

  3. func (c *threadSafeMap) updateIndices(oldObj interface{}, newObj interface{}, key string) {

  4. // 在添加和更新的时候都会获取老对象,如果存在老对象,那么就要删除老对象的索引,后面有说明

  5. if oldObj != nil {

  6. c.deleteFromIndices(oldObj, key)

  7. }

  8. // 遍历所有的索引函数,因为要为对象在所有的索引分类中创建索引键

  9. for name, indexFunc := range c.indexers {

  10. // 计算索引键

  11. indexValues, err := indexFunc(newObj)

  12. if err != nil {

  13. panic(fmt.Errorf("unable to calculate an index entry for key %q on index %q: %v", key, name, err))

  14. }

  15. // 获取索引分类的所有索引

  16. index := c.indices[name]

  17. if index == nil {

  18. // 为空说明这个索引分类还没有任何索引

  19. index = Index{}

  20. c.indices[name] = index

  21. }

  22. // 遍历对象的索引键,上面刚刚用索引函数计算出来的

  23. for _, indexValue := range indexValues {

  24. // 找到索引键的对象集合

  25. set := index[indexValue]

  26. // 为空说明这个索引键下还没有对象

  27. if set == nil {

  28. // 创建对象键集合

  29. set = sets.String{}

  30. index[indexValue] = set

  31. }

  32. // 把对象键添加到集合中

  33. set.Insert(key)

  34. }

  35. }

  36. }

  37. // 这个函数用于删除对象的索引的

  38. func (c *threadSafeMap) deleteFromIndices(obj interface{}, key string) {

  39. // 遍历索引函数,也就是把所有索引分类

  40. for name, indexFunc := range c.indexers {

  41. // 计算对象的索引键

  42. indexValues, err := indexFunc(obj)

  43. if err != nil {

  44. panic(fmt.Errorf("unable to calculate an index entry for key %q on index %q: %v", key, name, err))

  45. }

  46. // 获取索引分类的所有索引

  47. index := c.indices[name]

  48. if index == nil {

  49. continue

  50. }

  51. // 遍历对象的索引键

  52. for _, indexValue := range indexValues {

  53. 把对象从索引键指定对对象集合删除

  54. set := index[indexValue]

  55. if set != nil {

  56. set.Delete(key)

  57. }

  58. }

  59. }

  60. }

cache的实现

因为cache就是在ThreadSafeStore的再封装,实现也非常简单,我不做过多说明,只把代码罗列出来,读者一看便知。

 
  1. // 代码源自client-go/tools/cache/store.go

  2. func (c *cache) Add(obj interface{}) error {

  3. key, err := c.keyFunc(obj)

  4. if err != nil {

  5. return KeyError{obj, err}

  6. }

  7. c.cacheStorage.Add(key, obj)

  8. return nil

  9. }

  10. func (c *cache) Update(obj interface{}) error {

  11. key, err := c.keyFunc(obj)

  12. if err != nil {

  13. return KeyError{obj, err}

  14. }

  15. c.cacheStorage.Update(key, obj)

  16. return nil

  17. }

  18. func (c *cache) Delete(obj interface{}) error {

  19. key, err := c.keyFunc(obj)

  20. if err != nil {

  21. return KeyError{obj, err}

  22. }

  23. c.cacheStorage.Delete(key)

  24. return nil

  25. }

  26. func (c *cache) List() []interface{} {

  27. return c.cacheStorage.List()

  28. }

  29. func (c *cache) ListKeys() []string {

  30. return c.cacheStorage.ListKeys()

  31. }

  32. func (c *cache) GetIndexers() Indexers {

  33. return c.cacheStorage.GetIndexers()

  34. }

  35. func (c *cache) Index(indexName string, obj interface{}) ([]interface{}, error) {

  36. return c.cacheStorage.Index(indexName, obj)

  37. }

  38. func (c *cache) IndexKeys(indexName, indexKey string) ([]string, error) {

  39. return c.cacheStorage.IndexKeys(indexName, indexKey)

  40. }

  41. func (c *cache) ListIndexFuncValues(indexName string) []string {

  42. return c.cacheStorage.ListIndexFuncValues(indexName)

  43. }

  44. func (c *cache) ByIndex(indexName, indexKey string) ([]interface{}, error) {

  45. return c.cacheStorage.ByIndex(indexName, indexKey)

  46. }

  47. func (c *cache) AddIndexers(newIndexers Indexers) error {

  48. return c.cacheStorage.AddIndexers(newIndexers)

  49. }

  50. func (c *cache) Get(obj interface{}) (item interface{}, exists bool, err error) {

  51. key, err := c.keyFunc(obj)

  52. if err != nil {

  53. return nil, false, KeyError{obj, err}

  54. }

  55. return c.GetByKey(key)

  56. }

  57. func (c *cache) GetByKey(key string) (item interface{}, exists bool, err error) {

  58. item, exists = c.cacheStorage.Get(key)

  59. return item, exists, nil

  60. }

  61. func (c *cache) Replace(list []interface{}, resourceVersion string) error {

  62. items := map[string]interface{}{}

  63. for _, item := range list {

  64. key, err := c.keyFunc(item)

  65. if err != nil {

  66. return KeyError{item, err}

  67. }

  68. items[key] = item

  69. }

  70. c.cacheStorage.Replace(items, resourceVersion)

  71. return nil

  72. }

  73. func (c *cache) Resync() error {

  74. return c.cacheStorage.Resync()

  75. }

kubernetes中主要的索引函数

我搜遍了kubernetes代码,发现最主要的索引的函数大概就下面几种:

  1. MetaNamespaceIndexFunc,定义在client-go/tools/cache/index.go中,从名字看就是获取对象元数据的namesapce字段,也就是所有对象以namespace作为索引键,这个就很好理解了;
  2. indexByPodNodeName,定义在kubernetes/pkg/controller/daemon/deamon_controller.go,该索引函数计算的是Pod对象所在节点的名字;

为了方便理解,我们可以假设kubernetes主要就是一种索引函数(MetaNamespaceIndexFunc),也就是在索引中大部分就一个分类,这个分类的索引键就是namesapce。那么有人肯定会问,如果这样的话,所有的对象都存在一个namesapce索引键下面,这样的效率岂不是太低了?其实client-go为每类对象都创建了Informer(Informer内有Indexer),所以即便存储在相同namesapce下的对象都是同一类,这个问题自然也就没有了,详情可以看我针对Informer写的文章。

大家一定要区分MetaNamespaceIndexFunc和MetaNamespaceKeyFunc的区分,第一个索引键计算函数,第二个是对象键计算函数,第一个返回的是namespace,第二个返回的是对象包含namespace在内的对象全程。

总结

如果读者还对所谓的索引、索引分类、索引键、对象键比较混乱的话,我就要拿出我更加大白话的总结了:所有的对象(Pod、Node、Service等等)都是有属性/标签的,如果属性/标签就是索引键,Indexer就会把相同属性/标签的所有对象放在一个集合中,如果在对属性/标签分一下类,也就就是我们本文的将的Indexer的核心内容了。甚至你可以简单的理解为Indexer就是简单的把相同namesapce对象放在一个集合中,kubernetes就是基于属相/标签检索的,这么理解也不偏颇,方法不重要,只要能帮助理解都是好方法。

有人肯定会说你早说不就完了么?其实如果没有上面的分析,直接给出总结是不是显得我很没水平?关键是读者理解的也不会深刻!

原文转载于:https://blog.csdn.net/weixin_42663840/article/details/81530606


鲜花

握手

雷人

路过

鸡蛋
该文章已有0人参与评论

请发表评论

全部评论

专题导读
上一篇:
go多参数命令行发布时间:2022-07-10
下一篇:
解决 Windows To Go U盘没有盘符的问题发布时间:2022-07-10
热门推荐
热门话题
阅读排行榜

扫描微信二维码

查看手机版网站

随时了解更新最新资讯

139-2527-9053

在线客服(服务时间 9:00~18:00)

在线QQ客服
地址:深圳市南山区西丽大学城创智工业园
电邮:jeky_zhao#qq.com
移动电话:139-2527-9053

Powered by 互联科技 X3.4© 2001-2213 极客世界.|Sitemap