标签 设计模式 下的文章

Strategy模式的C实现

与那些复杂的模式相比,Stragegy Pattern(策略模式)是一个相对简单的模式,很直观,也易于理解。 同时它也是我们在开发过程中使用最多的模式之一。

问题是设计模式使用的驱动力,只有当我们遇到问题时,设计模式才会向我们伸出援助之手。这里我也想通过对问题以及解决方法演化的阐述来说明策略模式是如何更好地帮助我们的。我们从问题出发!

Tony最近接到了一个新任务,任务的内容是实现一个通用的平衡二叉树数据结构供其他同事使用。接到这个任务后,他十分欣喜,因为在之前的工作中他一直在用C语言编写繁芜复杂的业务逻辑,这让他感觉很烦躁。今天他终于盼到了一个做公共库的机会。为此Tony连夜拜读了《C语言接口与实现》一书,希望能从书中取点经,做出一个让大家都满意的通用的平衡二叉树。

Tony十分欣赏《C语言接口与实现》中采用的一些原则,比如尽量隐藏细节,只给外部暴露必要的信息等。他也是按照书中的原则来定义平衡二叉树的各个操作接口的。但在定义接口的过程中Tony却遇到了问题,以平衡二叉树的创建和销毁接口为例,Tony最初设计的接口原型是这样的:

/* balanced_binary_tree.h */
… …
struct bb_tree_t;
int bb_tree_create(struct bb_tree_t **tree, …);
int bb_tree_destroy(struct bb_tree_t **tree, …);
… …

/* balanced_binary_tree.c */
struct bb_tree_t {
    …
};
… …

很显然,为了隐藏细节,Tony选择了在接口实现内部为tree分配和释放内存空间,但他不确定应该用哪种内存分配方式。他经过一番思维斗争后最终选择了使用标准库中的malloc和free,因为他坚定地认为多数人都会使用这套标准的内存管理接口,例外的可能性很小。后续的实现过程很顺利,当这套"设计优美"的平衡二叉树公共库被提交给其他同事使用时,Tony感觉十分地开心。

/* balanced_binary_tree.c */
int bb_tree_create(struct bb_tree_t **tree, …) {
    struct bb_tree_t *p = (struct bb_tree_t*)malloc(sizeof(*p));
    …
}

int bb_tree_destroy(struct bb_tree_t **tree, …) {
    free(*tree);
    … …
}

不过墨菲定律告诉我们:事情如果有变坏的可能,不管这种可能性有多小,它总会发生。果然好景不长,这一天一位同事找到了Tony并向他诉苦到:"我们需要在共享内存上使用平衡二叉树,但你提供的bb_tree只能在堆上分配内存,无法被多个进程共享,我们真不知道该怎么办了,不知道你能否修改一下你提供的库,让它也支持在共享内存上分配呢"。Tony是个自尊心很强的人,听到自己提供的公共库有缺陷,他的心中只有一个念头:尽快解决这个问题!

Tony回到座位上开始重新审视自己的设计,他满脑子都是"既要支持标准内存分配接口,又要支持在共享内存上分配"的需求。在考量了十几分钟后,他似乎知道该怎么做了,他在屏幕上敲下了如下代码:

/* balanced_binary_tree.h */
… …
struct bb_tree_t;
enum mem_allocator_flag {
    STD_ALLOCATOR,
    SHM_ALLOCATOR
};
int bb_tree_create(struct bb_tree_t **tree, enum mem_allocator_flag flag, …);
int bb_tree_destroy(struct bb_tree_t **tree, …);
… …

/* balanced_binary_tree.c */
struct bb_tree_t {
    …
    enum mem_allocator_flag flag;
};

int bb_tree_create(struct bb_tree_t **tree, enum mem_allocator_flag flag, …) {
    … …
    struct bb_tree_t *p
    if (flag == STD_ALLOCATOR)
        p = (struct bb_tree_t*)malloc(sizeof(*p));
   
    if (flag == SHM_ALLOCATOR)
        p = (struct bb_tree_t*)shm_malloc(sizeof(*p));

    p->flag = flag;
    … …
}

int bb_tree_destroy(struct bb_tree_t **tree, …) {
    … …
    if ((*tree)->flag == STD_ALLOCATOR)
        free(*tree);
   
    if ((*tree)->flag == SHM_ALLOCATOR)
        shm_free(*tree);
    … …
}

嗯,这样改后就应该支持在共享内存上分配平衡二叉树了。Tony似乎又恢复了信心,表情也不再那么死板严肃了!他伸伸懒腰,感觉有些疲倦,于是他决定趴在办公桌上小憩一会儿。恍惚中一个同事来到了他的跟前,对他说:"Tony,标准内存分配接口使用的内存分配算法效率太低,时间长了又会导致太多的内存碎片,完全不能满足我们的需求,我们希望能在平衡二叉树中使用我们自己实现的一套高性能内存分配接口,你必须尽快做出修改,不然…."。Tony瞬间从梦中惊醒,他努力地回忆了一下刚才梦中的情景,又看了看屏幕上刚刚修改过的代码,心生一丝窃喜,"还好这只是一场梦,否则这份代码又要被人嘲笑了。内存管理的接口有N多种,我总不能每支持一种新分配算法就重新发布一次吧"。Tony又一次陷入了沉思。不过这次沉思显然也没有持续多久,Tony似乎又找到了解决方案,屏幕上的代码发生了变化。

/* balanced_binary_tree.h */
… …
struct bb_tree_t;
int bb_tree_create(struct bb_tree_t **tree,
                   void (*malloc_func)(size_t size),
                   void (*free_func)(void *ptr), …);
int bb_tree_destroy(struct bb_tree_t **tree, …);
… …

/* balanced_binary_tree.c */
struct bb_tree_t {
    …
    void (*malloc_func)(size_t size),
    void (*free_func)(void *ptr)
};

int bb_tree_create(struct bb_tree_t **tree,
                   void (*malloc_func)(size_t size),
                   void (*free_func)(void *ptr), …) {
    struct bb_tree_t *p = (struct bb_tree_t*)malloc_func(sizeof(*p));
    p->malloc_func = malloc_func;
    p->free_func = free_func;
    … …
}

int bb_tree_destroy(struct bb_tree_t **tree, …) {
    (*tree)->free_func(*tree);
    … …
}

这回Tony把使用何种存储分配机制的权力完完全全地交给了库的使用者,这样纵使内存分配机制有千般变化,这里也依旧可以满足。不过这次Tony还是长了个心眼儿,没有马上将库发布给其他同事,而是从优秀代码设计的角度再次对自己的代码做了一次分析。

Tony暂时抛开了bb_tree,而是从所有类似的公共库的设计和实现角度考虑了许久。他发现如果公共库的设计都遵循将细节隐藏,只暴露必要信息的原则的话,势必都会遇到类似的如何在内部进行存储分配的问题。bb_tree使用到了malloc和free接口,但其他公共库很可能还会用到calloc、realloc等接口。一旦遇到这种情况,按照上面的方案就会出现类似下面的函数原型形式:

int xx_create(…,
              void (*malloc_func)(size_t size),
              void (*calloc)(size_t nmemb, size_t size),
              void (*realloc)(void *ptr, size_t size),
              void (*free_func)(void *ptr)…);

Tony意识到这个函数原型参数太多,使用不便,代码味道自然不好!应该重构一下,将变化的事物封装起来。综上来看,目前主要有两点易变的地方:
1) 内存分配接口需支持可替换
2) 不同公共库可能使用不同形式的内存分配接口,有的用符号malloc原型的,有的用符合calloc原型的。

"封装变化!",Tony潜意识里跳出的第一个想法。于是十几分钟过后他的电脑屏幕上就出现了下面这些代码:

/* mem_allocator.h */
struct mem_allocator_t {
    void (*malloc)(struct mem_allocator_t *allocator, size_t size),
    void (*calloc)(struct mem_allocator_t *allocator, size_t nmemb, size_t size),
    void (*realloc)(struct mem_allocator_t *allocator, void *ptr, size_t size),
    void (*free)(struct mem_allocator_t *allocator, void *ptr)
};

/* balanced_binary_tree.h */
#include "mem_allocator.h"
… …
struct bb_tree_t;
int bb_tree_create(struct bb_tree_t **tree,
                   const struct mem_allocator_t *allocator, …);
int bb_tree_destroy(struct bb_tree_t **tree, …);
… …

/* balanced_binary_tree.c */
struct bb_tree_t {
    …
    const struct mem_allocator_t *allocator;
};

int bb_tree_create(struct bb_tree_t **tree,
                   struct mem_allocator_t *allocator, …) {
    struct bb_tree_t *p = (struct bb_tree_t*)(allocator->malloc(allocator, sizeof(*p)));
    p->allocator = allocator;
    … …
}

int bb_tree_destroy(struct bb_tree_t **tree, …) {
    struct mem_allocator_t *allocator = (*tree)->allocator;
    allocator->free(allocator, *tree);
    … …
}

Tony封装出一个接口mem_allocator_t,bb_tree的创建和销毁只依赖于mem_allocator_t。我们可以为bb_tree_create传入不同的mem_allocator_t接口的实现,以支持内存分配机制的更换;另外mem_allocator_t内部封装了所有形式的通用的内存管理原型,可以满足其他公共库实现的需要。

面向对象语言可以通过继承(derive)接口、重写方法(override method)的方式实现一个接口,但在C中,我们只能像下面这样给出mem_allocator_t接口的一个实现 – shm_mem_allocator:

/* shm_mem_allocator.h */
#include "mem_allocator.h"

struct mem_allocator_t* shm_mem_allocator_new();
void shm_mem_allocator_free(struct mem_allocator_t **allocator);

/* shm_mem_allocator.c */

struct shm_mem_allocator_t {
    struct mem_allocator_t allocator;
    … … /* 其他用于实现shm_mem_allocator所需要的字段 */
};

struct mem_allocator_t* shm_mem_allocator_new() {
    struct shm_mem_allocator_t *allocator = (struct shm_mem_allocator_t*)malloc(sizeof(*allocator));
    if (!allocator)
        return NULL;
    memset(allocator, 0, sizeof(*allocator));

    allocator->allocator.malloc = shm_mem_alloc;
    allocator->allocator.calloc = shm_mem_calloc;
    allocator->allocator.realloc = shm_mem_realloc;
    allocator->allocator.free = shm_mem_free;
   
    return (struct mem_allocator_t*)allocator;
}

static void* shm_mem_malloc(struct mem_allocator_t *allocator, size_t size) {
    struct shm_mem_allocator_t *p = (struct shm_mem_allocator_t*)allocator;
    … …
}

static void* shm_mem_calloc(struct mem_allocator_t *allocator, size_t nmemb, size_t size) {
    struct shm_mem_allocator_t *p = (struct shm_mem_allocator_t*)allocator;
    … …
}

static void* shm_mem_realloc(struct mem_allocator_t *allocator, void *ptr, size_t size) {
    struct shm_mem_allocator_t *p = (struct shm_mem_allocator_t*)allocator;
    … …
}

static void shm_mem_free(struct mem_allocator_t *allocator, void *ptr) {
    struct shm_mem_allocator_t *p = (struct shm_mem_allocator_t*)allocator;
    … …
}

void shm_mem_allocator_free(struct mem_allocator_t **allocator) {
    struct shm_mem_allocator_t *p = (struct shm_mem_allocator_t*)(*allocator);
    free(p);
    (*allocator) = NULL;
}

注意shm_mem_allocator本身也涉及到内部实现的内存分配问题,但考虑到其自身的特质:我们无须在共享内存上创建mem_allocator实例对象,也无须为mem_allocator实例对象内部的内存分配算法性能担忧(只是初始化时使用一次),因此可以无须支持多种内存分配接口,利用标准内存分配接口足矣。

下面是将bb_tree与shm_mem_allocator结合在一起使用的代码:
struct bb_tree_t *tree;
ret = bb_tree_create(&tree, shm_mem_allocator_new(), …);
… …

如果你要更换bb_tree内部的内存分配器,则在调用bb_tree_create时换用你自己的mem_allocator_t接口实现即可:
struct bb_tree_t *tree;
ret = bb_tree_create(&tree, your_mem_allocator_new(), …);
… …

Tony终于找到了一份可以让自己满意的方案了。在发布代码库后,他再也没有收到来自同事们的抱怨之声。在接下来的一个星期里,Tony又用同样的设计方案让产品可以支持从多种数据库(Oracle、MySQL、Sqlite3等)读取数据。

后来,Tony在翻阅《设计模式》一书时,发现自己的解决方法与书中描述的“策略模式”甚为类似,简直就是用C语言实现的策略模式,而且这种模式几乎没有什么缺点,除了每增加一种策略(比如新增一种mem_allocator_t接口的实现),就要多维护一种策略的代码,但这样带来的负担与之前相比几乎是可以忽略不计的。

Observer模式的C实现

设计模式 (Design Pattern,以下简称DP)的定义有很多种。我个人的理解:DP是人们在软件开发过程中所总结出来的一些典型问题的经验解决方法模板。使用它们可以使我们的代码更易被复用,更易扩展,更好地适应变化以及更便于后期维护。

人们都说设计模式是独立于语言的,但这里的"语言"更多的是指面向对象语言,比如Java、C++、C#、Python和Ruby等。使用面向对象语言(OO)在实现设计模式时更为自然而然。GoF的经典书籍《Design Patterns》 的副标题就是"Elements of Reusable Object-Oriented Software",显然DP主要针对面向对象的软件开发,书中的内容也主要是用C++表述的。

相比于OO语言,关于C语言 等面向过程的语言与设计模式结合使用的资料和例子都甚少,它们就像是走在两条互相平行的马路上的路人,老死不相往来。难道我们真地找不到C与设计模式的交集吗,非也!设计模式强调高内聚,低耦合 ,一切面向接口!这种思想其实也有助于C程序员写出更加模块化、更加灵活以及更为优美的代码来。只是用C语言实现后所展现出来的形式与支持继承、多态的OO语言相比可能不是那么自然。

我相信在现实使用C语言的开发过程中,有些C程序员已经在不知不觉中使用了模式的思想,但DP对于多数C程序员还是略显生分的,虽然DP已经诞生10多年了,也许这与C语言诞生在设计模式之前不无关系^_^。

如何在日常开发中融入模式的思想呢?GoF在《Design Patterns》一书的第一章就告诉了我们,大致是一切从问题出发:弄清楚你遇到的问题,浏览模式,找到适合解决你的问题的模式。

言归正传。我们的系统常常有这样的业务情景:某种数据对象发生变化,与其相关的其他数据对象也需要一并做出改变。为了便于理解,这里举一个大家都比较易懂的例子:Tony是一个中国移动全球通(GoTone)用户,他订购了中国移动提供的139邮箱和手机报业务。中国移动有一个系统用于管理全球通用户信息、各种移动业务以及全球通用户的各种业务订购关系。Tony这个人记性不大好,每天丢三落四,同时也经常忘记及时缴纳手机话费,导致系统中Tony这个用户的状态经常在"正常"和"停机"间变来变去。为了防止Tony在停机的状态下依旧可以使用邮箱和手机报业务,移动公司提出了一个新需求:当用户处于"停机"状态时,用户应该无法使用139邮箱和手机报业务。如果你是负责开发这个需求的程序员,你如何在系统中满足这个需求呢?

将客户提出的需求翻译为程序员的行话就是当Tony的用户信息由"正常"变为"停机"时,系统需要同时修改139邮箱订购关系数据和手机报订购关系数据,将两类数据记录中Tony的订购关系由"开通"变为"暂停";当Tony的用户信息由"停机"变为"正常"时,系统则需要将两类数据记录中Tony的订购关系由"暂停"改为"开通"。总之,当全球通用户信息发生变化时,139邮箱订购关系与手机报订购关系数据就需要随着进行变更。

从例子中已有的描述可以看出,现有的系统内部至少有三套数据集以及相应的数据操作接口,分别是全球通用户数据、139邮箱订购关系数据和手机报订购关系数据,这也是最基本的数据封装与抽象。现在我们就在此基础上来满足新的需求。

很多人首先想到的是修改全球通用户数据操作接口,实现两类订购关系的随动变更。
void update_gotone_customer_state(const char *number, int state) {
    /* 根据number查找出对应的用户信息,并更新其state */
    … …

    /* 新增如下操作 */
    update_mailbox_order(number, state);
    update_newspaper_order(number, state);
}

我们可以看出这种方法是在全球通用户数据操作接口中直接调用两种订购关系的数据操作接口来修改数据状态,这种方法显然是耦合最高的方法,它在本无耦合的数据对象之间建立了耦合。update_mailbox_order和update_newspaper_order的变化将直接导致update_gotone_customer_state接口的变化。我们也无法独立地对update_gotone_customer_state接口进行单元测试了,除非对新增的两个依赖接口进行mock — 显然这种mock是被动的,也是不合理的。

为了去除这一方法引入的耦合,我们可以引入一个新函数来作为用户状态变更时的处理函数,如下:
void gotone_customer_state_switch(const char *number, int state) {
    update_gotone_customer_state(number, state);
    update_mailbox_order(number, state);
    update_newspaper_order(number, state);
}

这种方法依次调用三个数据集各自的操作接口更新用户状态。显然这种方法去除了数据集操作接口之间的耦合,但也存在着另外一个更严重的问题,那就是这种方法难以适应移动业务日新月异的变化。

假定此时用户Tony又订购了一个移动业务 -手机电视,那么如何让手机电视订购关系感知到用户状态的变化呢?你可能会这样来改。
void gotone_customer_state_switch(const char *number, int state) {
    update_gotone_customer_state(number, state);
    update_mailbox_order(number, state);
    update_newspaper_order(number, state);
    update_mobiletv_order(number, state);
}

你知道这样的修改意味着什么吗?意味着程序要重新编译,重新发布以及重新部署上线。一个用户新订购了一个业务就带来如此大的变动,这是不能忍受的,不是吗!

关于这类问题,DP给出了一个解决模式,即Observer模式,中文叫作观察者模式。观察者模式中有两个主要元素:主题(Subject)和观察者(Observer),主题的变更引发诸多相应观察者的随动更新。在这个例子中,全球通用户数据显然是一个Subject,而139邮箱、手机报以及手机电视等业务订购数据则是Observer的角色。

全球通用户数据是一个Subject,但是却是一个具体的Subject,如果让其与139邮箱等具体业务Observer直接关联,势必会像最初的解决方法那样在不同数据对象间引入耦合。DP始终在告诉我们要面向接口,要依赖抽象,所以我们需要建立抽象的Subject和Observer接口来。在C语言中没有interface关键字,也没有abstract class(抽象类),C语言只有struct和函数指针。将struct和函数指针结合,我们就有了C语言接口的概念:

/* isubject.h */
struct iobserver_t;

struct isubject_t {
    void (*attach)(struct isubject_t *subject, struct iobserver_t *observer);
    void (*detach)(struct isubject_t *subject, struct iobserver_t *observer);
    void (*notify)(struct isubject_t *subject, void *arg);
};

/* iobserver.h */
struct iobserver_t {
    void (*update)(void *arg);
};

isubject_t声明了三个函数指针字段,attach用于增加监视这个Subject的Observer的;detach用于卸载监视这个Subject的某个Obsever;而notify则是在Subject发生变化时用于通知各个Observer进行更新的。iobserver_t相对比较简单,只有一个update函数指针字段,该字段指向的函数在Subject发生变化时被调用,完成Observer自身的更新。

isubject_t只是一个抽象的接口,我们还需要isubject_t的一个具体实现,包括attach, detach以及notify等函数的具体实现算法。下面是isubject_t的一个具体实现isubject_imp_t的相关数据类型与操作接口:

/* isubject_imp.h */
struct isubject_t* isubject_imp_new();
void isubject_imp_free(struct isubject_t **subject);

/* isubject_imp.c */
typedef struct _iobserver_t _iobserver_t;

/* 这里用apache apr库中的apr_ring作为存储observers的数据结构 */
typedef APR_RING_HEAD(_iobserver_head_t, _iobserver_t) _iobserver_head_t;

struct isubject_imp_t {
    struct isubject_t subject; /* 这里务必将subject放在第一个字段的位置 */
    _iobserver_head_t observers;
};

struct isubject_t* isubject_imp_new() {
    struct isubject_imp_t *p = NULL;

    p = (struct isubject_imp_t*)malloc(sizeof(*p));
    if (!p) return NULL;

    memset(p, 0, sizeof(*p));
    APR_RING_INIT(&(p->observers), _iobserver_t, link);
    p->subject.attach = isubject_imp_attach;
    p->subject.detach = isubject_imp_detach;
    p->subject.notify = isubject_imp_notify;

    return (struct isubject_t*)p;
}

static void isubject_imp_attach(struct isubject_t *subject, struct iobserver_t *observer) {
    struct isubject_imp_t *p = (struct isubject_imp_t *)subject;

    //将observer插入ring,这里代码省略
}

static void isubject_imp_detach(struct isubject_t *subject, struct iobserver_t *observer) {
    struct isubject_imp_t *p = (struct isubject_imp_t *)subject;
   
    //将observer从ring中移出,这里代码省略
}
   
static void isubject_imp_notify(struct isubject_t *subject, void *arg) {
    struct isubject_imp_t *imp = (struct isubject_imp_t *)subject;

    //遍历ring,调用每个observer的update接口,并传入参数arg,这里代码省略
}

注意以上操作都是面向isubject_t这个接口类型的,而不是isubject_imp_t这个具体类型的,isubject_imp_t对于外部是不可见的。下面我们尝试用Observer模式解决一下上面例子中的问题。

首先建立tony这个用户的subject,并初始化其当前的业务Observer:
struct isubject_t *tony_subj = isubject_imp_new();
struct iobserver_t mailbox_observer = {
        .update = update_mailbox_order
};
struct iobserver_t newspaper_observer = {
        .update = update_newspaper_order
};

tony_subj->attach(tony_subj, &mailbox_observer);
tony_subj->attach(tony_subj, &newspaper_observer);

恰巧Tony上个月又忘记缴纳话费了,月初Tony的手机被停机了。中国移动的系统进行了Tony这个账户的状态变更操作:
tony_subj->notify(tony_subj, tony_account_info); /* tony_account_info表示Tony的基本账户信息 */

这行代码的执行结果是update_mailbox_order和upudate_newspaper_order被调用,139信箱以及手机报订购关系中有关Tony的订购信息状态变为"暂停"。也许上面的代码还没有让你看到Observer模式的好处,Ok,让我们进行一些变化!

移动为了推广3G业务,允许用户免费试用3个月的手机电视业务。有便宜不能不占,Tony遂订购了手机电视业务。前面的两种方法对于此种情况都无能为例,但采用了Observer模式之后,对于手机电视的订购,系统只需要在相应的处理函数中执行:
struct iobserver_t mobiletv_observer = {
        .update = update_mobiletv_order
};
tony_subj->attach(tony_subj, &mobiletv_observer);

这样一来我们无需修改Tony被停机时的处理代码,手机电视订购关系也可以感知到Tony的账户变更。

同样,Tony使用了移动的手机上网套餐,打开手机浏览器,天下信息便可一览无余。于是Tony决定退订有些鸡肋的手机报业务,对于这一退订事件,系统只需要在相应的处理函数中执行:
tony_subj->detach(tony_subj, &newspaper_observer);

可以看到Tony停机的处理无需因手机报退订而做出改变,同时Tony停机也不会导致手机报订购关系数据的变更,这正是我们期望的。

以上是Observer模式的一种C语言实现,同时也解决了我们遇到的实际问题。关于Observer模式的详细描述还是参见GoF的《Design Patterns》一书吧。用过程式语言实现DP的确不那么自然,但这就是C语言的方式。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats