前言
最近被内存优化折磨得翻来覆去的,可怕的 springboot 服务启动即占 300M 简直是一场噩梦,当服务数量一多,整个 K8S 体系对内存的消耗就相当的恐怖了。在这个前提下,我们开始寻找对内存要求较低的框架,也进行了大量的测试,这里有一部分空载运行的数据:
框架 | 最小内存 |
---|---|
springboot | 87.9M |
ktor | 55.2M |
webflux | 87.5M |
golang | 1.5M |
看起来用 go 来开发 web 服务确实有优势啊,不过作为我本人来说,我非常不喜欢这门语言,是否有其他语言的框架可以使用呢?并且 1.5M 的空载占用,是极限吗?带着这两个问题,经历了一番寻找,最终,我觉得我似乎进入了一个全新的世界。
FPWEB
你可以简单粗暴的将其理解为 fpc 语言下的一个 web 框架,而确实它也就是干这个的,你可以很轻松的用几行代码跑起一个基本的服务程序:
program server;
uses fphttpapp;
begin
Application.Port:= 8080;
Application.Initialize;
Application.Run;
end.
非常出乎意料的是,这个框架的空载内存消耗只有 260K。
如果只看内存,它赢了,但是显然我们不可能只测试这个,还是要看看这东西是否经得起考验。
正好我手里有一台配置极低的阿里云服务器,1核1G,上面跑了个 MySQL 占用 350M 左右内存,扔个逻辑稍复杂的 Java 服务就会经常发生 OOM Kill
,感觉拿来试新框架还是非常好的呢。
初步上手
上手时有几个关键点,必须非常注意,否则会对整体程序造成巨大影响。另外,由于框架是底层框架,它并不具备 springboot 那种开箱即用的理念,对于每一片内存的使用和释放,都需要自己手动管理,在开发层面上,会带来不小的麻烦,特别写惯了 Java 习惯了 GC 的同学,如果不注意释放内存的话,你就会发现整个服务占用的内存依然在蹭蹭的往上涨。
以下参数在服务运行前必须被设置:
参数 | 含义 | 推荐值 |
---|---|---|
QueueSize | 最大请求队列值 | 1000 |
Port | 服务监听的端口 | |
Threaded | 是否以多线程监听和处理请求 | True |
需要注意的是 Threaded
参数,正常情况均必须设置为 True,该选项生效的情况下,接收到的请求均会新开线程处理,而万一某个线程出现异常,不会影响整个服务的运作。而若是该选项设为 False,则所有的请求会排队处理,前一个请求出现异常并未能正确处理时,将 block 掉后续所有的请求处理。
另外,对于包含 HTML、js、css 或是其他资源的服务,必须注册文件路径,以便服务程序能正确的找到对应的资源,如下所示:
RegisterFileLocation('static', filePath);
这句代码即表示,在页面中出现的 static
路径,其真实文件均会从 filePath
中去寻找,如页面中编写的代码为:
<script type="text/javascript" src="static/index.js"></script>
那么实际 serve 文件时,就会找到 <filePath>/index.js
。
编写路由
在 FPWEB 内要编写一个路径是十分简单的,如下即可:
HTTPRouter.RegisterRoute('/', rmAll, @index);
其中 rmAll
表示这个请求可以任意协议的,可以是 GET, POST 或是别的。如果只希望接受 GET/POST 请求,可以写为 rmGet
或 rmPost
。
后面接着的 @index
表示响应处理这个路径的真实函数,该函数实现如下:
procedure index(req: TRequest, resp: TResponse);
begin
resp.Code := 200;
resp.Contents.LoadFromFile(filePath + '/index.html');
end;
FPWEB 支持 Router 参数和 Query 参数,如以下 URL:
http://localhost:8080/api/sample/1234?lang=cn
我们可以方便的知道,1234
属于 Router 参数,而 lang=cn
则是 Query 参数,在 FPWEB 里可以这样写:
HTTPRouter.RegisterRoute('/api/sample/:id', rmGet, @sample);
procedure sample(req: TRequest; resp: TResponse);
var
id: string;
lang: string;
begin
id := req.RouteParams['id'];
lang := req.QueryFields.Values['lang'];
end;
对于 POST 请求,传入 json 作为请求体的,也可以支持,如下:
HTTPRouter.RegisterRoute('/api/sample2', rmPost, @sample2);
procedure sample2(req: TRequest; resp: TResponse);
var
body: string;
begin
body := req.Content;
end;
获取到之后就是 json 解析的工作了,这个后面再提。
那么接下来就是返回数据的问题,这里有几个关键点,一个是 StatusCode
,在 FPWEB 里可以用 resp.Code
来访问它,接口需要在返回时给予正确的状态码,如果不填写,则默认为 200,即表示请求成功。
其次是返回内容的 content-type
,对于返回页面的,其 content-type
应为 text/html
,而返回 json 串的,则应该是 application/json
,最后才是具体的返回内容:
procedure sample(req: TRequest; resp: TResponse);
begin
resp.Code := 200;
resp.ContentType := 'application/json';
resp.Content := '{"code": 200, "message": "ok"}';
end;
数据操作
在 FPWEB 内,并没有包含任何与数据操作相关的代码,我们需要使用 fpc 原生的数据操作方案,也就是利用 fpc 内置的 SQLDB 框架来进行。具体的实现代码如下:
var
mysql: TMySQLConnection = nil;
trans: TSQLTransaction = nil;
query: TSQLQuery = nil;
procedure initMySQL();
begin
mysql := TMySQLConnection.Create(nil);
mysql.SkipLibraryVersionCheck:= True;
trans := TSQLTransaction.Create(nil);
mysql.Transaction := trans;
mysql.HostName:= MYSQL_HOST;
mysql.Port:= MYSQL_PORT;
mysql.LoginPrompt:= False;
mysql.CharSet:= 'utf8mb4';
mysql.UserName:= MYSQL_USER;
mysql.Password:= MYSQL_PASSWORD;
mysql.DatabaseName:= 'SampleDB';
try
mysql.Open;
WriteLn('MySQL Opened,');
except
on E: Exception do begin
WriteLn('Open MYSQL Failed, reason: ' + E.Message);
end;
end;
query := TSQLQuery.Create(nil);
query.DataBase := mysql;
end;
procedure freeMySQL();
begin
if (query <> nil) then begin
query.Clear;
query.Free;
end;
if (mysql <> nil) then begin
mysql.Close(True);
mysql.Free;
end;
if (trans <> nil) then begin
trans.Free;
end;
end;
这里需要注意一个底层开发的基本原则,就是分配了内存后,由其调用者释放,因此这里必须有 free
相关的函数,否则会引起内存泄漏。
成功连接数据库后,就可以进行操作了,这个操作依然有需要注意的地方:
function doQuery(sql: string): TListSampleData;
var
list: TListSampleData;
item: TSampleData;
begin
list := TListSampleData.Create;
query.SQL.Text := sql;
query.Open;
while (not query.EOF) do begin
item := TSampleData.Create();
item.field1 := query.FieldByName('field1').asInteger;
item.field2 := query.FieldByName('field2').asString;
list.Add(item);
end;
query.Clear;
Exit(list);
end;
为了保证内存不被过多占用,在查询完毕后,需要调用 query.Clear;
,它可以将查询过程中产生的临时数据,如列信息等予以清除。同样的,由于返回内容是一个对象,也需要调用者在使用完毕后予以释放。
JSON 操作
FPWEB 同样不提供 json 操作库,另外,由于底层语言更多使用 VMT 而不是对象池,使得运行时信息不足,很难有“反射”框架的出现,这个问题同样出现在 fpc 身上,由于 RTTI 很弱,json 的对象的相互转换是比较困难的事情(有一个开源的 fpc json 框架叫 easyjson,并不好用),很多时候我们依然需要完全手动的处理 json。
uses fpjson, jsonparser;
var
parser: TJSONParser;
json: TJSONObject;
begin
parser:= TJSONParser.Create(jsonstr, [joUTF8]);
json:= TJSONObject(parser.Parse);
Writeln(json.Strings['message']);
json.Free;
parser.Free;
end.
需要注意一下参数,joUTF8
通常情况下是不需要的,如果 json 字符串内包含如 \uXXXX
这类的转义,才需要使用该参数,用于将转义字符转换为原本的字符。
踩坑合集
一号坑:文件路径
在一开始我就提到过 RegisterFileLocation
,该方法需要传入一个实际的路径 filePath
,而这个 filePath
是有着一个限制的,即不能包含无效的路径表达,如以下获取路径方式:
filePath := ExtractFilePath(Application.ExeName) + 'files';
// filePAth = /root/server/./files
这个路径是错误的,它将会导致搜索文件失败,正确的写法是这样的:
filePath := ExtractFilePath(Application.ExeName);
if (filePath.EndsWith('./')) then
filePath := filePath.substring(0, filePath.Length - 2);
filePath += 'files';
// filePAth = /root/server/files
此时可以得到正确的路径,从而正确的 serve 文件。
二号坑:跨域
由于前后端分离的关系,很多项目会要求允许跨域,在以往的开发中,一个注解就能搞定的事情,在 FPWEB 上实现,就需要了解跨域的原理,FPWEB 并没有提供直接的方法来允许路由跨域。解决问题的代码也很简单:
procedure allowCors(req: TRequest; resp: TResponse);
var
ori: string;
begin
ori:= req.CustomHeaders.Values['Origin'];
if (ori = '') then ori:= '*';
resp.SetCustomHeader('Access-Control-Allow-Origin', ori);
resp.SetCustomHeader('Access-Control-Allow-Credentials', 'true');
end;
三号坑:数据连接断开
这是 MySQL 的一个经典问题了,在网上搜搜可以搜到一大堆,其原因就是当一个连接没有活跃超过一定的时限后,MySQL 会自动断开它,这个时候客户端并不会收到消息,这种情况下再进行查询操作,就会报 MySQL 连接异常了。
这个问题在 fpc 下是比较容易解决的,检查状态并且主动重连就可以,代码如下:
procedure reconnect();
var
st: string;
begin
st := mysql.ServerStatus.ToLower;
if (st.Contains('lost connection') or st.Contains('gone away')) then begin
mysql.Close(True);
mysql.Open;
end;
end;
四号坑:FPC 泛型
还记得 数据操作
部分的返回内容吗?有两个类分别是 TListSampleData
和 TSampleData
,它们的定义如下:
type
TSampleData = class
public
field1: Integer;
field2: String;
end;
TListSampleData = specialize TFPGList<TSampleData>;
可以看到,这个 TListSampleData
是一个泛型类,有很多人在操作这种类的时候,一旦用完就直接调用了 list.Free()
来进行释放。殊不知这个释放会直接引起内存泄漏,并且极难被查出。
这个问题本质的原因是,在 list 内存放的 TSampleData 对象,是经过分配内存的,而 list.Free()
仅能释放掉 TListSampleData 对象,而不能释放 list 内所包含的对象,当 list 对象消失后,里面的东西就无法再被访问到了。
但是又有人要说了,这个东西似乎有 bug 呀,泛型类不能覆写方法,那要怎么办呢?这确实是 fpc 的一个缺陷,但是我们也有别的办法来解决它,这个解决方案就是扩展。对于不能直接覆写的东西,我们都可以用扩展来搞定;
{$modeswitch typehelpers}
type
TListSampleListHelper = type helper for TListSampleList
public
procedure FreeAll;
end;
procedure TListSampleListHelper.FreeAll;
var
i: integer;
begin
for i := 0 to Self.Count - 1 do
Self.Items[i].Free;
Self.Free;
end;
这样一来,我们就可以在需要释放整个 list 时,调用 list.FreeAll()
了。
另外,如果是使用新版本的 fpc(指 3.3.1 以上),可以使用泛型类的托管模式,此时不需要另行扩展,也可以在释放 list 时自动释放其内部的对象。
五号坑:JSON 字符串转义
当你试图读入或返回一个 JSON 字符串时,若里面有需转义的字符,该怎么处理呢,可能你会按经验写下一个方法:
function StrToJSONEncoded(AStr: string): string;
begin
Exit(AStr
.Replace('"', '\"', [rfIgnoreCase, rfReplaceAll])
.Replace(#10, '\n', [rfIgnoreCase, rfReplaceAll])
);
end;
粗看没毛病,将双引号变为反斜杠双引号,将 ASCII 码 10 的字符变为 \n
。但是请记得这是底层语言,在某些处理时,回车换行是一起的,需要额外处理为:
function StrToJSONEncoded(AStr: string): string;
begin
Exit(AStr
.Replace('"', '\"', [rfIgnoreCase, rfReplaceAll])
.Replace(#13#10, '\n', [rfReplaceAll, rfIgnoreCase])
.Replace(#10, '\n', [rfIgnoreCase, rfReplaceAll])
.Replace(#13, '\n', [rfIgnoreCase, rfReplaceAll])
);
end;
六号坑:UTF8 字符串处理
这又是一个底层语言经常遇到的问题,由于语言本身不知道字符串的编码格式,又是按字节处理,因此对于一个 UTF8 的字符串来说,获取其长度,获取其每个位置上的字符,都较为困难,并且目前行业内也没有特别好的解决方案,没有通用的库。
解决方案是参考样例项目内的 UTF8StringHelper
,我已经对 UTF8 的操作进行了完整的扩展,在字符串内使用以 U
开头的方法或属性即可完成操作:
procedure sample(str: string);
var
i: integer;
begin
for i:= 1 to str.ULength do
Writeln(str.UChar[i]);
end;
可以用常规的字符串操作方法,如以下代码进行对比,即可知 UTF8 操作的正确性:
procedure sample(str: string);
var
i: integer;
begin
for i:= 1 to str.Length do
Writeln(str[i]);
end;
七号坑:与调用命令时的数据传递
在实际开发中,还有一个不可回避的问题,即调用其他程序,如某些特定操作需要利用现成的 Java 库,用 fpc 重写一遍显然不现实,这个时候就会产生调用 jar 包的情况,通常我们是这样用的:
uses process;
var
outstr: string;
begin
RunCommand('java', ['-jar', jarPath, param], outstr, [poWaitOnExit, poUsePipes]);
WriteLn(outstr);
end.
在以 UTF-8 编码的终端下运行,可以得到正确的结果,而在其他编码的情况下,返回 ASCII 码大于 128 的字符都会变成问号,这个问题的处理需要两方面都配合改造,改造方法如下:
uses process, base64;
var
outstr: string;
begin
RunCommand('java', ['-jar', jarPath, EncodeStringBase64(param)], outstr, [poWaitOnExit, poUsePipes]);
WriteLn(DecodeStringBase64(outstr));
end.
这里使用了 Base64 转换,用其他的算法也是可以的,其最终目的就是把字符串转换成完全由 ASCII 128 以内的字符组成的串,并将该串进行传递。
样例项目
花了几个晚上完成了一个样例项目,存放于 Github,感兴趣的同学可以前去观摩(点击进入),同样的在该项目的 main 分支,包含了使用 Ktor
开发的同样功能的服务程序(点击进入),可以比较直观的进行代码的对比,以更多的了解 FPWEB 如何实现某个具体功能。
总结
目前样例项目已成功部署于我的低配版阿里云服务器,在1核1G的条件下,真实环境顺利运行了48个小时,共计响应不同的请求 7 万余次,其间内存占用的波动为 7.4M ~ 12.6M,完全在可接受范围内。
当然了,对于开发者而言,过于底层的开发需要花费较大的精力,学习成本很高,试错成本也很高,同时对框架、语言,库本身的熟悉程度也将是影响开发效率的因素。对于不熟悉底层的人来说,使用 FPWEB 来开发基本上属于噩梦级别,并且很难做到高可用,但是在熟悉之后,慢慢的会感受到它带来的一些优势,虽然麻烦,但是底层的另一个特点就是你可以把一切都控制得面面俱到,包括每一次的内存分配和回收,也包括在不同的情况下响应不同的请求,你完全可以在接收到一个错误的 json body 时,给到默认值甚至自行进行容错,这也是上层框架很难做到的。
对于公司内部而言,我个人认为,基础组的同学有必要向底层走一走,不论是选择了哪个技术栈,对于 OS 来说,开销、性能永远是优于开发效率的,哪怕只是略做了解,收获的益处也远远大于 CRUD,这也算是我对我们基础组的一个期许吧。加油!