Serverless开源项目分享-Spring-Cloud-Function
2017-08-19 / modified at 2022-04-04 / 2.3k words / 9 mins
️This article has been over 2 years since the last update.

Spring Cloud Function实现了类似于AWS Lambda的云函数调用,属于Serverless架构。它是基于SpringBoot开发的FAAS项目,目前Star只有100多,可以说是玩具级别。但是功能还是很全的。它基于Reactor进行设计,类似于Akka中的Actor或者RxJava中的Lift管道操作符

The Serverless Architecture

Deploy your applications as independent functions, that respond to events, charge you only when they run, and scale automatically.

阅读本文需要如下知识点

  • 了解基本SpringBoot的注解,可以参考《SpringBoot揭秘》
  • 掌握注解扫描/处理器与动态代理技术(又是如何写一个Parser)
  • 熟练使用Stream与FluxAPI,否则连Demo都看不懂

本文要做的事

  • 本文不分析前台JAX-RS的路由实现,它只是一个由Map与Regex构造的模式匹配
  • 本文主要分析Function的注册、编译与执行流程,不分析supplier/consumer

函数式编程

无状态函数是指无副作用的函数,也就是函数式编程中所谓的“纯函数”,比如map,filter,它们在LISP语言中叫做Lambda表达式。因为不涉及共享变量,所以总是线程安全的。

  • Spring Cloud Function: Spring推出的微服务框架,可以动态注册Java8函数
  • AWS Lambda: 云函数调用属于Serverless架构
  • Reactor编程范式: 与无状态函数配合有两种形式,一种是CallBack,比如RxJava,Flux, 还有一种是模式匹配 Scala, Clojure, Akka, Erlang
  • select: Select系统调用本质也是无副作用,给下游可读或者可写的fd,再通过函数指针进行调用

优点: 容易开发与热部署,测试用例也好实现。
缺点: 代码不容易理解,很难调试,(分布式情况下)全局函数可能命名重复而互相覆盖,目前只在Erlang等电信领域中比较多,纯Java的不成熟。

什么是Lambda函数

Lambda函数最开始来自于Lisp,是一种匿名函数

1
2
3
4
5
;; 定义一个f(x)=7*x
(lambda (number) (* 7 number))
;; 调用Lambda函数
((lambda (number) (* 7 number) 3)
=> 21

在Groovy/Ruby中也有类似的设计,比如Groovy中代码中{}就是lambda的语法糖

1
2
3
4
5
// 定义一个f(x)=7*x
def closure = {number-> 7 * number}
// 调用Lambda函数
{number-> 7 * number} 3
=> 21

其实上面有一个潜规则,Lambda函数应该尽量避免与外界接触,最好是“无副作用”的纯函数,以免引入全局变量使架构变复杂。

目前的AWS,阿里云等云服务商,通过提供一个云函数的运行环境(比如OpenFAAS),用户提供函数代码,按执行时间收费。这就是所谓的Serverless架构,或者称作FAAS架构(FunctionAsAService),比如CI构建编译等低频高性能要求的业务就适合这种架构。

当然,尽管网上说的神乎其神,不过在技术实现上我也觉得FAAS和JMX、在线OJ并没有本质区别

OOP编程与FP编程范式

它们俩个都属于编程范式,但是稍微有点不同,同时这两种也不是对立的。参考如下

最主要的区别:对函数的理解不同。比如在Java中用接口来模拟Lambda函数,而不是直接作为入参

拉通主流程

测试如下Shell

1
2
3
4
5
6
7
8
9
10
11
12
chmod +x ./script/*.sh
cd ./script
# 前端编译器,端口为8080
./function-registry.sh
# 用于生成文件到/tmp/function-registry
.registerFunction.sh -n uppercaseStr -f "s->s.toString().toUpperCase()" -i 'String' -o 'String'
# 函数Proxy,端口为9000
# 启动时讲通过FunctionProxyApplicationListener扫描spring.cloud.function.import
# 并读取 /tmp/function-registry 下的字节码
./web.sh -f uppercaseStr -p 9000
# 客户端调用
curl -H "Content-Type: text/plain" -H "Accept: text/plain" localhost:9000/uppercaseStr -d foo

最终返回了FOO,说明就成功了

从上面也可以看出,现在的架构太复杂了,要专门开一个编译前端

断点分析

现在开始考验你的代码阅读量了,一般是按照“日志”,“断点”,“JMX”,“源码”进行分析的。

编译流程分析

此部分主要是

  • 对文本脚本进行组装为java源文件
  • 并通过sun闭源工具(com.sun.tools.javac.api.JavacTool)生成class

首先在下面打上断点

1
org.springframework.cloud.function.compiler.app.CompilerController#registerFunction

通过分析CompilerController的REST路由,可以发现此部分是一个纯生成文件的模块

1
2
3
4
5
6
7
JAX-RS(请求)
|
CompilerController
|
调用 ToolProvider.getSystemJavaCompiler() 进行 javac 源码
|
存储Class文件到/tmp/function-registry

此部门代码主要难点在于源码的拼装与检查,而编译是通过sun闭源的JavacTool中的javac实现(本文不分析)

我们甚至可以把在/tmp目录下的文件给拷贝出来

1
cp /tmp/function-registry/functions/uppercase.fun ./uppercase.class

用IDEA打开反编译的结果是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package org.springframework.cloud.function.compiler;

import java.io.Serializable;
import java.util.function.Function;
import org.springframework.cloud.function.compiler.FunctionFactory;

public class UppercaseFunctionFactory implements FunctionFactory {
public UppercaseFunctionFactory() {
}

public Function<String, String> getResult() {
return (Function)((Serializable)((var0) -> {
return var0.toString().toUpperCase();
}));
}
}

可以发现,通过registerFunction.sh发送请求后,在CompilerApplication平台在内部生成了Class文件,并保存为文件到/tmp/function-registry目录下。

函数代理侧实现

代理侧就是./web.sh中扫描/tmp/function-registry并注册为JAX-RS服务的过程

  • 定制了FunctionProxyApplicationListener: 监听了ApplicationPreparedEvent事件,当【上下文启动完成而没有刷新】时,触发此事件,并invoke其中的onApplicationEvent方法
  • 通过PropertySourcesBinder实现对env中的K-V进行反序列化,并获取spring.cloud.function.compilespring.cloud.function.import的value
  • 通过registerByteCodeLoadingProxy构造单例Bean,key为function的名词,value为FunctionProxy的实现类

由于web.sh涉及到很多变量,如何打上断点呢,可以在web.sh中加入

1
-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005

接着配置IDE的remote,点击debug即可

由于现在不清楚JAX-RS的路由,无法快速上手,有个技巧就是在String中打上断点

1
2
3
public String toUpperCase() {
+ return toUpperCase(Locale.getDefault());
}

通过断点与curl,可以发现此框架是通过类似RxJava的框架折腾出来的,断点很难打,接着通过分析stacktrace,可以定位出通过url路由到function的方法为

1
org.springframework.cloud.function.web.flux.FunctionHandlerMapping#findFunctionForPost

其中url与Function的映射处理为

1
2
3
4
5
6
7
public <T, R> Function<T, R> lookupFunction(String name) {
return (Function<T, R>) Stream.of(StringUtils.tokenizeToStringArray(name, ","))
// 从HashMap获取缓存
.map(functions::get)
.filter(f -> f != null)
.reduce(null, (f1, f2) -> f1 == null ? f2 : f1.andThen((Function)f2));
}

并最终返回FluxFunction<ByteCodeLoadingFunction>,这里的FluxFunction类似于Akka的Actor,而内部的ByteCodeLoadingFunction是实现类,实现将同步函数转换为Flux响应式调用(断点难度也直线上升)

接着,通过一系列的onNext最终调用到toUpperCase

总的来说,目前基本上就是玩具,暂时不建议花时间分析这个项目。

自己实现FAAS

总的来说,这次分析主流程并不难,因为我在以前的JMX文章中写过几行代码就能搞定动态执行JS等脚本的方法

下面是一个基于jsr223精简版FAAS实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class FAAS{
Closure eval = {script ->
//jsr223,此处内置了定制GroovyClassLoader
ScriptEngine engine = new ScriptEngineManager().getEngineByName("groovy");
Object eval = null
try {
eval = engine.eval(script)
}catch (Exception e){
e.printStackTrace();
}
}.memoize()/*记忆化执行优化*/
// 可以用etcd/zk/redis封装代替
def map = [:]
void reg(name,func){
map.put(name,func)
}
Object run(funcName, Object... args){
def func = map.get(funcName)
def toRun = func + ' ' + args?.join(',')
println "toRun = $toRun"
eval toRun
}
}
FAAS faas = new FAAS()
faas.reg('sum','{a,b->a+b}')
assert faas.run('sum','1','2') == 3
faas.reg('uppercase','{s->s.toUpperCase()}')
assert faas.run('uppercase','"Hell"') == 'HELL'

在这个基础上加入API网关、缓存优化、JIT优化、热部署等,可以比SpringCloud更快更好。

当然这个实现的并不安全,比如

1
2
3
// 缺乏管理权限导致被rm
faas.reg('attack','{a-> a.execute().text}')
faas.run('attack','"rm -rf ~"')

就是代码任意执行漏洞,把整个机器给黑掉了

总结

通过对开源框架SpringCloudFunction进行分析,可以发现它目前只属于玩具级别

  • 语言上只支持纯Java8,还要精通Stream/Flux层层包装操作,无法大范围推广
  • 此框架总体上就是重构了一个ScriptEngine,最底层甚至还是用文件进行共享,还不如二次封装ScriptEngine执行JVM语言(js/jruby/groovy/kotin)来的痛快
  • SpringCloudFunction不支持在方法中调用其它已注册的Function,无法复用代码,这点绝对是硬伤
  • 引入了一堆学习成本较高的注解/接口来模拟【闭包】,这个是Java本身的缺点。动态代理+注解的形式,比不上动态语言的methodMissing
  • 我认为更好的设计是使用JVM动态语言,后期我会以Groovy为例详细说下GroovyClassLoader
  • 无论是Spring还是Groovy,都没解决一个重要问题: 无断点调试的IDE提供支撑

总的来说,开源的FAAS尚不成熟,需要后期跟进。如果Spring项目更新了,本文后期也会更新。