使用 FPWEB 打造超轻量的 Web 服务

Posted by rarnu on 05-13,2021

前言

最近被内存优化折磨得翻来覆去的,可怕的 springboot 服务启动即占 300M 简直是一场噩梦,当服务数量一多,整个 K8S 体系对内存的消耗就相当的恐怖了。在这个前提下,我们开始寻找对内存要求较低的框架,也进行了大量的测试,这里有一部分空载运行的数据:

框架最小内存
springboot87.9M
ktor55.2M
webflux87.5M
golang1.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 请求,可以写为 rmGetrmPost

后面接着的 @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 泛型

还记得 数据操作 部分的返回内容吗?有两个类分别是 TListSampleDataTSampleData,它们的定义如下:

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,这也算是我对我们基础组的一个期许吧。加油!