Elide 是一个主要基于 JPA 注释自动构建 JSON API / GraphQL 接口的中间件。只需要给实体对象新增一个注解即可获得 CRUD 的 API,同时也提供了接口让你整合业务逻辑,权限控制,swagger 文档…

类比的话,或许有点儿类似于 Spring Data REST,不过它做得更多,生成的接口也更丰富…

Note

本文基于 elide-spring-boot-starter 1.4.0 & Spring Boot 2,请注意有一些功能并不是 Elide 所直接提供的,关于该 starter 的更多信息于 Github 主页查看。

快速开始

引入 starter

引入 starter 即可完成自动配置

<dependency>
  <groupId>org.illyasviel.elide</groupId>
  <artifactId>elide-spring-boot-starter</artifactId>
  <version>1.4.0</version>
</dependency>

如果你需要 GraphQL 的接口,你需要额外引入

<dependency>
  <groupId>com.yahoo.elide</groupId>
  <artifactId>elide-graphql</artifactId>
  <version>${elide.version}</version> <!-- 目前 starter 指定版本为 4.2.3 -->
</dependency>

将实体类暴露为接口

@Setter
@NoArgsConstructor
@Table(name = "users")
@Entity
@Include(rootLevel = true)  // <---- 在你实体类上添加一行注解
public class User {

  private Integer id;
  private String username;
  private String password;

  @Id
  @GeneratedValue
  public Integer getId() { return id; }

  public String getUsername() { return username; }

  public String getPassword() { return password; }
}

OK,完成了 😂。你已经拥有了 CRUD 的 API 接口了,现在来试试吧。

Warning

请注意你的 JPA 及下文所述的注解最好放在 get 方法上,Elide 目前没有完全支持位于 field 上的注解。

C 创建

使用 JSON API 创建 user

var data = {
  "data": {
    "type": "user",
    "attributes": {
      "username": "test",
      "password": "test"
    }
  }
};
fetch('http://localhost:8080/api/user', {
  method: 'POST',
  headers: {
    'Accept': 'application/vnd.api+json',
    'Content-Type': 'application/vnd.api+json',
  },
  body: JSON.stringify(data),
})
.then(response => response.ok && response.json())
.then(json => console.log(json));

使用 GraphQL 创建 user

var payload = {
  "query": "mutation createUser($name: String, $pw: String) { user(op: UPSERT, data: { username: $name, password: $pw }) { edges { node { id username } } } }",
  "variables": {
    "name": "alice",
    "pw": "123",
  }
};
fetch('http://localhost:8080/api/graphql', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload),
})
.then(response => response.ok && response.json())
.then(json => console.log(json));

R 查询

使用 JSON API 分页 & 过滤 & 指定属性 & 排序查询 user

fetch(encodeURI("http://localhost:8080/api/user?filter[user]=username=='test'&fields[user]=id,username&sort=-id&page[number]=1&page[size]=3&page[totals]"), {
  method: 'GET',
  headers: {
    'Accept': 'application/vnd.api+json',
  },
})
.then(response => response.ok && response.json())
.then(json => console.log(json));

使用 GraphQL 查询 user

var query = {
  "query": "query { user { edges { node { id username } } } }"
};
fetch('http://localhost:8080/api/graphql', {
  method: 'POST',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(query),
})
.then(response => response.ok && response.json())
.then(json => console.log(json));

U 更新

var data = {
  "data": {
    "id": "1",
    "type": "user",
    "attributes": {
      "username": "new name"
    }
  }
};
fetch('http://localhost:8080/api/user/1', {
  method: 'PATCH',
  headers: {
    'Accept': 'application/vnd.api+json',
    'Content-Type': 'application/vnd.api+json',
  },
  body: JSON.stringify(data),
})
.then(response => console.log(response.status === 204 ? 'ok' : 'failure'));

D 删除

fetch('http://localhost:8080/api/user/1', {
  method: 'DELETE',
  headers: {
    'Accept': 'application/vnd.api+json',
  },
})
.then(response => console.log(response.status === 204 ? 'ok' : 'failure'));

将实体暴露为接口

将实体暴露为接口只需要为其添加 @Include@Include(rootLevel=true) 注解即可,暴露的路径默认为实体名称的首字母小写形式,即 User -> user,不指定 rootLevel 的实体无法从根路径直接访问。

@Include(rootLevel=true)
@Entity
public class Author {
  private Set<Book> books;
  ...
  @ManyToMany
  public Set<Book> getBooks() {
      return books;
  }
  ...
}

@Include
@Entity
public class Book {
  private Set<Author> authors;
  ...
  @ManyToMany
  public Set<Author> getAuthors() {
      return authors;
  }
  ...
}

例如上述代码,你无法直接访问 Book,即 /api/book 不是一个有效的路径,必须通过其他暴露于根路径的关系抵达,如 /api/author/:authorId/books

@Include 注解的实体类将暴露其所有的属性,除非你使用 @Transient@Exclude 标记属性。被 @Transient 标记的属性会同时被 Elide 于 JPA 忽略,而被 @Exclude 标记的属性只会被 Elide 忽略。

非持久化/可计算属性

通过 @ComputedAttribute@ComputedRelationship 注解你可以暴露一个不存在于 JPA 中的属性。该属性与其他属性并没有什么不同,只是会因为 @Transient 而被 JPA 忽略。

@Include
@Entity
public class Book {
  ...
  @Transient
  @ComputedAttribute
  public String getMyComputedAttribute(RequestScope requestScope) {
      return "My special string stored only in the JVM!";
  }
  ...
}

RequestScope 是可选参数,该属性最后形成 myComputedAttribute 这样的字段。

Hooks

Hooks 给予你在 CRUD 事件中触发其它业务逻辑或是对该操作对应的实体类进行修改的机会,目前支持三种不同的时期:

  1. Pre Security - 权限检查前
  2. Pre Commit - 事务提交前,有可能已经 flush 了
  3. Post Commit - 事务提交后
  4. 没有 Post Security

各个时期所对应 CRUD 注解:

  • @OnCreatePreSecurity
  • @OnCreatePreCommit
  • @OnCreatePostCommit
  • @OnDeletePreSecurity
  • @OnDeletePreCommit
  • @OnDeletePostCommit
  • @OnUpdatePreSecurity(value)
  • @OnUpdatePreCommit(value)
  • @OnUpdatePostCommit(value)
  • @OnReadPreSecurity(value)
  • @OnReadPreCommit(value)
  • @OnReadPostCommit(value)

value 为可选值,若指定 value,则仅当指定属性的指定操作发生时触发;若 value 为空,则注解针对实体,即只要该实体的某个属性触发了指定操作就会触发。

位于实体类中的 Hooks

@Entity
class Book {
  @Column
  public String title;

  @OnReadPreSecurity("title")
  public void onReadTitle() {
    // 查询 title 属性时触发,GraphQL 必须显示指定,而 JSON API 可以显示指定
    // 权限检查前触发
  }

  @OnUpdatePreSecurity("title")
  public void onUpdateTitle() {
    // 更新 title 属性时
    // 权限检查前触发
  }

  @OnUpdatePostCommit("title")
  public void onCommitTitle() {
    // 更新 title 属性时
    // 事务提交后
  }

  @OnCreatePostCommit
  public void onCommitBook() {
    // 创建实体时触发
    // 事务提交后触发
  }

  /**
   * 所有的触发器函数都可以可选的接受 `RequestScope` 参数
   * 该参数可用于访问从 Spring MVC Controller 的参数中获取到的 Principle 对象
   */
  @OnDeletePreCommit
  public void onDeleteBook(RequestScope scope) {
    // 删除实体时触发
    // 事务提交前触发
  }
}

更新操作的触发器函数还可接收一个 ChangeSpec 参数,该参数包含了指定属性修改前和修改后的值。但请注意,目前来说,若没有指定属性时,即 @OnUpdatePreSecurity 只要是更新操作就会触发,此时 ChangeSpec 是没有有效值的。

@OnUpdatePreSecurity("title")
public void onUpdateTitle(RequestScope scope, ChangeSpec changeSpec) {
  // 当 title 被修改时(PATCH 请求包含 title 属性且从数据库读取的值和 PATCH 中给定的值不一致时)触发
  // 权限检查前触发
  // 修改后 changeSpec.getModified,修改前 changeSpec.getOriginal
}

Tip

在开启 Spring 的依赖注入后(由 starter 提供,默认开启),你可以在实体类中使用 @Autowired@Inject 等注解注入 Bean 以供触发器函数使用…个人建议你关掉它,用下面提到的 Function Hooks。

Function Hooks

如果你不想污染你的实体类,可以实现 LifeCycleHook<Entity> 接口将逻辑与实体类分离。这里使用了 starter 提供 @ElideHook 注解自动绑定 Function Hook,该注解的 fieldOrMethodName 为可选值,与上述的 value 值等价。

@ElideHook(lifeCycle = OnUpdatePreCommit.class, fieldOrMethodName = "password")
public class UserOnUpdatePreCommit implements LifeCycleHook<User> {

  private final PasswordEncoder passwordEncoder;

  @Autowired
  public UserOnUpdatePreCommit(PasswordEncoder passwordEncoder) {
    this.passwordEncoder = passwordEncoder;
  }

  @Override
  public void execute(User user, RequestScope requestScope, Optional<ChangeSpec> changes) {
    // encode password
  }
}

依赖注入

由 starter 提供的功能,每次 Elide 实例化一个实体时 Spring 都会尝试自动注入 Bean。如前文所言,个人建议关掉这个默认开启的功能…

# application.yml
elide:
  # 关闭 Spring 依赖注入
  spring-dependency-injection: false
  # 顺带一提,建议开启这个默认关闭的配置,使返回的错误对象符合 JSON API 及 GraphQL 规范
  return-error-objects: true 

验证(JSR303)

将注解应用在 get 方法上即可,目前来说实际上是由 Hibernate 提供的功能,所以你在 On..PreSecurity 中访问到的实际上是未经验证的值,可以注入 Validators 手动调用验证。

@NotNull(message = "密码不应该为空")
public String getPassword() { return password; }

权限控制

Permission Annotations

权限控制由 5 个注解实现,ReadPermission, UpdatePermission, CreatePermission, DeletePermission, SharePermission。除 SharePermission 外都接受一个字符串形式的表达式,该字符串包含若干个 Check,可由 AND, OR, NOT, 小括号 以及 Check 组成,只有当该表达式组成的 Check 返回 True 时指定操作才可执行。

权限注解会向上进行寻找直到发现或使用默认值,遵循下述顺序,即优先级由高到底(可覆盖):

  1. get 方法上的注解
  2. 类注解
  3. 该包下 package-info 上的注解
  4. 默认值

默认情况下,实体类拥有除 SharePermission 的所有权限。

ReadPermission

标识字段或实体是否可读取,该权限会分层进行评估,例如 GET /users/1/posts/3/comments/99 这个请求分为三个步骤:

  1. User<1>#posts 是否可读。
  2. Post<3>#comments 是否可读。
  3. Comment<99> 是否可读,若可读,还将逐个检查声明要读取的字段。

UpdatePermission

标识实体或字段是否可更新,除更新操作外,创建或更改一个双向关系时,对侧的 UpdatePermission 也需要进行评估。

CreatePermission

标识实体或字段是否可在 POST 请求中初始化数据。

DeletePermission

标识实体是否可删除

Question

JSON API 支持只删除关联而不删除实体,那么该注解是否于关系字段仍然有效?可以自行尝试一下~

SharePermission

这是唯一默认不拥有的权限,标记一个已存在(不是该请求新建的)的实体是否可以被其它实体关联。例如,新建(POST)一个 Phone 对象,并指定其所有者(owner 为已经存在的实体),如果该 owner 实体不拥有 SharePermission 则视为没有权限这样做。

User Checks

User Check 继承自 com.yahoo.elide.security.checks.UserCheck,返回的 boolean 值标识当前用户是否拥有进行指定操作的权限。

public class UserCheck {

  public static final String REJECT_ALL = "reject all";

  @ElideCheck(REJECT_ALL)
  public static class RejectAll extends com.yahoo.elide.security.checks.UserCheck {

    @Override
    public boolean ok(User user) {
      return false;
    }
  }
}

与 Function hooks 一样,为了免去手动绑定这个繁琐的任务,使用由 starter 提供的 @ElideCheck 注解自动绑定 Check,该注解接收一个全局唯一的字符串用于标识该 Check,后续可在相应的实体类或字段上这样使用:

@NotNull
@ReadPermission(expression = REJECT_ALL)
public String getPassword() { return password; }

现在,任何人都无法读取 password 这个字段了。

Tip

Elide 提供了多个预置的 Check,上述的 @ReadPermission(expression = REJECT_ALL) 等价于 @ReadPermission(expression = "Prefab.Role.None"),相应的 "Prefab.Role.All" 总是返回 true

Operation Checks

User Check 继承自 OperationCheck<Entity>,与 User Checks 不同的是,Operation Checks 可以需要读取实体的数据,需要注意的是这个过程是在内存中进行的。

public class UserPropertyCheck {

  public static final String EMAIL_PUBLIC = "email.public";

  @ElideCheck(EMAIL_PUBLIC)
  public static class EmailCheck extends OperationCheck<User> {

    @Override
    public boolean ok(User user, RequestScope requestScope, Optional<ChangeSpec> changeSpec) {
      return user.getEmailPublic();
    }
  }
}

Warning

截至目前,如果你将 Operation Checks 置于类级别上,此时你将在内存中过滤实体,此时该实体将不再支持分页,你可以考虑使用下面的 Filter Expression Checks 或者把 Checks 分散于该实体的属性上。

Filter Expression Checks

Filter Expression Checks 继承自 FilterExpressionCheck<Entity>,该类型的 Check 将直接应用到 where 子句中。

public class TopicFilterCheck {

  public static final String NON_DELETE = "topic.delete.false.filter";

  @ElideCheck(NON_DELETE)
  public static class TopicNotDeleteFilter extends FilterExpressionCheck<Topic> {

    @Override
    public FilterExpression getFilterExpression(Class<?> entityClass, RequestScope requestScope) {

      // 类似 `topic.delete in (false)` 这样的子句会被添加到 where 条件中
      return new FilterPredicate(
          new PathElement(Topic.class, Boolean.class, "delete"),
          Operator.IN,
          Collections.singletonList(false));
    }
  }
}

同时使用多个 Check

@ReadPermission(expression = PermissionCheck.TOPIC_DELETE_PERMISSION
    + " OR (" + TopicFilterCheck.NON_DELETE
    + " OR " + PermissionCheck.TOPIC_DELETE_OWN_PERMISSION
    + " AND " + TopicFilterCheck.DELETE_BY_SELF
    + ")")

JSON API

可从 Elide JSON APIJSON API Spec测试用例查看更多信息。

GraphQL

可从 Elide GraphQLGraphQL测试用例查看更多信息。

Swagger

Elide 提供了自动生成文档的工具,除了 swagger 相应的依赖(swagger 自身或是 springfox 之类的),只需要额外添加

<dependency>
  <groupId>com.yahoo.elide</groupId>
  <artifactId>elide-swagger</artifactId>
  <version>${elide.version}</version>
</dependency>

下述代码即可获得 json 格式的 swagger 文档,更多关于 swagger 的信息可以查看文档,怎么使用 swagger 也不多说了~~

@Autowired
private Elide elide;

public String getElideDoc() {
  Info info = getInfo();
  Swagger swagger = new SwaggerBuilder(elide.getElideSettings().getDictionary(), info).build();
  String json = SwaggerBuilder.getDocument(swagger);
  return json;
}

写在后头

  • 虽然号称基于 JPA,实际上严重依赖于 Hibernate,想换 EclipseLink 是不可能的。╮(╯▽╰)╭
  • 所有的查询操作都是使用 HQL 生成的不带关系的查询,即使是 ToOne 也要额外查询,务必需要配置好缓存。
  • OnUpdatePreSecurity 阶段无法重新设置被修改的属性,但是修改其它未被修改的属性还是没问题的。
  • 他们家的文档挺粗糙的,关于 JSON API 和 GraphQL 的语法可以去翻阅测试用例。
  • 他们家的文档挺粗糙的,可以试试搜索 issue。
  • 用这个造了一个玩具,唔,可以于 check, hook, domain 查看更多例子。

Reference & Next