低代码平台中的GraphQL引擎

GraphQL是Facebook开源的一种面向对象图结构的查询语言。相比于REST服务调用方式,GraphQL提供了可以由调用者控制的、强大而灵活的数据重组能力,而所谓的低代码平台,其技术的基本价值也正在于将原先必须由程序员编码实现的功能,通过抽象和封装,以一种有限组合的方式提供给上层应用,因此可以想见,在低代码平台中GraphQL可以与其他组合机制相互促进,实现价值的最大化。在本文中,我将基于可逆计算原理对Nop平台中GraphQL引擎的设计进行一些理论分析,并介绍我们对GraphQL所做的一些扩展。

一. 对象分解

为了进行有效的管理和控制,对于任何一个复杂的系统,我们必然都会把它分解为很多相对独立的子部分。在面向对象技术的语境中,这表现为先有对象,再有方法,我们会先把业务系统划分为一系列的业务对象,然后围绕着这些业务对象来组织业务方法。也就是说,先有名再有实,给对象分配唯一的名称之后,我们可以将整个系统的概念空间划分为多个不相交的子空间

在这里插入图片描述

这种分解一般我们称之为纵向分解(主分解维度),它表现为在不同的层面,我们可以识别出类似的对象分解结构,例如在存储层我们识别出订单和客户表,而在界面层我们同样会识别出客户和订单对象的相关页面。

在低代码平台中,这种不同层面的相似性显然是一种可以被利用的信息。一般来说,低代码平台会采用约定大于配置的方式将底层的业务表(业务对象的持久化表示)按照缺省命名约定透出为Web层的某种资源链接(Endpoint),并为业务对象配备一组缺省的增删改查页面。也就是说,只要知道业务对象名,我们基本可以猜测出前后端代码所对应的访问路径、代码位置等信息。

Nop平台的具体做法是以数据模型为基础,自动生成实体定义、SQL表定义、GraphQL类型、前端页面等。以部门表Department为例,缺省情况下我们会生成一个GraphQL类型Department,并为主外键关联生成对应的属性,例如parent和children。如果增加了connection标签,我们还会为关联对象生成分页获取所对应的属性,例如usersConnection通过类似Relay Cursor Connection的方式来分页返回属于指定部门的用户。缺省情况下业务对象会自动继承CrudBizModel,所以它会自动生成GraphQL入口操作

extend type Query{
    Department__get(id:String!): Department
    Department__batchGet(ids:[String!]): [Department]
    Department__findPage(query:QueryBeanInput): PageBean_Department
    ...
} 
extend type Mutation{
    Department__save(data: DepartmentInput): Department
    Department__delete(id:String!): Boolean
    ...
}

Nop平台可以看作是一个自动化的软件生产线,它的输入是用户需求(以Excel文档的形式表达),输出是可运行的应用系统,主要通过系统化的增量式代码生成方案来实现生产线的运转。这其中,GraphQL Schema是根据Meta元数据定义和BizModel业务模型定义自动生成的一种中间产物,我们并不会手工编写GraphQL类型定义,在编写业务代码的过程中也不需要具有任何GraphQL相关的知识,不需要实现GraphQL特有的DataFetcher、DataLoader等接口。具体的技术细节在“差量流水线”一节中会有更详细的介绍。另外可以参考以下文章:

数据驱动的差量化代码生成器

二. Gather and Scatter

软件框架的作用在于对系统逻辑进行某种规范化的组织,对于底层引擎而言,最基本的组织能力是Gatter(从系统各处收集信息)和Scatter(将集中管理的信息分发到系统各处)。

Nop平台以业务对象(BizObject)为主分解维度来组织业务逻辑,它所要解决的一个基本问题是:如何构造BizObject以及如何将通用的知识应用于BizObject?

在这里插入图片描述

Nop平台支持从ProCode到LowCode再到NoCode的平滑过渡。首先,它内置了一些开箱即用的通用模型,例如实现增删改查的CrudBizModel。然后在开发实施阶段,我们可以通过LowCode技术,基于业务模型自动生成业务相关的一系列代码,并在这些生成代码的基础上进行增量化的调整。最后,在系统运行阶段,客户可以通过可视化设计器等以NoCode的方式定制系统已有的功能(修改、删除)或者增加新的功能。

NopGraphQL引擎在初始化的时候会利用IoC容器的动态扫描能力发现所有标记了@BizModel注解的bean,并把它们按照BizObjName配置进行归类合并。例如

@BizModel("NopAuthUser")
public class NopAuthUserBizModel extends CrudBizModel<NopAuthUser>{
    @BizMutation
    public void changeSelfPassword(@Name("oldPassword") String oldPassword,
                                   @Name("newPassword") String newPassword) {
        ...
    }
}


@BizModel("NopAuthUser")
public class NopAuthUserBizModelEx{
    @BizMutation
    public void otherOperation(){
         ... 
    }

    @BizMutation
    @Priority(NORMAL_PRIORITY-100)
    public void changeSelfPassword@Name("oldPassword") String oldPassword,
                                   @Name("newPassword") String newPassword) {
        ...
    }
}

NopAuthUserBizModel和NopAuthUserBizModelEx的BizObjectName都是NopAuthUser,它们的方法会叠加在一起共同生成NopAuthUser业务对象上的方法。当出现同名的函数时,会按照@Priority优先级配置选择优先级更高的实现。如果优先级相同且函数名相同,则会抛出异常。

NopGraphQL引擎在构造BizObject的时候还会检查xbiz扩展模型,我们可以通过在NopAuthUser.xbiz模型文件中增加方法来扩展BizObject,这个模型文件可以在线更新,更新后会即时起效,无需重新初始化GraphQL类型定义。xbiz文件中定义的方法优先级最高,它会自动覆盖JavaBean中定义的业务方法。

如果把对象名相同的BizModel看作是对象的一个切片,则NopGraphQL引擎相当于是在系统初始化的时候动态收集这些对象切片,然后像docker镜像一样把它们叠加在一起,构成完整的对象定义。在运行时,最上层的xbiz切片可以被动态修改,并覆盖下层切片的功能。

BizModel切片的概念有些类似于游戏开发领域中的Entity Component System (ECS)模式,只是它累加的是动态行为而不是局部状态。

与Gather对偶的能力是Scatter:我们经常需要做一些全局规则的抽象,需要将某些公共知识自动推送到不同的业务对象中。NopGraphQL主要通过AOP机制和元编程机制来实现信息的分发:

  1. 公共的机制可以作为AOP拦截器作用于符合条件的业务方法上

  2. xbiz文件中可以通过XLang中通用的x:gen-extends元编程机制动态生成方法定义。也可以使用外部的CodeGenerator来生成代码。

三. CRUD模型

在一般的业务开发中,CRUD(Create/Read/Update/Delete)操作往往是不同的业务对象中相似度最高的部分,因此有必要对它们进行统一抽象。NopGraphQL使用设计模式中的模板方法(Template Method)模式提供了通用的CRUD实现:CrudBizModel。具体使用方法是从CrudBizModel类继承,然后可以通过实现defaultPrepareSave/afterEntityChange等函数补充定制逻辑。参见代码

CrudBizModel.java

ObjMetaBasedValidator.java

NopAuthUserBizModel.java

3.1 元数据驱动

CrudBizModel采用的是元数据驱动的实现方式,它会读取xmeta配置文件中的内容,内置实现了数据验证、自动初始化、级联删除、逻辑删除、数据权限等多种常见需求,所以一般情况下只需要调整xmeta和xbiz配置文件,并不需要编写定制逻辑。

  1. 数据验证:类似于GraphQL的输出选择,NopGraphQL可以对输入字段进行选择性验证和转换,这体现了输入和输出的对偶性

    validatedData = new ObjMetaBasedValidator(bizObjName,objMeta,context)
                        .validateForSave(input,inputSelection)
    
  2. 自动初始化:在meta中可以配置字段的autoExpr表达式,更新或者修改的时候可以根据该配置自动初始化字段值。autoExpr表达式可以根据数据模型中的domain配置自动生成。

  3. 自动转换:根据meta中配置transformIn表达式,对输入的属性值进行适配转换。transformIn表达式可以根据数据模型中的domain配置自动生成。

  4. 级联删除:标记为cascade-delete的子表数据会随着主表数据的删除一并删除,而且会执行子表对应的BizObject业务对象上的定义的删除逻辑。

  5. 逻辑删除:如果启用delFlag逻辑删除标记字段,则底层的ORM引擎会自动将删除调用转换为修改delFlag的操作,并且对所有查询都自动应用delFlag=0的过滤条件,除非明确在SQL对象上设置disableLogicalDelete属性。

  6. 数据权限:所有读取到的实体记录都会自动验证是否满足数据权限要求。

3.2 复杂查询

CrudBizModel对于复杂查询提供了三个标准接口

PageBean<OrmEntity> findPage(QueryBean query, FieldSelectionBean selection);
List<OrmEntity> findList(QueryBean query);
OrmEntity findFirst(QueryBean query);
  1. findPage会根据查询条件返回分页查询结果,分页逻辑可以采用cursor+next page的方式,也可以采用传统的offset+limit的方式。selection对应于前端调用时传入的返回字段集合。如果没有要求返回total总页数,则findPage内部会跳过总页数查询,如果没有要求返回items数据列表,则实际会调整真正的分页查询本身

  2. findList根据查询条件返回列表数据,如果没有设置分页大小,则按照meta上的配置选择maxPageSize条记录。

  3. findFirst返回满足条件的第一条记录。

QueryBean类似于Hibernate中的Criteria查询对象,支持复杂的and/or嵌套查询条件以及排序条件。QueryBean可以由前台直接构造,在送到dao中真正执行之前它会经历如下处理过程:

  1. 验证查询条件中只包含标记为queriable的字段,且查询算符在每个字段的allowFilterOp集合中,缺省只允许按照相等条件进行查询。例如配置用户名支持模糊查询

    <!-- 支持按照相等或者模糊匹配的方式进行查询,缺省前端生成的控件为模糊查询 -->
    <prop name="userName" allowFilterOp="eq,contains" 
              xui:defaultFilterOp="contains"/>  
    
  2. 追加数据权限过滤条件,例如过滤只能查看管理单位是本单位的数据。

  3. 增加按主键字段排序的排序条件。分页查询时如果不进行排序,则因为数据库并发执行的原因,返回的结果集合可能是随机的。所以所有分页查询原则上都应该具有排序条件,确保排序后的分页顺序一致。

QueryBean利用底层的NopOrm引擎的能力,可以很自然的支持关联对象查询,例如

<eq name="manager.dept.type" value="1" />

表示按照 manager.dept.type = 1条件进行过滤,自动根据manager_id关联对应的部门表。

如果底层的ORM引擎不支持关联查询,也可以自行编写一个QueryTransformer接口来对QueryBean进行变换,例如将上面的等于判断转换为一个子查询

o.manager_id in (select user.id from User user, Dept dept
       where user.dept_id = dept.id and dept.type = 1)

在前端,为了通过以表单方式构造复杂查询条件,我们做了如下约定:

字段名格式为: filter_{propName}__{filterOp}

例如 filter_userName__contains表示按照contains运算符对userName字段进行过滤。对于filterOp为eq(等于条件)的情况,可以省略filterOp的部分,例如 filter_userId等价于filter_userId__eq

3.3 this指针:知识的相对化

GraphQL中定义的操作名是全局名称,例如 query{ getUser(id:3){ id, userName}}查询中用到的getUser方法需要在整个模型中具有唯一性,这一要求对于复用代码来说是不利的。

NopGraphQL中实现CRUD时只需要继承CrudBizModel基类,对外暴露的GraphQL操作名由对象名和方法名拼接而成。

class CrudBizModel<T>{
    @BizQuery
    @GraphQLReturn(bizObjName="THIS_OBJ")
    public T get(@Name("id")String id){
       .... 
    }
}

@BizModel("NopAuthUser")
class NopAuthUserBizModel extends CrudBizModel<NopAuthUser>{

}

上面的示例中,NopGraphQL引擎会自动生成一个query操作NopAuthUser_get,并且它的返回类型为THIS_OBJ,这意味着它会被替换为当前对象所对应的BizObjName,即NopAuthUser。

注意到,采用这种实现方案,我们可以针对同一个实现类提供不同的GraphQL类型。例如

@BizModel("NopAuthUser_admin")
public NopAuthUserAdminBizModel extends CrudBizModel<NopAuthUser>{

}

同样是从CrudBizModel<NopAuthUser>继承,但是因为BizModel注解中提供的bizObjName为NopAuthUser_admin,则get方法返回的字段集合可以有别于普通的NopAuthUser,对后台调用的权限要求也可能不一样。

也就是说,对象上的方法名是一个局部名称,它的语义是相对于this指针而定义的。在不具备全部知识的情况下,我们可以基于相对知识编制相当复杂的逻辑,然后注入不同的this指针,就可以改变整个一组调用的具体含义。这实际上是面向对象最基本的设计原理。

面向对象技术创造了一个特殊的名—this指针,它是一种约定了的固化了的局部名称。使用this指针使得我们区分了领域(domain)的内外。在domain外对象可以有各种称谓,而domain内我们直接通过this直接指代当前对象。

代码本身只是一种形式表达,它的具体含义需要一个诠释的过程才能确定。基于对象指针的调用形式直接导向了诠释的多样化:只要注入不同的this指针,就可以提供不同的诠释。

在前台的实现中,我们使用了类似的策略:前台脚本根据方法名的后缀自动判断方法签名,例如所有以_findPage为后缀的方法它的缺省签名都是

XXX_findPage(query:QueryBeanInput):PageBean_XXX

四. 框架无关的设计

使用传统的Web框架在编写业务代码的时候总是不可避免的会用到框架特有的一些环境对象,例如HttpServletRequest或者SpringMVC中的ModelAndView等。这些对象都和框架特定的运行时环境强相关,使得我们的代码与某个运行时环境绑定,难以应用到多种使用场景中。最明显的,一个为在线API调用编制的服务函数,一般无法直接作为消息队列的消费者来使用。我们必须抽象出一个额外的层次:Service层,然后在Service层的基础上分别包装为Controller和MessageConsumer,让它们负责响应Web请求和消息队列。

NopGraphQL在实现业务方法时,采用的是一种框架无关的非侵入式设计,它扩展了服务方法的使用场景,简化了服务层的编写。具体来说,NopGraphQL引入了少量注解,使用POJO对象来作为输入输出对象,自动将业务方法翻译为GraphQL引擎所需的DataFetcher和DataLoader。例如

@BizModel("MyEntity")
class MyBizModel{
    @BizQuery
    public MyEntity get(@Name("id")String id){
        return ... 
    }

    @BizLoader
    public String extProp(@ContextSource MyEntity entity){
        ...  
    }

    @BizLoader(forType=OtherEntity.class)
    public String  otherProp(@ContextSource OtherEntity entity){
       ...  
    }

    @BizLoader("someProp")
    public CompletionStage<List<SomeObject>> batchLoadSomePropAsync(
       @ContextSource  List<MyEntity> entities){
       ...
    }
}
  1. @BizQuery表示本方法将被映射为GraphQL中的query调用,@BizMutation将被映射为GraphQL中的mutation调用。

  2. @BizLoader为GraphQL类型的属性提供fetcher和loader定义。注意,为了保证概念的简单性,NopGraphQL要求所有属性都必须在xmeta文件中声明,BizModel中仅是为已定义的属性提供定制的加载器。

  3. 如果返回值类型为CompletionStage,则表示该方法异步执行

  4. 如果标注了@BizLoader注解的方法的ContextSource参数为 List类型,则表示它对应GraphQL的DataLoader实现,支持批量加载。

在这里插入图片描述

基于NopGraphQL引擎编写的服务方法,可以看作具有如下函数签名

ApiResponse<Object> service(ApiRequest<Map> request);

class ApiRequest<T>{
    Map<String,Object> headers;
    FieldSelectionBean selection;
    T data;
}

服务方法都是接收一个POJO的request对象,返回一个POJO的response对象。因为输入和输出都是简单对象,所以可以无需编码,只需要简单配置,就可以做到

  1. 把GraphQL服务方法发布为消息队列的消费者,它从一个topic接收request对象,向另一个topic发送返回消息,如果header中标注了one-way,则忽略返回消息。

  2. 将GraphQL服务方法发布为RPC服务函数

  3. 从批处理文件中读取Request对象,依次调用服务方法,批量提交,失败重试,然后把返回的Response消息写入到输出文件中。

五. REST Over GraphQL

GraphQL引擎可以运行在REST服务之上,提供所谓federation的功能,将多个REST服务组合为一个统一的GraphQL端点。那么反过来是不是也可以将底层的GraphQL服务方法拆解开来,暴露为一个个独立的REST资源?

NopGraphQL借助lazy字段的概念,对GraphQL类型定义Eager加载的属性集合,通过规范化的方式将GraphQL模型中的方法转化为REST服务。具体REST链接格式如下

/r/{operationName}?@selection=a,b,c{d,e}
  1. 通过request body来传参数

  2. /r/{operationName}为服务链接,通过可选的@selection参数来指定对返回结果的字段选择。如果不指定,则后台会自动返回所有没有标记为lazy的属性。代码生成的时候,关联表的数据缺省会被标记为lazy,因此它们在缺省情况下不会包含在REST调用的返回结果中。

如果是query请求,则可以通过GET方法来进行调用,此时可以通过URL参数来传递调用参数。例如

GET /r/NopAuthUser_get?id=3

等价于执行 NopAuthUser_get(id:3)。

Nop平台的前端框架在百度AMIS框架的基础上,对GraphQL调用做了进一步的简化。在前端,我们现在可以使用如下url格式来发起GraphQL调用,

api: {
    url: '@query:NopAuthUser__get/id,userName?id=$id'
}

上面的url链接使用了所谓的前缀引导语法,底层的ajaxFetch函数会识别@query:前缀,并把它转化为graphql请求

query($id:String){
    NopAuthUser_get(id:$id){
       id, userName
    }
}

ajaxFetch识别的graphql url的格式为

(@query|@mutation):{operationName}/{selection}?参数名=参数值

当我们需要为表单或者表格编写加载函数时,如果字段比较多,则手工编写graphql请求很容易出现字段遗漏。因为Nop平台的前端代码也是自动生成的,所以我们可以利用编译期信息自动生成graphql请求,使得我们恰好只选择表单或者表格中用到的数据。具体做法是引入编译期的变量formSelection, pageSelection等。例如

@query:NopAuthUser_get/{@formSelection}?id=$id

{@formSelection}表示选择当前表单中用到的所有字段。

前缀引导语法是一种适应性非常广泛的DSL扩展方案,具体介绍可以参见文章

六. GraphQL扩展

6.1 Map类型

GraphQL是一种强类型的框架,它要求所有数据都有明确的类型定义,这在某些动态场景中使用时并不方便。例如有的时候我们可能需要把一个扩展集合返回到前端。

NopGraphQL引入了一个特殊的Scalar类型: Map,可以利用它来描述那些动态数据结构。例如

type QueryBean{
    filter: Map
    orderBy: [OrderFieldBean]
}

6.2 树形结构

对于单位树、菜单树这样的树形结构的获取,NopGraphQL通过Directive机制提供了一个扩展语法,可以直接表达递归拉取数据,例如

NopAuthDept_findList{
    value: id,
    label: displayName
    children @TreeChildren(max=5)
}

@TreeChild(max=5)表示按照本层的结构最多嵌套5层。

七. 差量流水线

在日常开发中,我们经常可以发现一些逻辑结构之间存在相似性和某种不精确的衍生关系,例如后端数据模型与前端页面之间密切的关联,对于最简单的情况,我们可以根据数据模型直接推导得到它对应的增删改查页面,或者反向根据表单字段信息推导得到数据库存储结构。但是,这种不精确的衍生关系很难被现有的技术手段所捕获和利用,如果强行约定一些关联规则,则只能应用于非常受限的特定场景,而且还会导致与其他技术手段的不兼容性,难以复用已有的工具技术,也难以适应需求从简单到复杂的动态演化。

可逆计算理论为实现这种面向动态相似性的复用提供了标准的技术路线:

  1. 借助于嵌入式元编程和代码生成,任意结构A和C之间都可以建立一条推理管线

  2. 将推理管线分解为多个步骤 : A => B => C

  3. 进一步将推理管线差量化:A => _B => B => _C => C

  4. 每一个环节都允许暂存和透传本步骤不需要使用的扩展信息

作为这一技术策略的演示应用,Nop平台内置了从数据模型到前端页面的一条自动推理管线。

在这里插入图片描述

  1. 根据数据模型的需求文档(Excel格式),由代码生成工具自动生成ORM实体关系映射模型。例如从nop-auth.orm.xlsx生成_app.orm.xml。参见nop_auth.orm.xlsx_app.orm.xml

  2. ORM引擎真正使用的模型定义文件app.orm.xml从自动生成的_app.orm.xml文件继承,在关系模型的基础上可以补充更多ORM特定的配置,例如全局缓存策略、component映射、属性别名、动态字段映射等。

  3. 根据实体模型自动生成业务对象元数据定义xmeta,它定义了对象上具有哪些属性、属性的长度和类型等。参见_NopAuthUser.xmeta

  4. 程序中真正使用的xmeta是从自动生成的xmeta继承而来,在ORM映射关系的基础上,我们可以补充更多业务相关的信息,例如字段是否允许查询,能够按照哪些运算符进行查询,如果对输入字段进行校验和类型转换,如何添加数据库中并不存在的虚拟字段。

  5. 根据xmeta信息再补充BizModel中定义的操作函数、数据加载函数信息,自动生成GraphQL模型。

  6. 根据xmeta上定义的字段类型信息以及是否可修改、是否可显示等扩展信息,可以自动生成视图大纲模型xview。视图大纲模型主要定义界面上存在哪些表单和表格,表单和表格中字段如何布局,字段使用哪个控件进行显示等信息。自动生成的xview会缺省根据数据类型和自定义的数据域(domain)信息自动推导得到缺省布局和缺省控件。参见_NopAuthResource.view.xml

  7. 从自动生成的xview继承,对界面布局和字段级展现进行精细调整。参见NopAuthResource.view.xml

  8. 利用XLang内置的x:gen-extends元编程机制,调用<web:GenPage>标签根据视图大纲模型生成前端框架所需的json格式文件。参见main.page.yaml

  9. page.yaml页面描述文件中可以利用XLang内置的DeltaMerge机制对自动生成的页面描述进行调整。参见NopAuthUser/main.page.yaml。实际返回到前台的页面内容是编译期自动展开、合并后的结果,可以被AMIS框架直接使用,与Nop平台此前的处理步骤完全无关,参见_dump/main.page.yaml

  10. Nop平台中所有的模型都允许增加自定义的扩展信息,例如ext:show, xui:defaultFilterOp等,这些信息可以透传到下一阶段,由后续的步骤识别并使用。

在Nop平台所建立的这个生产管线中,ORM对应于存储模型,而Page对应于前端页面模型,为了简化从存储模型到页面模型的自动推导,增加了两个中间步骤:XMeta和XView,其中XMeta还可以产出中间产物GraphQL模型。XView相比于通用的Page,它的领域属性更强,以一种非常紧凑的方式描述界面上最关键的字段显示、字段布局、字段联动、API调用等信息,并可以在编译期进行有效性检查。XView原则上与具体的界面框架无关,所以可以根据它生成适应不同前端框架的页面文件。例如可以根据xview直接生成vue代码,而不一定是生成AMIS JSON描述。

整个推理关系的各个步骤都是可选环节:我们可以从任意步骤直接开始,也可以完全舍弃此前步骤所推理得到的所有信息。例如我们可以手动增加xview模型,并不需要它一定具有特定的xmeta支持,也可以直接新建page.yaml文件,按照AMIS组件规范编写JSON代码,AMIS框架的能力完全不会受到推理管线的限制。

总结

Nop平台是基于可逆计算原理从零开始构建的新一代低代码平台。它采用的是DSL优先、模型优先、自动测试优先的正向设计方案,而不是根据已有的程序框架结合部分低代码改造得到,在很多方面可以克服目前业界公开的低代码方案所存在的困难。

NopGraphQL是Nop平台后端服务的运行引擎,它与NopOrm和前端的AMIS框架相互配合,同时支持REST和GraphQL两种接口协议,通过模型驱动和元编程的方式自动生成代码,极大降低了需要手工编写的代码量。

关于可逆计算理论的详细介绍,可以参见我此前的文章
可逆计算:下一代软件构造理论
从张量积看低代码平台的设计
低代码平台需要什么样的ORM引擎(1)
低代码平台需要什么样的ORM引擎(2)

基于可逆计算理论设计的低代码平台NopPlatform已开源:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值