动态api设计文档

用模板引擎的方式撰写一段Sql脚本模板,并指明对应的参数含义,发布后前端可以通过统一的端点地址直接调用。

设计目标 #

目标: 用模板引擎的方式撰写一段Sql脚本模板,并指明对应的参数含义,发布后前端可以通过 /api/dynamic-api/invoke/my-api-name的方式直接调用,且调用支持多种HTTP方法和以不同的方式传递参数,如:查询参数、bodyheader参数方式。该设计思路与: http://www.51dbapi.com 类似。

基本过程 #

api定义(ApiDefinition): 定义一个api,指明唯一的name和备注信息等,并指明它的sql脚本和数据库链接等。

连接池(ConnectionPool): 多个api请求数据库的数据时共享同一个连接池,减少建立链接的过程。为api指定连接池,即指明对应的数据库链接地址,则直接执行调用;

api参数(ApiParameter): 定义一个api需要的参数,该参数的名称需要上述sql脚本使用的脚本保持一致。

配置动态api的过程如下:

  1. 提供表单,用户填写api的基本信息,如:英文名称、请求方法、备注信息等;
  2. 用户填写api运行时要执行的sql脚本,注意这里要使用到模板引擎,有很多判空的逻辑。本系统推荐使用liquid模板。
  3. 为该api指明sql脚本执行时调用哪个链接,即使用哪个数据库。弹出连接池列表选中一个。
  4. 用户填写api是否支持分页和排序,若支持,则自动含有分页和排序对应的查询参数,如:pageSize\MaxResult\Sorting等。
  5. 用户为该api添加参数,参数分为:查询参数、body参数和header参数,另外也需要添加参数的名称。这些参数需要与第2步中,脚本引擎引用的变量名称保持一致。
  6. 发布该api,配置该api的可访问permissions
  7. 测试api的可用性,提供参数的输入界面,点击测试后能返回对应的结果。

liquid模板的sql脚本实例如下:

{% assign param1 = 'value1' %}
{% assign param2 = 'value2' %}
{% assign param3 = null %}

{% assign sql_query = "SELECT * FROM your_table WHERE 1=1" %}

{% if param1 != null %}
    {% assign sql_query = sql_query | append: " AND column1 = '" | append: param1 | append: "'" %}
{% endif %}

{% if param2 != null %}
    {% assign sql_query = sql_query | append: " AND column2 = '" | append: param2 | append: "'" %}
{% endif %}

{% if param3 != null %}
    {% assign sql_query = sql_query | append: " AND column3 = '" | append: param3 | append: "'" %}
{% endif %}

{{ sql_query }}

可类比C#模板引擎: https://github.com/scriban/scriban , abp使用了该模板引擎,主要看对if语句的兼容情况,以及要保持语言简洁。

Handlebars 模板引擎来构造 SQL 查询语句的示例:

{{#with params}}
SELECT * FROM your_table WHERE 1=1
{{#if param1}} AND column1 = '{{param1}}'{{/if}}
{{#if param2}} AND column2 = '{{param2}}'{{/if}}
{{#if param3}} AND column3 = '{{param3}}'{{/if}}
{{/with}}

razor语法:

string template = @"
            @{
                var param1 = Model.param1;
                var param2 = Model.param2;
                var param3 = Model.param3;

                // 构造 SQL 查询语句
                var sqlQuery = ""SELECT * FROM your_table WHERE 1=1 order by "";
                if (!string.IsNullOrEmpty(param1))
                {
                    sqlQuery += "" AND column1 = '"" + param1 + ""'"";
                }
                if (!string.IsNullOrEmpty(param2))
                {
                    sqlQuery += "" AND column2 = '"" + param2 + ""'"";
                }
                if (!string.IsNullOrEmpty(param3))
                {
                    sqlQuery += "" AND column3 = '"" + param3 + ""'"";
                }
            }
            @sqlQuery";

var model = new { param1 = "value1", param2 = (string)null, param3 = "value3" };
string result = Engine.Razor.RunCompile(template, "templateKey", null, model);

动态api的返回对象 #

IsMultipleResult=true 或 IsPaging = true时:

{
    "totalCount": 100,
    "items":[{
        // ... 具体的实体
    }]
}

IsMultipleResult=false时:

{
   // 具体的实体结构
}

数据库设计 #

数据库连接池 ConnectionPool

字段名逻辑名数据类型长度约束说明
Id主键IdString36主键Id
DisplayText显示名称String64连接池名称
ConnectionString链接字符串String256链接字符串
DbType数据库类型String64数据库类型,如: Oracle、MySql等
Host主机地址String100主机地址
Port端口号String10端口号
UserName用户名String32用户名
Password密码String128密码
IsActive是否激活Bool1是否激活,可用状态
PoolSize连接池大小Integer32连接池大小
MaxConcurrency最大并发量Integer32最大并发量
Remark备注String备注

API定义 ApiDefinition

字段名逻辑名数据类型长度约束说明
Id主键IdString64主键Id
Name名称String64非空API名称,英文
Category类别String32类别,默认为default
ConnectionPoolId连接池IdString64非空连接池Id,表明从哪个数据库获得数据
HttpMethod请求方法String32非空请求方法,如:get\post\delete\put等
ScriptTemplate脚本模板String1024非空脚本模板,如使用一段 liquid代码
ScriptType脚本类型String64脚本类型,如: sql 语句, 或存储过程 procedure,默认为sql
TemplateEngine模板引擎String32非空如使用liquid解析模板
IsMultipleResult是否是多行记录Bool1是否返回多行记录,此设置影响返回结果的格式
IsPaging是否分页Bool1是否支持分页,若支持则自动有 pageSize 和 maxResult查询参数,不需要配置
IsSorting是否排序Bool1是否支持排序,若支持则自动有 sorting查询参数,不需要配置
Permissions需要的权限String512逗号隔开,只有对应的权限才可以访问该api,不填则不需要权限
Rank顺序值Integer32顺序值
Remark备注String备注信息

IsMultipleResult: 是用来指示返回结果的格式,为true时结果集为数组,也影响执行sql语句后对结果的处理上。

IsPaging: 指示是否分页,若分页就不需要在ApiParameter表增加pageSize和maxResult的查询参数,另外当IsPaging=true时, 必然 IsMultipleResult = true。

IsSorting: 指示是否排序,若有排序就不需要在ApiParameter表增加sorting的查询参数,在脚本模板里可以引用sorting字段。

API参数 ApiParameter

字段名逻辑名数据类型长度约束说明
Id主键IdString64主键Id
Name名称String64非空参数名称
DisplayText显示的中文文本String128显示的中文文本
DataType数据类型String32数据类型,如:float,integer,string,bool等。
ParamType参数类型String32非空参数类型,如:查询参数、路径参数(基本不大可能)、header参数或body请求体
Multiple是否是数组Bool1非空是否是数组
Required是否是必需的Bool1是否是必需的
DefinitionIdApi的定义IdString64该参数属于哪个Api定义的
Rank顺序值Integer32顺序值

接口设计 #



public interface ITemplateEngine{
    public string Name { get; }
    Task<string> Render(string template, IDictionary<string, object> input = null);
}

public class JsTemplateEngine: ITemplateEngine{
}
public class LiquidTemplateEngine: ITemplateEngine{
}
public class TemplateEngineManager {
    public void RegisterEngine(string engineName){}
    public ITemplateEngine GetEngine(string engineName);
}

public class ConnectionManager{
    // 先实现 Oracle和 mysql的 连接池获取
    // 需要用到连接池 ConnectionPool
    public IDbConnection GetConnection(Guid connectionPoolId){
    }
    
    public void ReturnConnection(IDbConnection connection){}
}

public class ExecutionContext{
    public ExecutionContext(string apiName, ApiDefinition definition, IEnumerable<ApiParameter>  parameters){
        ApiName = apiName;
        Definition = definition;
        Parameters = parameters;
    }
    public string ApiName{get; protected set;}
    public ApiDefinition Definition{get; protected set;}
    public IEnumerable<ApiParameter> Parameters{ get; protected set;}
    public bool CheckParameters(IDictionary<string, object> input){
        return true;
    }
}

public interface IDynamicApiExecutor{
    Task<object> ExecuteAsync(IDictionary<string, object> input);
}

public class DefaultDynamicApiExecutor: IDynamicApiExecutor{
    
     protected readonly TemplateEngineManager _templateEngineManager;
     protected readonly  ConnectionManager _connectionManager;
    
     public async Task<object> ExecuteAsync(ExecutionContext context, IDictionary<string, object> input = null){
        // 1.检查入参:根据传入的input和Parameters定义,检查 input的类型以及是否必需等信息,若不满足则抛出异常
         bool isSuccess = context.CheckParameters(input);
         
        // 2.渲染模板: 根据定义和对应的引擎类型,传入input获得渲染后的sql语句或调用存储过程的语句
         var engine = _templateEngineManager.GetEngine(context.Definition.TemplateEngine);
         string sqlQuery = engine.Render(context.Definition.ScriptTemplate, input);
         
        // 3.获取链接:根据Definition的ConnectionPoolId从连接池中获得链接
         IDbConnection connection = _connectionManager.GetConnection(context.Definition.ConnectionPoolId);
              
        // 4.执行sql: 建立链接,并启动调用。根据定义返回对应的结果
          var result =  connection.ExecuteSql(sqlQuery);
        // 5.归还链接:归还到连接池中
         _connectionManager.ReturnConnection(connection);
         
         return result;
    }
}

public class DynamicApiManager{
      protected IDynamicApiExecutor _executor;
      private async Task<ExecutionContext> BuildContext(string apiName){
          // var definition = _apiDefinitionRepository.FindAsync(s=>s.Name == apiName);
            return new ExecutionContext();
       }
    
      public async Task<object> ExecuteAsync(string apiName, IDictionary<string, object> input = null){ 
      	  var context = await BuildContext(apiName);
          return await _executor.ExecuteAsync(context, input);
      }
    
    public async Task<bool> CheckPermission(string apiName, string userId){
        var permissions = await repository.GetApiPermissions(apiName);
        return await _permissionManager.CheckPermissions(permissions, userId);
    }
}

Endpoint设计 #

IQueryCollection 解决查询参数不确定的问题。IFormCollection 解决POST方法时表单参数不确定的问题。

using Microsoft.AspNetCore.Http;

protected DynamicApiManager _dynamicApiManager;

[HttpPost("invoke/{apiName}")]
public IActionResult InvokePost(string apiName, IFormCollection formData, IQueryCollection queryParameters)
{
    var input = new Dictionary<string, object>(2);
    // 处理请求体参数
    formData.ForEach(s => input[s.Key] = s.Value);
    // 处理查询参数
    queryParameters.ForEach(s => input[s.Key] = s.Value);
    var result = _dynamicApiManager.ExecuteAsync(apiName, input);
    return Ok(result);
}

[HttpGet("invoke/{apiName}")]
public IActionResult InvokeGet(string apiName,  IQueryCollection queryParameters)
{
    var input = new Dictionary<string, object>(2);
    queryParameters.ForEach(s => input[s.Key] = s.Value);
    var result = _dynamicApiManager.ExecuteAsync(apiName, input);
    return Ok(result);
}

[HttpPut("invoke/{apiName}")]
public IActionResult InvokePut(string apiName, IFormCollection formData, IQueryCollection queryParameters)
{}

[HttpDelete("invoke/{apiName}")]
public IActionResult InvokeDelete(string apiName, IFormCollection formData, IQueryCollection queryParameters)
{}

注意:上述Action的URL地址配置都是一样的,但是HTTP方法都不一样,那么就可以实现调用侧只需要使用不同的方法但是相同的url地址实现api接口的使用。