0、前言

因为我以往的开发经历,习惯了在数据库端开发存储过程,封装业务逻辑,然后给应用层(.Net 或者 Java)调用;又因为我基于SAP HANA一体机做过数据仓库开发,比较熟悉HANA SQL开发,尤其是传统方式的视图、函数、存储过程开发;再加上对比了Hana数据库与其他传统关系型数据库内存计算性能之强大,所以在对 ABAP 程序做性能优化过程中,对于一些比较复杂的数据计算逻辑或大数据量计算的需求,就想着有没有方法让 ABAP 调用 Hana 数据库端存储过程来实现,以此来发挥 Hana 内存计算优势。这个思路,也正好与SAP Code Pushdown 思路契合一致。SAP正是由于更改了策略,调整其应用系统使用自家的SAP HANA数据库之后,统一了底层数据库,也就使得这个思路具备了实现的条件。

1、AMDP 简介

1.1 代码下沉(Code Pushdown)

随着 SAP HANA 的不断发展,促使 ABAP AS 与 SAP HANA 的结合越来越紧密,如今SAP S4 产品只支持 SAP HANA 数据库。
在这里插入图片描述
在如此背景下,ABAP 7.40 SP05 的发布,SAP ABAP 引入了一种新的应用开发范式,即所谓的代码下沉(Code Pushdown)。

传统的 ABAP 应用开发方式,即下图左边的 Data to Code,数据库仅仅作为数据的静态存储仓库,ABAP 应用开发人员通过 Open SQL 等方式将数据从数据库层读取到ABAP 应用层,再在 ABAP 应用层进行数据处理。
在这里插入图片描述
Code Pushdown 意味着一种编程理念的转变,即上图右边所示,将密集的数据计算从 ABAP 应用层下推到 HANA 数据库层,然后再将少量的计算结果传输到 ABAP 应用层加以展示,从而充分发挥 HANA 数据库高性能的数据处理能力。

而要实现 Code Pushdown ,SAP 必须提供一种技术,能够允许 ABAP 开发人员在应用层直接编写HANA 数据库层应用逻辑。这些应用逻辑可以实现在所谓的数据库过程(Database Procedure)里,实现语言为SAP HANA SQL Script。
在这里插入图片描述

1.2 AMDP 是托管数据库过程的容器

AMDP 托管数据库过程(ABAP-Managed Database Procedure,以下简称 AMDP ),即在 ABAP 层进行 HANA 数据库过程的实现和生命周期(Lifecycle)的管理。
在这里插入图片描述
开发人员使用Eclipse的ADT(ABAP Development Tool)工具,通过在 ABAP 层开发 AMPD 类, 使用 HANA SQL Script 编写具体逻辑,作为 AMDP 类的 AMDP 方法的实现,以此达到在 ABAP 层直接消费 HANA 数据库层原生功能的目的。在应用层即 ABAP 程序中管理数据计算逻辑和建模,激活后会在 HANA 中创建相应的数据库对象。这种特殊的 ABAP 类方法,作为 HANA SQLScript 的承载容器,使得 AMDP 同其他普通的 ABAP 开发对象一样,采取统一的 ABAP传输管理,生命周期管理,代码缺陷修复和升级管理方式。

使用 AMDP,可以将 ABAP Application 编写的代码通过 HANA 的新特性(代码下推技术),将逻辑在数据库层执行。除了 AMDP 之外,数据库过程代理是另一种 HANA 数据库过程的实现方式。这种方式首先在 HANA repository 里创建一个 HANA 原生的数据库过程,再到 ABAP 层创建一个代理指向前者,在 ABAP 应用里通过使用该代理对象,消费 HANA 仓库里的原生数据库过程。同 AMDP 相比,这种方式需要在 HANA 层进行原生开发,而 AMDP 则是在应用层提供了简单的调用SQL Script等数据库语言的方式。因此在实际的开发场景中,SAP更推荐使用AMDP。

1.3 AMDP 的优缺点

优点:

  • 可以充分发挥 Hana内存计算优势;
  • 能对 SQL Script 的静态代码检查(相比 NativeSQL);
  • 具备语法高亮(支持pretty printer格式优化器);
  • 在AMDP方法内不光能访问ABAP字典的视图和表,还能访问其它AMDP方法;
  • 可以像普通的ABAP方法一样调用(不包括AMDP function);
  • 可以进行传输管理(相比数据库传统开发模式);
  • 可以使用ST22进行运行时错误的详细分析。

缺点:

  • AMDP 还不能在 Eclipse、HANA Studio 上进行 Debug 操作;
  • 不能使用被代理表,如mseg表,但可以使用它的代理对象 NSDM_V_MSEG,或者直接访问 Hana 新表 MATDOC表;
  • 数据类型必须是表或者标准类型(INT CHAR …);
  • 对于传入的 Select-option,必须先在 ABAP 中用方法 cl_shdb_seltab=>combine_seltabs 处理后在传入 AMDP ;然后在AMDP中用 APPLY_FILTER;
  • AMDP 不能自动处理 Client 信息,使用时必须传入 SY-MANDT;否则取出来的数据是所有 Client 的全部数据。

1.4 几种数据库访问方式的区别

访问方式依赖ABAP DDIC,支持引用列表托管数据库连接禁用DDL自动Client、应用缓存
Open SQL
AMDP受限
ABAP Managed Native SQL受限
Non-ABAP-Managed Native SQL

1.5 几种数据库访问方式的选用

按SAP的官方建议,在可以使用Open SQL实现需要的功能或优化目标的时候,不建议使用AMDP。而在需要使用Open SQL不支持的特性,或者是大量处理流和分析导致了数据库和应用服务器之间有重复的大量数据传输的情况下,则应当使用AMDP。
在这里插入图片描述

几种方式的推荐使用顺序:

  • 优先使用OpenSQL 和 CDS,因为它简化了许多方面,例如客户端处理,并使编程变得简单,例如因为它可以巧妙地读取类型结构和表。因为OpenSQL能够访问CDS,所以推荐使用 CDS View来实现 Code Pushdown。
  • 如果数据计算复杂或者数据量特别大,为减轻应用层计算压力、降低数据库和应用层之间大量数据传输的网络负载,则可以考虑使用 Code Pushdown,将代码下沉到 Hana 数据库端实现,即采用 AMDP 托管数据库过程的方法。
  • 如果以上两种方法都无法达到目标,例如因为需要连接到另一个数据库或需要使用具有不同授权的辅助数据库连接,那么可以使用 ADBC ,不建议使用 Native SQL。

1.6 使用的开发工具

AMDP 在 SAP NetWeaver AS ABAP 7.40 SP05 版本中被引入。只有基于 Eclipse 的开发工具(即ADT,版本要不低于2.19)才支持 AMDP 类的编辑,SAP GUI 上面的 SE80 是不提供编辑功能的,只能用来阅读代码。
在这里插入图片描述

2、实现方法

AMDP类就是普通的类,只是实现了 IF_AMDP_MARKER_<DB_TYPE> 接口。如果是 HANA 数据库,那么就是实现接口 IF_AMDP_MARKER_HDB。虽然原则上 AMDP 是为了支持各种数据库的存储过程而存在的,但到目前(ABAP 7.52)为止,AMDP 只支持 SAP HANA 数据库。

AMDP 方法有两种实现,一种是 AMDP procedure 实现;另一种是 AMDP function 实现。

2.1 AMDP PROCEDURE(存储过程)实现

  • 这种方法,需要在实现 Method 时,使用附加项 BY DATABASE PROCEDURE。
  • 这种方法,它将AMDP方法实现为一个 Hana 数据库端的 procedure。
  • 这种方法,可以定义为公共的静态方法或者实例方法。
  • 这种方法和普通的 ABAP 对象方法在使用方式上没区别,ABAP 程序可以直接调用。

语法结构如下:

CLASS <my_amdp_class> DEFINITION.
  PUBLIC SECTION.
* 指定的Marker接口
  INTERFACES IF_AMDP_MARKER_<DB_TYPE>.
  [CLASS-]METHODS <my_amdp_method>
    [IMPORTING list]
    [EXPORTING list].
ENDCLASS.

CLASS <my_amdp_class> IMPLEMENTATION.
* AMDP 存储过程方法 
  METHOD <my_amdp_method> 
    BY DATABASE PROCEDURE 
    FOR <db_type>
    LANGUAGE <db_language>
    OPTIONS <db_options>  
    USING   <db_entity>.
    --使用数据库语言实现存储过程
  ENDMETHOD.
ENDCLASS.

说明:

  • BY DATABASE PROCEDURE: 实现一个数据库过程。
  • FOR HDB: 指定数据库类型为 HANA database。
  • LANGUAGE SQLSCRIPT: 指定用于实现AMDP的数据库特定语言为 SQLSCRIPT。
  • OPTION READ-ONLY: 设置只能在存储过程中读取数据库。
  • USING <table_name/view_name/amdp_func_method_name>: 设置使用的数据库表、视图或者是已定义的AMDP FUNCTION实现。

2.2 AMDP FUNCTION(函数)实现

  • 这种方法,需要在实现 Method 时,使用附加项 BY DATABASE FUNCTION。
  • 这种方法,它将 AMDP 方法实现为一个 Hana 数据库端的 function,可以是scalar function(值函数),也可以是table function(表函数)。
  • 这种方法不能被 ABAP 程序直接调用,只能被 AMDP PROCEDURE 方法使用。
  • 这种方法,实现 Table 时还有两种细分的实现方式,一种是 TABLE FUNCTION,可以定义为私有的静态方法或实例方法,另一种是 TABLE FUNCTION CDS,必须定义为公共的静态方法。

补充说明:

  • scalar function 是只读的用户定义的值函数,可以接收多个输入参数,接收单一的标量返回值。在 SQL Scitpt 中可以放在字段相同的位置使用。
  • table function 是只读的用户定义的表函数,可以接收多个输入参数,接收单一的表返回值。在 SQL Scitpt 中可以放在与数据库表或者视图相同的位置使用。
  • table function cds,可以简单理解为对 table function 名称(类名称=>方法名称)的一个形式转换,以 view 视图的方式来呈现。

语法结构如下:

CLASS <my_amdp_class> DEFINITION.
  PUBLIC SECTION.
* 指定的Marker接口
  INTERFACES IF_AMDP_MARKER_<DB_TYPE>.
  CLASS-METHODS <my_amdp_method_for_cds> FOR TABLE FUNCTION <my_cds_name>.
  PRIVATE SECTION.
  [CLASS-]METHODS <my_amdp_method>
    [IMPORTING list]
    [RETURNING single_value_or_table].
ENDCLASS.

CLASS <my_amdp_class> IMPLEMENTATION.
* AMDP 函数方法 
  METHOD <my_amdp_method> 
    BY DATABASE FUNCTION
    FOR <db_type>
    LANGUAGE <db_language>
    OPTIONS <db_options>  
    USING   <db_entity>.
	--使用数据库语言实现函数
	--值函数不需要return,表函数必须要return
    [RETURN select_from_table.]	
  ENDMETHOD.

  METHOD <my_amdp_method_for_cds> 
    BY DATABASE FUNCTION
    FOR <db_type>
    LANGUAGE <db_language>
    OPTIONS <db_options>  
    USING   <db_entity>.
	--使用数据库语言实现函数
    RETURN select_from_table.
  ENDMETHOD.
ENDCLASS.

说明:

  • BY DATABASE FUNCTION: 实现一个数据库函数。
  • FOR HDB: 指定数据库类型为 HANA database。
  • LANGUAGE SQLSCRIPT: 指定用于实现AMDP的数据库特定语言为 SQLSCRIPT。
  • OPTION READ-ONLY: 设置只能在存储过程中读取数据库。
  • USING <table_name / view_name / amdp_class_name=>method_name >: 设置使用的数据库表、视图或者是已定义的 AMDP FUNCTION 实现。

3、实例DEMO

步骤1:在ADT中,定义一个AMDP类,继承接口:IF_AMDP_MARKER_HDB

CLASS ycl_amdp_hdb_demo DEFINITION
  PUBLIC
  FINAL
  CREATE PUBLIC .

PUBLIC SECTION.
  INTERFACES IF_AMDP_MARKER_HDB .     "继承系统AMDP接口"

    TYPES:
        BEGIN OF ty_scarr,
            mandt     TYPE S_MANDT,
            carrid    TYPE s_carr_id,
            carrname  TYPE s_carrname,
            url       TYPE s_carrurl,
        END OF ty_scarr .
    TYPES:
        tt_scarr TYPE TABLE OF ty_scarr WITH EMPTY KEY .

  " 常规方法:Open SQL "
  CLASS-METHODS GET_SCARR_BY_OPENSQL
    IMPORTING
      VALUE(p_clnt)     TYPE s_mandt
      VALUE(p_carrid)   TYPE s_carr_id
      VALUE(p_carrname) TYPE s_carrname
      VALUE(p_url)      TYPE s_carrurl
    EXPORTING
      VALUE(et_scarr)   TYPE tt_scarr.


  "AMDP方式1: AMDP PROCEDURE实现,直接写SQLScript从HDB获取数据 "
  " 也可直接调用已创建的数据库对象,如存储过程、视图、函数等 "
  " 该方法可以直接被ABAP程序调用,必须设置为PUBLIC "
    CLASS-METHODS GET_SCARR
    IMPORTING
      VALUE(p_clnt)     TYPE s_mandt
*      VALUE(p_carrid)   TYPE s_carr_id
*      VALUE(p_carrname) TYPE s_carrname
*      VALUE(p_url)      TYPE s_carrurl
    EXPORTING
      VALUE(et_scarr) TYPE tt_scarr.

  "AMDP方式2-1-1: AMDP function实现,直接写SQLScript从HDB获取数据 "
  " 该方法不能直接被ABAP程序调用,只能在AMDP PROCEDURE实现中调用,建议可设置为 PRIVATE "
  CLASS-METHODS GET_SCARR_FUNC
    IMPORTING
      VALUE(p_clnt)     TYPE s_mandt
*      VALUE(p_carrid)   TYPE s_carr_id
*      VALUE(p_carrname) TYPE s_carrname
*      VALUE(p_url)      TYPE s_carrurl
    RETURNING
      VALUE(et_scarr)   TYPE tt_scarr.

	"AMDP方式2-1-2: AMDP procedure实现,调用上述的 AMDP function实现 "
    CLASS-METHODS GET_SCARR_BY_FUNC
    IMPORTING
      VALUE(p_clnt)     TYPE s_mandt
*      VALUE(p_carrid)   TYPE s_carr_id
*      VALUE(p_carrname) TYPE s_carrname
*      VALUE(p_url)      TYPE s_carrurl
    EXPORTING
      VALUE(et_scarr)   TYPE tt_scarr.
  
  "AMDP方式2-2-1: AMDP procedure实现,为 TABLE FUNCTION CDS编写具体实现逻辑 "
  " 该方法无法直接被ABAP程序调用,但是对应的CDS可以被ABAP调用。 "
  " 此处对应的CDS名为:YCDS_INVENTORY,需要另外单独定义 "
  " 定义为 TABLE FUNCTION CDS 时,方法必须为 PUBLIC CLASS-METHODS "
  CLASS-METHODS GET_SCARR_FOR_CDS FOR TABLE FUNCTION YCDS_ADMP_DEMO_SCARR.

  "AMDP方式2-1-2: AMDP procedure实现,调用上面 AMDP function 实现的CDS "
  CLASS-METHODS GET_SCARR_BY_CDS
    IMPORTING
      VALUE(p_clnt)     TYPE s_mandt
*      VALUE(p_carrid)   TYPE s_carr_id
*      VALUE(p_carrname) TYPE s_carrname
*      VALUE(p_url)      TYPE s_carrurl
    EXPORTING
      VALUE(et_scarr) TYPE tt_scarr.

PROTECTED SECTION.
PRIVATE SECTION.

ENDCLASS.

CLASS ycl_amdp_hdb_demo IMPLEMENTATION.

  METHOD GET_SCARR_BY_OPENSQL.
    select mandt, carrid, carrname, url
    from scarr
    where ( carrid = @p_carrid or @p_carrid = '' )
       or ( carrname = @p_carrname or @p_carrname = '' )
       or ( url = @p_url or @p_url = '' )
    into CORRESPONDING FIELDS OF TABLE @et_scarr.
  ENDMETHOD.

  METHOD GET_SCARR
    BY DATABASE PROCEDURE FOR HDB
         LANGUAGE SQLSCRIPT
         OPTIONS READ-ONLY
         USING SCARR.
      ET_SCARR =
        SELECT
            MANDT, CARRID, CARRNAME, URL
        FROM SCARR
        WHERE MANDT = :p_clnt;
  endmethod.

  method GET_SCARR_FUNC
    BY DATABASE FUNCTION FOR HDB
         LANGUAGE SQLSCRIPT
         OPTIONS READ-ONLY
         USING SCARR.
    RETURN
        SELECT
            MANDT, CARRID, CARRNAME, URL
        FROM SCARR
        WHERE MANDT = :p_clnt;
  ENDMETHOD.

  METHOD GET_SCARR_BY_FUNC
    BY DATABASE PROCEDURE FOR HDB
         LANGUAGE SQLSCRIPT
         OPTIONS READ-ONLY
         using ycl_amdp_hdb_demo=>GET_SCARR_FUNC.
     ET_SCARR =
       select *
       from "YCL_AMDP_HDB_DEMO=>GET_SCARR_FUNC"
       (p_clnt => :p_clnt);
  ENDMETHOD.

    METHOD GET_SCARR_FOR_CDS
         BY DATABASE FUNCTION FOR HDB
         LANGUAGE SQLSCRIPT
         OPTIONS READ-ONLY
         USING SCARR.
    RETURN
        SELECT
            MANDT, CARRID, CARRNAME, URL
        FROM SCARR
        WHERE MANDT = :p_clnt;
  ENDMETHOD.

  METHOD GET_SCARR_BY_CDS
    BY DATABASE PROCEDURE FOR HDB
         LANGUAGE SQLSCRIPT
         OPTIONS READ-ONLY
         using YCDS_ADMP_DEMO_SCARR.
     ET_SCARR =
        select MANDT, CARRID, CARRNAME, URL
        from YCDS_ADMP_DEMO_SCARR( P_CLNT => :P_CLNT );
  ENDMETHOD.

ENDCLASS.

步骤2:在ADT中,定义 TABLE FUNCTION CDS

@EndUserText.label: 'ADMP_DEMO_SCARR'
define table function YCDS_ADMP_DEMO_SCARR
with parameters 
    @Environment.systemField: #CLIENT
    p_clnt  : abap.clnt
returns {
  mandt     : abap.clnt;
  carrid    : s_carr_id;
  carrname  : s_carrname;
  url       : s_carrurl;
}
implemented by method ycl_amdp_hdb_demo=>get_scarr_for_cds;

步骤3:在SAP中,开发ABAP程序,调用AMDP类

*&---------------------------------------------------------------------*
*& Report YZ_AMDP_DEMO
*&---------------------------------------------------------------------*
*&
*&---------------------------------------------------------------------*
REPORT yz_amdp_demo.

TYPES:
    BEGIN OF ty_scarr,
        mandt     TYPE s_mandt,
        carrid    TYPE s_carr_id,
        carrname  TYPE s_carrname,
        url       TYPE s_carrurl,
    END OF ty_scarr .
TYPES:
    tt_scarr TYPE TABLE OF ty_scarr WITH EMPTY KEY .

DATA: gt_scarr TYPE tt_scarr.

DATA: iv_carrid   TYPE s_carr_id,
      iv_carrname TYPE s_carrname,
      iv_url      TYPE s_carrurl.

PARAMETERS: p_proc    RADIOBUTTON GROUP g1 DEFAULT 'X', " Method of AMDP-Proc "
            p_proc_c  RADIOBUTTON GROUP g1,             " Method of AMDP-Proc Call CDS "
            p_open    RADIOBUTTON GROUP g1,             " Method of Open SQL "
            p_func    RADIOBUTTON GROUP g1,             " Method of AMDP-Func "
            p_proc_f  RADIOBUTTON GROUP g1,             " Method of AMDP-Proc Call Func "
            p_open_c  RADIOBUTTON GROUP g1.             " Open SQL call CDS view "

START-OF-SELECTION.

  IF p_open_c EQ 'X'.
    SELECT * FROM ycds_admp_demo_scarr
    INTO CORRESPONDING FIELDS OF TABLE @gt_scarr.
    cl_demo_output=>display( gt_scarr ).   " 报错,改用ALV显示 "

  ELSE.

    IF cl_abap_dbfeatures=>use_features(
              EXPORTING
                requested_features = VALUE #( ( cl_abap_dbfeatures=>call_amdp_method )
                                                      ( cl_abap_dbfeatures=>amdp_table_function ) ) ).

      TRY ."异常捕捉 "
        IF p_proc EQ 'X'.     " Method of AMDP-Proc "
          ycl_amdp_hdb_demo=>get_scarr(
            EXPORTING
              p_clnt       =  sy-mandt
            IMPORTING
              et_scarr = gt_scarr ).
        ENDIF.
        IF p_proc_c EQ 'X'.   " Method of AMDP-Proc Call CDS "
          ycl_amdp_hdb_demo=>get_scarr_by_cds(
            EXPORTING
              p_clnt       =  sy-mandt
            IMPORTING
              et_scarr = gt_scarr ).
        ENDIF.
        IF p_open EQ 'X'.     " Method of Open SQL "
          ycl_amdp_hdb_demo=>get_scarr_by_opensql(
            EXPORTING
              p_clnt       =  sy-mandt
              p_carrid     =  iv_carrid
              p_carrname   =  iv_carrname
              p_url        =  iv_url
            IMPORTING
              et_scarr = gt_scarr ).
        ENDIF.
        IF p_func EQ 'X'.     " Method of AMDP-Func, Dump, Not Allowed to call an AMDP Function Method "
          ycl_amdp_hdb_demo=>get_scarr_func(
            EXPORTING
              p_clnt       =  sy-mandt
            RECEIVING
              et_scarr = gt_scarr ).
        ENDIF.
        IF p_proc_f EQ 'X'.     " Method of AMDP-Proc Call Func "
          ycl_amdp_hdb_demo=>get_scarr_by_func(
            EXPORTING
              p_clnt       =  sy-mandt
            IMPORTING
              et_scarr = gt_scarr ).
        ENDIF.

        CATCH cx_amdp_error INTO DATA(amdp_error).
          cl_demo_output=>display( amdp_error->get_text( ) ).
          RETURN.
        CATCH cx_ai_system_fault INTO DATA(zcl_cx_ai_system_fault).
*          EV_STATUS = 'E'.
*          EV_MESSAGE =  ZCL_CX_AI_SYSTEM_FAULT->GET_TEXT( ).
          EXIT.
        CATCH cx_ai_application_fault INTO DATA(zcl_cx_ai_application_fault).
*          EV_STATUS = 'E'.
*          EV_MESSAGE = ZCL_CX_AI_APPLICATION_FAULT->GET_TEXT( ).
          EXIT.
      ENDTRY.

        cl_demo_output=>display( gt_scarr ).   " 报错,改用ALV显示 "
        "PERFORM frm_alv_show.
    ELSE.
      cl_demo_output=>display( '警告!当前系统不支持AMDP.' ).
    ENDIF.

  ENDIF.

附、AMDP异常

AMDP procedure 实现的异常名前缀是 CX_AMDP。这些异常都在目录 CX_DYNAMIC_CHECK 之下,必须使用 RASING 显式地在 AMDP procedure 实现的定义中声明。

CX_ROOT
|–CX_DYNAMIC_CHECK
| |–CX_AMDP_ERROR
| | |–CX_AMDP_VERSION_ERROR
| | | |–CX_AMDP_VERSION_MISMATCH
| | |–CX_AMDP_CREATION_ERROR
| | | |–CX_AMDP_CDS_CLIENT_MISMATCH
| | | |–CX_AMDP_DBPROC_GENERATE_FAILED
| | | |–CX_AMDP_DBPROC_CREATE_FAILED
| | | |–CX_AMDP_NATIVE_DBCALL_FAILED
| | | |–CX_AMDP_WRONG_DBSYS
| | |–CX_AMDP_EXECUTION_ERROR
| | | |–CX_AMDP_EXECUTION_FAILED
| | | |–CX_AMDP_IMPORT_TABLE_ERROR
| | | |–CX_AMDP_RESULT_TABLE_ERROR
| | |–CX_AMDP_CONNECTION_ERROR
| | | |–CX_AMDP_NO_CONNECTION
| | | |–CX_AMDP_NO_CONNECTION_FOR_CALL
| | | |–CX_AMDP_WRONG_CONNECTION

注意:AMDP function没有异常类。

原创文章,转载请注明来源-X档案

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐