点击我的视频网校,学习编程课程,或者关注我的微信公众号“编程之路从0到1”,了解课程更新

Dart 注解与代码生成

Dart 注解与代码生成

Dart语言在Flutter中禁用了反射,因此我们无法使用反射去实现一些框架功能,甚至无法去识别注解,为此Dart提供了另一套方案,就是编译前通过解析器,静态的生成代码。

入门示例

如果我们想使用注解来生成Dart代码,那么我们至少要创建两个Dart包。一个用来提供注解类,一个用来生成代码。

先看一个简单例子,假设我们现在想开发一个基于Dart注解的ORM库,那么分别创建两个包,命名为:ormorm_generator

在相同目录下,使用Dart命令分别先创建两个库模版工程,然后再创建一个example工程作为Demo来测试我们编写的orm库:

dart create -t package-simple orm
dart create -t package-simple orm_generator
dart create -t console-simple example

目录结构如下:

|--dir
	|--orm
	|--orm_generator
	|--example

声明注解

orm包中,创建lib/src/annotations.dart文件:

/// 声明一个具有常量构造方法的类
class Table {
  final String? name;

  const Table({this.name});
}

我们知道,在Dart语言中,任意一个具有const 构造方法的类,都可以作为注解使用。

注意,自动生成的模版工程,会在lib下创建一个与工程同名的导包文件orm.dart,我们可以在里面导出src下的各种文件:

library orm;

export 'src/annotations.dart';

这样一来,其他人就可以仅使用一句简洁的导入语句来使用注解类:import 'package:orm/orm.dart';

工程结构:

使用注解

这里也就是要依赖注解。进入example工程,就像使用其他第三方库一样,在pubspec.yaml文件中配置依赖:

dependencies:
  orm:
    path: ../orm/

注意,这里配置的是本地依赖,如果我们的库最终上传到Dart的pub仓库,就要改成版本号依赖了。

创建bin/example.dart文件:

import 'package:orm/orm.dart';

@Table(name: 'students')
class Student{
  String? _name;
  int? _age;
}

可以看到,Dart中注解的使用十分简单,使用@符号引用我们写的注解,并给注解的构造传参。运行代码好像什么也不会发生,这是因为我们还没有处理这些注解,后面我们就会扫描这些注解,并生成相应的代码。

现在应该思考的是,我们想用这些注解干嘛?

显然的,我们在Student类上使用了注解,其实是想将Student类和数据库中的students表映射起来,Student中的字段就对应表中的字段。我们希望避免使用SQL语言操作数据库,通过对类的操作就能自动实现对数据库的操作,这就是ORM的功能。

在Dart上要想实现这一点,只能通过注解来自动生成大量模版代码,以达到代码复用,简化开发的目的。我们这里只简单的模拟这个需求,转到具体实现上,我们希望根据上述注解,生成如下代码:

class StudentEntity {
  String? _name;
  int? _age;

  String? get name => _name;
  set name(String? name) {
    this._name = name;
  }

  int? get age => _age;
  set age(int? age) {
    this._age = age;
  }

  void save() {
    print('execute sql:[insert into students values ($_name,$_age)]');
  }
}

这里save方法仅简单模拟执行SQL语句。

工程结构:

生成代码

上面我们简述了两头的环节,现在具体来说一说中间的环节,也是关键的一环,如何扫描识别注解,并生成Dart模版代码。

1. 配置依赖

进入orm_generator包,在pubspec.yaml文件中添加依赖:

dependencies:
  build:
  source_gen:
  orm:
    path: ../orm/

dev_dependencies:
  build_runner:

2. 创建元素访问器

创建lib/src/visitor.dart

import 'package:analyzer/dart/element/visitor.dart';
import 'package:analyzer/dart/element/element.dart';

/// 派生自己的元素访问器用于扫描
class MyVisitor extends SimpleElementVisitor<void> {
  String? className;
  final fields = <String, dynamic>{};

  @override
  void visitConstructorElement(ConstructorElement element) {
    className = element.type.returnType.toString();
  }

  @override
  void visitFieldElement(FieldElement element) {
    final elementType = element.type.toString();
    fields[element.name] = elementType;
  }
}

这里的元素访问器主要用来扫描被注解的类,在此例中,也就是Student类。SimpleElementVisitor提供了许多有用的扫描方法,我们自定义的类仅实现了其中两个,visitConstructorElementvisitFieldElement分别用来扫描Student的构造方法和成员字段。

扫描构造方法时,通过它的返回类型,可以拿到该类的类名,这里将其保存到className变量中。扫描类成员变量时,将成员变量名和变量类型保存到fields字典中。

3. 创建代码生成器

创建lib/src/table_generator.dart文件

import 'package:analyzer/dart/element/element.dart';
import 'package:build/src/builder/build_step.dart';
import 'package:orm/orm.dart';
import 'package:source_gen/source_gen.dart';

import '../generators.dart';

class TableGenerator extends GeneratorForAnnotation<Table>{

  @override
  generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
    final visitor = MyVisitor();
    // 传入我们自己定义的元素访问器
    element.visitChildren(visitor);

    final codeBuffer = StringBuffer();
    for (final field in visitor.fields.keys) {
      codeBuffer.writeln("${visitor.fields[field]} $field ;");
    }
    codeBuffer.writeln();

    // 遍历类的成员字段
    for (final field in visitor.fields.keys) {
      if(field.startsWith('_')){
        // 去除私有变量的下划线
        final variable = field.replaceFirst('_', '');
        //生成: String get name => _name;
        codeBuffer.writeln(
            "${visitor.fields[field]} get $variable => $field;");

        // 生成: set name(String name) {
        //       this._name = name;
        //     }
        codeBuffer
            .writeln('set $variable(${visitor.fields[field]} $variable) {');
        codeBuffer.writeln('this.$field = $variable;');
        codeBuffer.writeln('}');
      }
    }

    var ann = annotation.peek('name');
    var tableName = ann != null ? ann.stringValue : visitor.className;

    var sb = StringBuffer();
    for (final field in visitor.fields.keys) {
      sb.write('\$$field,');
    }
    var parameter = sb.toString().substring(0,sb.length-1);

    var code = '''
class ${visitor.className}Entity{
  $codeBuffer
  
  void save(){
    print('execute sql:[insert into $tableName values ($parameter)]');
  }
}
    ''';
    return code;
  }
}

这里我们自定义类继承自GeneratorForAnnotation,它的泛型指定为我们想要扫描的注解类型。这个类的名字非常清晰,表明通过注解来生成代码。

我们必须实现该类的generateForAnnotatedElement方法,该方法的返回值是一个String,也就是我们想要生成的Dart源码。这个方法的实现非常简单,主要就是在做字符串的拼接,拼接完成之后直接返回。这里值得注意的有两个地方,首先我们实例化之前写好的元素访问器,并通过element.visitChildren(visitor);设置我们的访问器,访问器中的逻辑主要实现对被注解类的扫描。然后将我们需要的数据解析出来,接下来就可以拼接生成Dart源码了。另一地方是我们使用了该方法的第二个参数ConstantReader,通过调用它的peek方法来查询注解中传入的参数name,因为这里name是可选的,所以必须判空。

4. 创建构建器

上面的逻辑虽然都写好了,但是显然还缺乏一个入口,谁去调用上面的一系列逻辑呢?此外,也还缺乏文件生成的逻辑,别忘了generateForAnnotatedElement方法只是返回了字符串,并没有涉及在磁盘上写文件的操作。这时候,我们还需要创建一个构建器,由构建器来完成一系列的调用。

创建lib/src/builder.dart文件:

import 'package:build/build.dart';
import 'package:orm_generator/src/table_generator.dart';
import 'package:source_gen/source_gen.dart';

Builder generateTable(BuilderOptions options) =>
    SharedPartBuilder([TableGenerator()], 'table_generator');

这里我们定义了一个全局函数,它的参数是BuilderOptions类型,返回值必须是一个构建器Builder类型。注意,此处我们不用自定义一个构建器,这是因为我们依赖了source_gen库,这个库为我们实现了三个Builder的子类,这里使用了其中的SharedPartBuilder,省去了我们许多工夫。创建SharedPartBuilder对象时需要两个参数,第一个是生成器列表,第二个是传一个唯一标识符作为ID。

最后,我们还需要在工程的根目录下创建一个配置文件build.yaml

targets:
  $default:
    builders:
      orm_generator|orm:
        enabled: true

builders:
  orm_generator:
    target: ":orm_generator"
    import: "package:orm_generator/generators.dart"
    builder_factories: ["generateTable"]
    build_extensions: { ".dart": [".g.dart"] }
    auto_apply: dependents
    build_to: cache
    applies_builders: ["source_gen|combining_builder"]

5. 生成

example工程的配置文件中添加依赖:

dev_dependencies:
  build_runner:
  orm_generator:
    path: ../orm_generator/

在使用注解的地方,添加part of引用:

import 'package:orm/orm.dart';
part 'example.g.dart';

@Table(name: 'students')
class Student{
  String? _name;
  int? _age;
}

好了,到这来只需要在工程下执行命令即可生成代码:

# dart工程执行
pub run build_runner build

# flutter工程执行
flutter pub run build_runner build

如果重复生成,可能需要添加参数删除上一次生成的文件:

pub run build_runner build --delete-conflicting-outputs
# 或者
flutter pub run build_runner build --delete-conflicting-outputs

在同目录下生成了example.g.dart文件,打开文件查看,生成的代码与我们预期的一致。

工程结构:

高级代码生成

以上,我们使用了最简单的方式,字符串拼接来生成Dart源码。这种方式不仅简单直观,也可谓简陋,在复杂需求的情况下,极大的缺乏灵活性。实际上官方为我们提供了一个code_builder库专门用来生成源码,它是通过API的方式来生成源码。

orm_generator包中添加对该库的依赖:

dev_dependencies:
  build_runner:
  code_builder:

我们现在就使用code_builder库来改造上述示例:

import 'package:analyzer/dart/element/element.dart';
import 'package:build/src/builder/build_step.dart';
import 'package:orm/orm.dart';
import 'package:source_gen/source_gen.dart';
import 'package:code_builder/code_builder.dart';
import 'package:dart_style/dart_style.dart';
import '../generators.dart';

class TableGenerator extends GeneratorForAnnotation<Table>{

  @override
  generateForAnnotatedElement(Element element, ConstantReader annotation, BuildStep buildStep) {
    final visitor = MyVisitor();
    // 访问元素的所有子元素,没有特定的顺序
    element.visitChildren(visitor);

    // 读取注解参数
    var ann = annotation.peek('name');
    var tableName = ann != null ? ann.stringValue : visitor.className;

    var sb = StringBuffer();
    for (final field in visitor.fields.keys) {
      sb.write('\$$field,');
    }
    var parameter = sb.toString().substring(0,sb.length-1);

    // 创建一个类
    var clz = Class((b){
      b.name = '${visitor.className}Entity';

      // 生成类的成员变量
      Iterable<Field> fields = visitor.fields.keys.map((e) => Field((f) {
            f.name = e;
            f.type = refer(visitor.fields[e]);
          }));

      b.fields.addAll(fields);

      // 生成 setter 和 getter方法
      fields.forEach((e) {
        final variable = e.name.startsWith('_')? e.name.replaceFirst('_', ''): e.name;

        b.methods.add(Method((m){
          m.name = variable;
          m.lambda = false;
          m.type = MethodType.setter;
          m.requiredParameters.add(Parameter((p) {
            p.name = variable;
            p.type = e.type;
          }));
          m.body = Code("${e.name} = $variable;");
        }));

        b.methods.add(Method((m){
          m.name = variable;
          m.lambda = true;
          m.returns = e.type;
          m.type = MethodType.getter;
          m.body = Code(e.name);
        }));
      });

      // 生成 save 方法
      b.methods.add(Method((m){
        m.name = 'save';
        m.lambda = false;
        m.returns = refer('void');
        m.body = Code("print('execute sql:[insert into $tableName values ($parameter)]');");
      }));
    });

    final emitter = DartEmitter();
    // 对生成的代码格式化后再返回
    return DartFormatter().format(clz.accept(emitter).toString());
  }
}

运行后示例,可见与我们通过字符串拼接生成的源码相同。更多用法,请查看code_builderAPI文档

原理简述

首先看一下我们使用到的几个库:

  • source_gen:它是对另外两个库analyzerbuild的封装。这两个库更加底层,使用起来更麻烦,source_gen在这两者之上提供了更简单友好的API给我们使用。因此source_gen并不是必须的,我们也可以直接使用底层的接口来实现。
  • build_runner:为我们提供了命令行调用,即提供了pub run build_runner命令。它主要读取我们编写的build.yaml配置文件,从而运行指定的构建器。
  • code_builder:以API调用的方式生成Dart源码,避免直接的字符串拼接。

具体来说,source_gen主要为我们提供了两个抽象生成器:

  • Generator:完全控制生成器,继承该类,可访问代码所有元素,获得完全控制
  • GeneratorForAnnotation<T>:简单生成器,主要解析被注解的类或其他成员

以及其实现的三个构建器:

  • SharedPartBuilder:该构建会生成一个扩展名为:.g.dart文件,使用的地方需要用part of引用
  • PartBuilder:该构建可以生成自定义part文件,即任意的文件名,例如:students.dart
  • LibraryBuilder: 该构建可以生成一个单独的文件,即一个可以被导入的独立库。

整体流程如下图:

配置文件

我们在orm_generator包中添加了一份配置文件:build.yaml,该配置文件主要是指导build_runner库如何启动构建。关于这些配置项,需要进行一些详细说明。

根据官方文档描述,该配置文件有5大部分,我们先来看一下最重要的两个部分,也是我们上面例子配置的两个部分,分别是

  • targets:配置你要构建的目标。
  • builders:配置具体构建行为。

将包划分为构建目标

当对包中的文件子集构建时,包可以被分解成多个 目标。 目标是在build.yamltargets部分配置。 每个目标的键构成该目标的名称。 目标可以用 '$definingPackageName:$targetname' 引用。 当目标名称与包名称匹配时,也可以只称为包名称。 每个包中的一个目标必须使用包名称,以便运行时默认使用它。

build.yaml 文件中,这个目标可以用键$default 或包的名称来定义。

每个目标还可以包含以下键:

  • sources:字符串列表或Map,可选。包中构成该目标的一组文件。文件是用glob语法指定的。如果使用一个字符串列表,则它们被视为include glob。如果使用Map 只能有 includeexclude键。任何与 include 键中的 glob 匹配且在 exclude 中没有被排除的文件都被认为是目标的源。当 include被省略时,每个文件都被认为是匹配的。
  • dependencies:字符串列表,可选。这个目标所依赖的其他目标。格式为'$packageName:$targetName'的字符串用于依赖包中的目标,或者$packageName用于依赖包的默认目标。默认情况下,这是这个包所依赖的所有包名(来自pubspec.yaml)。
  • builders:Map,可选。
配置builders

每个目标可以指定一个builders键,用于配置该目标的构建器。此值是一个Map。键的格式是'$packageName:$builderName'。该配置可以有以下的键:

  • enabled: 布尔型,可选。是否将构建器应用于此目标。如果你想要基于构建器的默认行为,省略此键auto_apply 的配置。手动应用的构建器 (auto_apply: none) 仅在目标使用 enabled: True 指定构建器时才使用
  • generate_for: 字符串列表或 Map,可选。 可以决定针对哪些文件或文件夹做扫描,或者排除哪些文件。极大的提供扫描的效率。
  • options: Map类型,可选。可以允许你以键值对形式携带一些配置数据到代码生成器中,对应的是BuildOption参数,如上例的generateTable函数参数
  • dev_options:同options,当构建在开发模式下使用此键覆盖。
  • release_options: 同options,当构建在发布模式下使用此键覆盖。

这其中最重要的是generate_for配置,正确使用它能大大加速代码生成:

targets:
  $default:
    builders:
      freezed:freezed:
        generate_for:
          include:
            - lib/**.codegen.dart

以上指定只扫描带.codegen.dart后缀名的文件,也可以配置只扫描某个文件夹。当然,我们还可以指定排除规则,不扫描哪些文件:

targets:
  $default:
    builders:
      freezed:freezed:
        generate_for:
          exclude: ['**.g.dart','**.c.dart']

另需注意,我们可以配置全局global_options去覆盖目标级别构建器的options。这些选项在所有构建器默认值和目标级配置之后,在--define 命令行参之前应用。

配置构建器

构建器 在 build.yamlbuilders部分进行配置。这是一个构建器名称到配置的Map。每个构建器的配置可以包含以下键:

  • import
  • builder_factories
  • build_extensions
  • auto_apply
  • required_inputs
  • runs_before
  • applies_builders
  • build_to
  • is_optional:可选的布尔值。指定 Builder 是否可以延迟运行。它的一个输出被后面的Builder请求时才执行。该选项很少见。默认值为False
  • defaults: 可选的。当用户未在其 builders 部分指定相应键时的默认值。可包含以下键:
    • generate_for
    • options
    • dev_options
    • release_options

重要的配置说明:

配置示例:

builders:
  my_builder:
    import: "package:my_package/builder.dart"
    builder_factories: ["myBuilder"]
    build_extensions: {".dart": [".my_package.dart"]}
    auto_apply: dependents
    defaults:
      release_options:
        some_key: "Some value the users will want in release mode"

用法进阶

在上面的示例中,我们一直使用的是顶级注解,即注解添加到类上。有时候我们希望给类中的方法添加注解,这种情况下简单生成器GeneratorForAnnotation是无法处理的,它只能识别顶级注解,也就是类上、全局函数等元素上的注解。解决这个问题,有两种思路:

  • 先给类上添加一个顶级注解,然后再到类中方法上加注解。这样就能使用GeneratorForAnnotation先识别到被注解的类,然后遍历类中的所有成员,定位到被添加了注解的方法成员。
  • Generator派生自定义的生成器

以上第一种思路显然不够灵活,如果只想注解方法,还得先给类加上一个注解,多此一举。现在我们来看一下如何自定义生成器,实现完全控制:

abstract class GeneratorForAnnotatedMethod<AnnotationType> extends Generator {
  DartObject? getAnnotation(Element element) {

    final annotations =
        TypeChecker.fromRuntime(AnnotationType).annotationsOf(element);
    if (annotations.isEmpty) {
      return null;
    }
    if (annotations.length > 1) {
      throw Exception(
          "You tried to add multiple @$AnnotationType() annotations to the "
          "same element (${element.name}), but that's not possible.");
    }
    return annotations.single;
  }

  @override
  String generate(LibraryReader library, BuildStep buildStep) {
    final values = <String>{};
    for (final element in library.allElements) {
      if (element is ClassElement && !element.isEnum) {
        for (final method in element.methods) {
          final annotation = getAnnotation(method);
          if (annotation != null) {
            values.add(generateForAnnotatedMethod(method, ConstantReader(annotation),
            ));
          }
        }
      }
    }

    return values.join('\n');
  }

  String generateForAnnotatedMethod(MethodElement method, ConstantReader annotation);
}

以上先实现了一个能解析方法注解的生成器,主要是重写generate方法,然后遍历所有的类元素,再遍历类中的成员方法,检查每个方法是否带有我们期望的注解。看一下使用示例:

class EventGenerator extends GeneratorForAnnotatedMethod<Event>{

  @override
  String generateForAnnotatedMethod(MethodElement method, ConstantReader annotation) {
    var className = annotation.read('name').stringValue;
    return 'class $className{}';
  }
}

使用方法注解:

class Test{

  @Event('UpEvent')
  void func1(){}

  @Event('DownEvent')
  void func2(){}
}

附:Build 配置文件

官方提供了一个Build配置文件的文档,如下:

build.yaml 文件由 BuildConfig 对象描述。

这是一份关于build.yaml格式的技术性文件,如果你想看例子或目标导向的文档,你可能想看看 build_config/README.md

BuildConfig

keyvaluedefault
targetsMap<String, BuildTarget>与包同名的单个目标
buildersMap<String, BuilderDefinition>empty
post_process_buildersMap<String, PostProcessBuilderDefinition>empty
global_optionsMap<String, GlobalBuilderOptions>empty
additional_public_assetsListempty

BuildTarget

keyvaluedefault
auto_apply_buildersbooltrue
buildersMap<BuilderKey, TargetBuilderConfig>empty
dependenciesList<TargetKey>pubspec 中所有依赖项的所有默认目标
sourcesInputSet所有默认 sources

BuilderDefinition

keyvaluedefault
builder_factoriesListnone
importStringnone
build_extensionsMap<String, List>none
auto_applyAutoApplyAutoApply.none
required_inputsListnone
runs_beforeList<BuilderKey>none
applies_buildersList<BuilderKey>none
is_optionalboolfalse
build_toBuildToBuildTo.cache
defaultsTargetBuilderConfigDefaultsnone

PostProcessBuilderDefinition

keyvaluedefault
builder_factoryStringnone
importStringnone
input_extensionsListnone
defaultsTargetBuilderConfigDefaultsnone

InputSet

注意,除了以下结构,你还可以提供一个List<String>,这将被推断为下面的 include 键。

keyvaluedefault
includeList**
excludeListnone

TargetBuilderConfig

keyvaluedefault
enabledbooltrue
generate_forInputSet**
optionsBuilderOptionsnone
dev_optionsBuilderOptionsnone
release_optionsBuilderOptionsnone

TargetBuilderConfigDefaults

keyvaluedefault
generate_forInputSet**
optionsBuilderOptionsnone
dev_optionsBuilderOptionsnone
release_optionsBuilderOptionsnone

GlobalBuilderOptions

keyvaluedefault
optionsBuilderOptionsnone
dev_optionsBuilderOptionsnone
release_optionsBuilderOptionsnone

BuilderOptions

一个任意的Map<String, dynamic>的配置选项,由各个构建器公开。请参阅你正在配置的构建器的文档以获得指导。

AutoApply

valuemeaning
none默认情况下不适用于任何包,必须明确
: : enabled.:
dependents适用于所有直接依赖于此的包
:: package.:
all_packages适用于图表中的所有包。
root_package仅适用于root(应用程序)包。

BuildTo

valuemeaning
cache将所有文件写入缓存目录
source将所有文件直接写入源目录

TargetKey

一个target标识符。一个目标键有两个部分,一个是package,一个是name

为了构建一个键,用:连接包和名称,例如,在foo包中的 bar 目标将被这样引用foo:bar

有一个特殊的别名,那就是$default目标。这指的是当前包中的默认目标(与包的名称相同)。

BuilderKey

一个 builder的标识符。一个构建器有两个部分,一个是package,一个是name

为了构建一个键,用 |连接包和名字,例如,在foo包中的bar 构建器将被这样引用 foo|bar


关注公众号:编程之路从0到1

编程之路从0到1

或关注博主的视频课程

云课堂

评论

公众号:编程之路从0到1

公众号

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×