Flutter中api实现自动生成(简单记录下)

Flutter中api实现自动生成

最近公司项目中电商模块从H5迁移为Flutter,在此过程中难免要对之前的接口再实现一遍。
最初设计是定义一单例的ApiUtil,再实现一基于ApiUtil的扩展,在扩展类中添加各个接口
的实现,部分代码如下:

class ApiUtil {
  ApiUtil._();

  static final ApiUtil _instance = ApiUtil._();

  static ApiUtil get inst => _instance;

  static Dio _dio = getDio();

  static Dio getDefaultDio() {
    Dio result = Dio(BaseOptions(
      connectTimeout: 10000,
      receiveTimeout: 10000,
    ));

    final adapter = result.httpClientAdapter as DefaultHttpClientAdapter;
      adapter.onHttpClientCreate = (client) {
        client.findProxy = (uri) {
          return "PROXY ";
        };
        client.badCertificateCallback =
            (X509Certificate cert, String host, int port) {
          return true;
        };
      };

    return result;
  }

  static Dio getDio() {
    _dio = getDefaultDio();
    _dio.interceptors
        .add(InterceptorsWrapper(onRequest: (RequestOptions options) {
      ...
    }, onError: (DioError error) {
      return error;
    }));
    return _dio;
  }

  Future<Response> get(
    String path, {
    data,
    Map<String, dynamic> queryParameters,
    CancelToken cancelToken,
    ProgressCallback onReceiveProgress,
    String contentType = Headers.jsonContentType,
  }) async {
    return _dio.get(path,
        options: Options(
          headers: {
            HttpHeaders.acceptHeader: "application/json,text/plain,*/*",
          },
          contentType: contentType,
        ),
        queryParameters: queryParameters,
        cancelToken: cancelToken,
        onReceiveProgress: onReceiveProgress);
  }

  Future<Response> post(
    String path, {
    data,
    Map<String, dynamic> queryParameters,
    CancelToken cancelToken,
    ProgressCallback onSendProgress,
    ProgressCallback onReceiveProgress,
    String contentType = Headers.formUrlEncodedContentType,
  }) async {
    return _dio.post(path,
        data: data,
        options: Options(
          headers: {
            HttpHeaders.acceptHeader: "application/json,text/plain,*/*",
          },
          contentType: contentType,
        ),
        queryParameters: queryParameters,
        cancelToken: cancelToken,
        onSendProgress: onSendProgress,
        onReceiveProgress: onReceiveProgress);
  }
}

扩展类:

extension BaseApiUtil on ApiUtil {
  Future<GetAppPageRsp> getAppPage(int pageType, {String pageId}) {
    Map<String, dynamic> data = {
      "pageType": pageType,
      "modeType": 1,
      "companyId": AppConfig.COMPANY_ID,
      "lang": "zh_CN",
      "platformId": 4
    };
    if (!StringUtil.isEmpty(pageId)) {
      data.addAll({"pageId": pageId});
    }
    Future<GetAppPageRsp> result = new Future(() async {
      Response rsp = await post(
        "${ApiConfig.base}/cms/page/getAppPage",
        data: data,
      );
      return GetAppPageRsp.fromJson(rsp.data);
    });
    return result;
  }

  Future<RecommendMpListRsp> recommendMpListByMpIds(List mpIds) {
    Map<String, dynamic> data = {
      "sceneNo": 2,
      "pageNo": 1,
      "pageSize": 24,
      "platformId": AppConfig.PLATFORM_ID,
      "mpIds": mpIds.join(','),
      "sessionId": GlobalData.sessionId,
      "areaCode": MallData.getAreaCode(),
    };
    Future<RecommendMpListRsp> result = new Future(() async {
      Response rsp = await get(
        "${ApiConfig.base}/search/rest/recommendMpList",
        queryParameters: data,
      );
      return RecommendMpListRsp.fromJson(rsp.data);
    });

    return result;
  }
  ...
}

查看扩展类中的部分代码,我们可以看到,其定义了基本的get/post方法,供其他接口实现时调用。相关模块在调用时使用ApiUtil.inst获取ApiUtil的实例,然后调用扩展中实现的方法。大致接口实现结构如下:

Future<应答类> 方法名(参数...) {
    Map<String, dynamic> data = {
      ...
    };
    Future<应答类> result = new Future(() async {
      Response rsp = await 请求方式(get/post)(
        "接口地址",
        "请求数据(get->queryParameters, post->data)": data,
      );
      return 应答类.fromJson(rsp.data);
    });

    return result;
  }

如果有新增接口实现的话,基本可以拷贝其中的接口实现,修改方法返回类型、方法名、参数列表、data中的Map数据、请求方式、接口地址。尽着能少写代码就少写代码的原则, 考虑接口实现部分代码能否自动生成呢?答案是肯定的,通过注解、source_gen和build_runner方式可以实现类似json_serializable那样的代码自动生成。我们在build.yaml中定义的builder,编译时扫描builder下的相关文件,搜集其中的注解,通过generator生成具体代码。以下是相关步骤:

  • 1 定义注解类
    我们通过注解来生成代码,那么肯定需要在注解中定义要搜集的一些信息。我们的目标是生成一个接口工具类,那么显而易见,首先需要定义一个目标类名,其次接口地址、请求方式、请求数据等也是必须的,具体定义如下:
class ApiGen {
  static const String GET = 'GET';
  static const String POST = 'POST';
  static const String PUT = 'PUT';
  static const String PATCH = 'PATCH';
  static const String DELETE = 'DELETE';

  final String target;// 目标类名
  final String url;// 接口地址
  final String method;// 请求方式
  final dynamic data;// 请求数据
  final String contentType;// 请求数据contentType
  final Map<String, dynamic> header;// 请求header
  final String requestName;// 请求方式名

  const ApiGen(this.url, {
    this.method = POST,
    this.data,
    this.contentType,
    this.header,
    this.requestName,
    this.target
  });
}
  • 2 创建注解解析生成器
class ApiGenerator extends GeneratorForAnnotation<ApiGen> {
  @override
  generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
    // 解析注解,生成目标代码
  }
}
  • 3 有了注解解析生成器,得能触发解析才可以执行,于是Builder就登场了。Builder有SharedPartBuilder、PartBuilder、LibraryBuilder,用于处理生成不同的文件。SharedPartBuilder生成.g.dart文件,在源文件中作为part引入,PartBuilder生成.自定义.dart文件,在源文件中作为part引入,LibraryBuilder生成单独的dart文件。因为我们要生成单独的文件,所以使用LibraryBuilder, 其中generatedExtension为生成文件的扩展名
Builder apiBuilder(BuilderOptions options) => LibraryBuilder(
  ApiGenerator(),
  generatedExtension: '.api.util.dart'
);
  • 4 build.yaml中配置builder,其中package_name为pubspec.yaml的name
    generate_for下的include为我们要扫描的文件
targets:
  $default:
    builders:
      package_name|api_builder:
        enabled: true
        generate_for:
          include: ['**.api_gen.dart']
builders:
  api_builder:
    import: 'package:package_name/annotation/api_builder.dart'
    builder_factories: ['apiBuilder']
    build_extensions: {'.api_gen.dart': ['.api.util.dart']}
    auto_apply: root_package
    build_to: source

当我们执行flutter packages pub run build_runner build --delete-conflicting-outputs执行编译时,build会读取build.yaml中的配置信息,读取到apiBuilder后触发注解生成器ApiGenerator,在GeneratorForAnnotation中调用generate来处理生成代码,可以看到generate中会调用generateForAnnotatedElement,故我们在generateForAnnotatedElement中实现注解相关解析处理即可。接口实现部分代码使用mustache模块来实现,相关语法说明可参考mustache

class ApiUtilTpl {
  static const String tpl = """
import 'package:dio/dio.dart';
{{#imports}}
import '{{{path}}}';
{{/imports}}

extension {{className}} on {{targetClassName}} {
  {{#functions}}
  {{{functionDefine}}} {
    {{#hasData}}
    {{{dataType}}} data = {{{dataValue}}};
    {{/hasData}}
    
    {{^withBodyWrapper}}
    {{#params}}
    if (null != {{paramName}}) {
      data["{{{paramName}}}"] = {{paramName}};
    }
    {{/params}}
    {{/withBodyWrapper}}
    
    {{{returnType}}} result = new Future(() async {
      Response rsp = await {{requestName}}(
          "{{{url}}}",
          {{#hasData}}{{#httpSendData}}data{{/httpSendData}}{{^httpSendData}}queryParameters{{/httpSendData}}: data,{{/hasData}}
          {{#hasContentType}}contentType: "{{{contentType}}}",{{/hasContentType}});
      {{#withBodyWrapper}}
      return {{{rspType}}}.fromJson(json.decode(rsp.data));
      {{/withBodyWrapper}}
      
      {{^withBodyWrapper}}
      return {{{rspType}}}.fromJson(rsp.data);
      {{/withBodyWrapper}}
    });

    return result;
  }
  {{/functions}}
}

""";
}

generateForAnnotatedElement中解析注解内容,并用mustache模板渲染代码

List<Map<String, dynamic>> functions = [];
List<Map<String, dynamic>> imports = [];
Map<String, bool> importMap = {};

class ApiGenerator extends GeneratorForAnnotation<ApiGen> {
  @override
  generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
    String baseUrl = '';
    /// 注解修饰的是类,注解中可添加baseUrl及生成目标类名
    if (element is ClassElement) {
      baseUrl = annotation.peek('url')?.stringValue ?? '';
      print('ClassElement baseUrl : ' + baseUrl);
      if (baseUrl.isEmpty) {
        print('please check annotation url of class : ' + element.name);
        return;
      }
    }

    addDocumentImport(element, buildStep);

    /// 遍历含有注解的类的成员,本文只处理接口方法注解
    element.visitChildren(SimpleVisitor(buildStep, baseUrl));

    Template tpl = Template(ApiUtilTpl.tpl);
    String content = tpl.renderString({
      'imports': imports,
      'className': annotation.peek('target')?.stringValue,
      'targetClassName': 'ApiBase',
      'functions': functions,
    });
    imports.clear();
    functions.clear();
    importMap.clear();
    return content;
  }

  /// 添加import包信息
  static void addDocumentImport(Element element, BuildStep buildStep) {
    if (element.documentationComment != null) {
      List<String> comments = element.documentationComment.split('\n');
      for (String elem in comments) {
        if (elem?.isNotEmpty ?? false) {
          if (elem.contains('package:')) {
            ApiGenerator.addImport(
                buildStep, elem.substring(elem.indexOf('package')));
          } else if (elem.contains('dart:')) {
            ApiGenerator.addImport(
                buildStep, elem.substring(elem.indexOf('dart')));
          }
        }
      }
    }
  }

  static void addImport(BuildStep buildStep, String path) {
    String result = path;
    if (path.startsWith('/${buildStep.inputId.package}/lib/')) {
      result =
      "package:${buildStep.inputId.package}/${path.replaceFirst('/${buildStep.inputId.package}/lib/', '')}";
    }
    if (!importMap.containsKey(result)) {
      importMap[result] = true;
      print("addImport path[$path]");
      imports.add({"path": result});
    }
  }
}

class SimpleVisitor extends SimpleElementVisitor {
  String _baseUrl;
  BuildStep _buildStep;
  SimpleVisitor(this._buildStep, this._baseUrl);

  @override
  visitMethodElement(MethodElement element) {
    ConstantReader reader = ConstantReader(TypeChecker.fromRuntime(ApiGen).firstAnnotationOf(element));
    if (reader == null) {
      print('firstAnnotationOf ' + element.name + ' is null');
      return;
    }

    Map<String, dynamic> funcInfo = {};
    Map<String, dynamic> defaultParams = {};

    funcInfo['functionDefine'] = element.toString();

    if (element.returnType.isVoid) {
      print('please check return type of method : ' + element.name);
      return;
    }

    var url = reader.peek('url')?.stringValue ?? '';
    if (url.isEmpty) {
      print('please check annotation url of method : ' + element.name);
      return;
    }
    funcInfo['url'] = _baseUrl + url;

    funcInfo["withBodyWrapper"] = false;
    ApiGenerator.addDocumentImport(element, _buildStep);

    var requestName = reader.peek('requestName')?.stringValue ?? '';
    var method = reader.peek('method')?.stringValue ?? '';
    switch (method) {
      case ApiGen.POST:
        requestName = 'post';
        funcInfo['httpSendData'] = true;
        break;

      case ApiGen.GET:
        requestName = 'get';
        funcInfo['httpSendData'] = false;
        break;

      default:
        print('unsupportable method : ' + method);
        return;
    }
    funcInfo['requestName'] = requestName;

    var data = reader.peek('data');
    funcInfo["hasData"] = data != null && data.objectValue != null;
    if (funcInfo["hasData"]) {
      funcInfo["dataType"] = AnnotationUtil.getDataType(data.objectValue);
      funcInfo["dataValue"] = AnnotationUtil.getDataValue(data.objectValue);
    } else {
      if ((element.parameters?.length ?? 0) > 0) {
        funcInfo["hasData"] = true;
        funcInfo["dataValue"] = "{}";
        funcInfo["dataType"] = "Map<String, dynamic>";
      }
    }

    /// 函数参数,收集有默认值的参数
    List<Map<String, String>> params = [];
    element.parameters?.forEach((parameterElement) {
      params.add({"paramName": parameterElement.displayName});
      if (parameterElement.defaultValueCode != null) {
        defaultParams[parameterElement.displayName] = parameterElement.defaultValueCode;
      }
    });
    funcInfo["params"] = params;

    /// 函数参数有默认值的情况,更新函数定义
    if (defaultParams.isNotEmpty) {
      Iterator<String> iterator = defaultParams.keys.iterator;
      String funcDef = element.toString();
      while (iterator.moveNext()) {
        String key = iterator.current;
        funcDef = funcDef.replaceFirst(key, key + ' = ' + defaultParams[key]);
      }
      funcInfo["functionDefine"] = funcDef;
    }

    /// 函数返回值
    DartType returnType = element.returnType;
    funcInfo["returnType"] = returnType.toString();

    /// 返回值为泛型
    if (AnnotationUtil.canHaveGenerics(returnType)) {
      List<DartType> types = AnnotationUtil.getGenericTypes(returnType);
      if (types.length > 1) {
        throw Exception("multiple generics not support!!!");
      }
      funcInfo["rspType"] = types.first.toString();
    }

    /// http contentType
    funcInfo['hasContentType'] = reader.peek('contentType')?.stringValue != null;
    if (funcInfo['hasContentType']) {
      funcInfo['contentType'] = reader.peek('contentType')?.stringValue;
    }

    /// 获取此函数需要的引入的包
    /// 返回值的包
    ApiGenerator.addImport(_buildStep, returnType.element.librarySource.fullName);

    /// 返回值为泛型
    if (AnnotationUtil.canHaveGenerics(returnType)) {
      List<DartType> types = AnnotationUtil.getGenericTypes(returnType);
      for (DartType type in types) {
        ApiGenerator.addImport(_buildStep, type.element.librarySource.fullName);
      }
    }
    functions.add(funcInfo);
  }
}

其中类AnnotationUtil代码如下:

class AnnotationUtil {
  static const String KEEP_NAME_PREFIX = "@C_";

  /// 获取 DartObject 数据值字符串。代码格式
  static String getDataValue(DartObject dartObject) {
    String result = "";
    if (dartObject.type.isDartCoreMap) {
      Map<DartObject, DartObject> map = dartObject.toMapValue();
      result = "{";
      map.forEach((key, value) {
        result += "\n${getDataValue(key)} : ${getDataValue(value)},";
      });
      result += "\n}";
    } else if (dartObject.type.isDartCoreString) {
      if (dartObject.toStringValue().startsWith(KEEP_NAME_PREFIX)) {
        return dartObject.toStringValue().substring(KEEP_NAME_PREFIX.length, dartObject.toStringValue().length);
      }
      return "\"${dartObject.toStringValue()}\"";
    } else if (dartObject.type.isDartCoreList) {
      List<DartObject> list = dartObject.toListValue();
      result = "[";
      list.forEach((element) {
        result += "\n${getDataValue(element)},";
      });
      result += "\n]";
    } else if (dartObject.type.isDartCoreInt) {
      result = "${dartObject.toIntValue()}";
    } else if (dartObject.type.isDartCoreDouble) {
      result = "${dartObject.toDoubleValue()}";
    } else if (dartObject.type.isDartCoreBool) {
      result = "${dartObject.toBoolValue()}";
    } else if (dartObject.type.isDynamic) {
      result = "${dartObject.toString()}";
    } else {
      throw Exception("data value [${dartObject.type}] not support!!!");
    }
    return result;
  }

  /// 获取 DartObject 数据类型。代码格式
  static String getDataType(DartObject value) {
    if (value.type.isDartCoreMap) {
      return "Map<String, dynamic>";
    } else if (value.type.isDartCoreString) {
      return "String";
    } else if (value.type.isDartCoreList) {
      return "List";
    } else if (value.type.isDartCoreInt) {
      return "int";
    } else if (value.type.isDartCoreDouble) {
      return "double";
    } else if (value.type.isDartCoreBool) {
      return "bool";
    } else if (value.type.isDynamic) {
      return "dynamic";
    } else {
      throw Exception("data type not support!!!");
    }
  }

  static List<DartType> getGenericTypes(DartType type) {
    return type is ParameterizedType ? type.typeArguments : const [];
  }

  static bool canHaveGenerics(DartType type) {
    final element = type.element;
    if (element is ClassElement) {
      return element.typeParameters.isNotEmpty;
    }
    return false;
  }
}

因为注解中只能使用常量字符串,像AppConfig.COMPANY_ID(1)这种常量,MallData.getAreaCode()这种方法调用,为了能在生成的代码中保留原始展示,在注解处理时需要做下特殊处理,避免转换成为常量数值或者普通字符串。上面代码中的KEEP_NAME_PREFIX部分处理即是为了保留原始注解值做的处理。

  • 注解用法示例test.api_gen.dart,其中类修饰注解中定义目标类名及baseUrl
/// package:package_name/api/api_base.dart
/// package:package_name/api/api_config.dart
@ApiGen('\${ApiConfig.base}', target: 'TestApi')
abstract class ApiInterface {
  /// package:package_name/api/base/constants.dart
  @ApiGen('/cms/page/getAppPage', data: {
    'platformId' : '@C_Constants.PLATFORM_ID'
  })
  Future<GetPageRsp> getAppPage(int pageType, {String pageId});
}

执行flutter packages pub run build_runner build --delete-conflicting-outputs后,会自动生成一文件test.api_gen.api.util.dart,其中内容如下:

// GENERATED CODE - DO NOT MODIFY BY HAND

// **************************************************************************
// ApiGenerator
// **************************************************************************

import 'package:dio/dio.dart';
import 'package:package_name/api/api_base.dart';
import 'package:package_name/api/api_config.dart';
import 'package:package_name/api/base/constants.dart';
import 'dart:async';
import 'package:package_name/api/get_app_page.dart';

extension TestApi on ApiBase {
  Future<GetPageRsp> getAppPage(int pageType, {String pageId}) {
    Map<String, dynamic> data = {
      "platformId": Constants.PLATFORM_ID,
    };

    if (null != pageType) {
      data["pageType"] = pageType;
    }
    if (null != pageId) {
      data["pageId"] = pageId;
    }

    Future<GetPageRsp> result = new Future(() async {
      Response rsp = await post(
        "${ApiConfig.base}/cms/page/getAppPage",
        data: data,
      );

      return GetPageRsp.fromJson(rsp.data);
    });

    return result;
  }
}

这种生成代码的方式有一个缺点是,因为代码需要自动生成,所以编译时间会稍微变长。示例完整代码详见flutter_api_gen

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,905评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,140评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,791评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,483评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,476评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,516评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,905评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,560评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,778评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,557评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,635评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,338评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,925评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,898评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,142评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,818评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,347评论 2 342

推荐阅读更多精彩内容