FPWEB 完全踩坑与技巧实录

Posted by rarnu on 05-17,2021

前些天分享了一个牛逼的 WEB 服务框架即 FPWEB,今天继续来踩坑。为了实现服务的高可用,规避掉底层代码的问题,在框架的使用上还是有相当多的坑要踩,当然了,踩过去就是海阔天空。当然了,底层代码写起来麻烦,有些时候重复代码很多,还是会需要一些技巧,和一些特殊的写法来帮助提升开发效率。

编码!又是编码

是的,和底层打交道,编码的问题是绕不开的,之前我提供了一个对于 UTF8 字符串的扩展,但是这个扩展对于数据库操作是无效的,对于以下代码,连接 MySQL 并读写数据时,会产生乱码:

mTrans := TSQLTransaction.Create(nil);
mDb := TMySQL57Connection.Create(nil);
mDb.SkipLibraryVersionCheck:= True;
mDb.Transaction := mTrans;
mDb.HostName:= MYSQL_HOST;
mDb.Port:= MYSQL_PORT;
mDb.LoginPrompt:= False;
mDb.KeepConnection:= True;
mDb.CharSet:= 'utf8';
mDb.UserName:= MYSQL_USER;
mDb.Password:= MYSQL_PASSWORD;
mDb.DatabaseName:= 'SampleDB';
mDb.Params.Add('useUnicode=true');
mDb.Params.Add('characterEncoding=utf8');
mTrans.DataBase := mDb;
mDb.Open;

诶,很奇怪吧,我都已经把 CharSet 设置为 utf8了,并且连接参数里也加上,为什么会乱码呢?

这个锅应该由 libmysqlclient 来背,原因是 libmysqlclient 在实现时,使用的是 latin1 编码,不得不说,都 2021 年了,MySQL 官方依然没有重视这个东西,看起来是 Java 这类天生 UTF8 的语言占了 MySQL 相关开发的绝大多数吧。而在实际操作时,对于将数据送入 libmysqlclient 的情况,本质上是将字符串变成 Byte 数组然后送入,这个过程中 没有编码信息,而经过 libmysqlclient 时,编码又被定义为 latin1,因此写入数据库时,就变成了乱码。

解决方案是利用 MySQL 自带的机制,执行两个全局的语句:

mDb.ExecuteDirect('SET CHARACTER SET utf8');
mDb.ExecuteDirect('SET NAMES utf8');
mTrans.Commit;

这样在写入数据时,就不会出现乱码了,这时传递的数据在经过 libmysqlclient 时,即会正确的以 UTF8 编码予以解析。

以上解决方案适用于 *nux 平台,而针对 Mac,libmysqlclient 又有不同的表现,具体表现为,仅接收以 字符串 方式传入的编码,而不接收以 参数 形式传入的编码,具体来说是这样:

mQuery.Append;
mQuery.FieldByName('field1').asString := '测试';
mQuery.Post;
mQuery.ApplyUpdates;
// 以上代码写法将产生乱码

mQuery.SQL.Text := 'insert into SampleTable (field1) values (''测试'')';
mQuery.ExecSQL();
// 以上代码写法将正确插入数据

而在 Linux 下,一旦设置完 UTF8,则两种写法均可以正常工作。

在这里还需要注意一点,在 fpc 里面,由于使用单引号作为字符串的包装,那么在字符串内出现单引号,就需要转义,该转义方法是使用两个单引号。

至于说 Windows 下怎么开发,嗯?什么是 Windows,我表示不会啊 :(

顺便,这里再提一下带编码的响应,在返回数据时,在部分浏览器上可能会出现乱码,这是因为没有正确的设置返回的编码。一般来说,当返回 json 字符串时,我们会使用以下代码:

resp.Code := 200;
resp.ContentType := 'application/json';
resp.Content := jsonstring;

而正确的写法应当是这样的:

resp.Code := 200;
resp.ContentType := 'application/json; charset=utf-8';
resp.Content := jsonstring;

同样的,对于返回 html 或是文本的情况,也可以同样处理:

resp.ContentType := 'text/html; charset=utf-8';
resp.ContentType := 'text/plain; charset=utf-8';

响应图片(二进制内容)请求

最典型的例子就是前端请求 favicon.ico,这并非把图片放在程序同路径就完事的,而是要自己写请求响应:

procedure favicon(AReq: TRequest; AResp: TResponse);
begin
  try
    AResp.ContentType:= 'image/x-icon';
    AResp.SetCustomHeader('Accept-Ranges', 'bytes');
    AResp.ContentStream := TMemoryStream.Create;
    TMemoryStream(AResp.ContentStream).LoadFromFile(workPath + 'favicon.ico');
    AResp.ContentLength:= AResp.ContentStream.Size;
    AResp.SendContent;
  finally
    AResp.ContentStream.Free;
  end;
end;

除此之外,其他的图片可以放到指定目录下来 serve,也可以手动写响应代码, 注意返回图片的 ContentType 即可,其他的二进制内容可以用这样的方法来进行响应。

异常的正确处理

在 FPWEB 中,当一个组件产生异常后,如果不恢复该组件,这个组件会一直保持着不可用的状态,如使用 Connection 来访问数据库,若是 Connection 出现了异常,那么不论是否引起了程序整体崩溃,这个 Connection 都将不可再用。

为了解决这一问题,我们需要将异常进行接管并统一处理:

procedure showRequestException(AResponse: TResponse; AnException: Exception; var handled: boolean);
begin
  log(lvError, 'showRequestException: ' + AnException.Message);
  if (AnException.Message.ToLower.Contains('mysql access')) then begin
    resetMysqlConnection();
  end;
  AResponse.Code:= 500;
  AResponse.ContentType:= 'application/json; charset=utf-8';
  AResponse.Content:= '{"error": "%s"}'.Format([StrToJSONEncoded(AnException.Message)]);
  handled:= True;
end;

获取请求者的 IP 地址

为了输出日志,统计或是别的需求,通常我们都是要获取请求方的 IP 地址的,在 FPWEB 内,并没有直接的获取 IP 的接口,必须自己来实现一个,以下这个实现考虑了在 VPN 下沿转发路径获取真实 IP 的可能:

function getIPAddr(AReq: TRequest): string;
var
  ipAddress: string;
begin
  ipAddress:= AReq.GetCustomHeader('x-forwarded-for').Trim;
  if (ipAddress = '') or (ipAddress.ToLower = 'unknown') then begin
    ipAddress:= AReq.GetCustomHeader('Proxy-Client-IP').Trim;
  end;
  if (ipAddress = '') or (ipAddress.ToLower = 'unknown') then begin
    ipAddress:= AReq.GetCustomHeader('WL-Proxy-Client-IP').Trim;
  end;
  if (ipAddress = '') or (ipAddress.ToLower = 'unknown') then begin
    ipAddress:= AReq.RemoteAddr.Trim;
  end;
  Exit(ipAddress);
end;

更加效率的读写文件

由于 fpc 封装了文件操作的方法,也拥有很多简单的读写方式,如:

var
  list: TStringList;
begin
  list := TStringList.Create;
  list.LoadFromFile('log.txt');
  list.Add('my log');
  list.SaveToFile('log.txt');
  list.Free;
end.

这段代码很容易理解,将一个文件读入内存,然后追加一行日志,再保存。这里会有产生一个问题,即当文件的内容很多时,读和写均会造成巨大的开销,这里提一种完全底层的读写方法,这个方法对资源的开销非常的小:

var
  txt: TextFile;
begin
  AssignFile(txt, 'log.txt');
  if (FileExists('log.txt')) then begin
    Append(txt);
  end else begin
    Rewrite(txt);
  end;
  WriteLn(txt, 'my log');
  Flush(txt);
  CloseFile(txt);
end.

排查内存泄漏问题

在底层的开发中,内存问题一直是很严重并且很严肃的问题,如果对内存不重视,那使用底层框架开发就毫无意义,还是会落入如使用 Java 开发服务等占用大量内存的局面,并且更加的不好收拾。一般情况下,我会习惯于在分配内存时,在函数内部或是在其调用者先写好释放的代码,以防遗漏。

fpc 提供了一个内存分析的工具,即 Heaptrc 可以简单的在编译时开启,它可以帮助开发者在程序运行结束时,分析内存的释放情况。对于一些场景,Heaptrc 可以告知出现泄漏的代码片段和行号,但是也有一些场景下,仅能告知有泄漏存在,这会对排查问题造成很大的影响。因此,比较好的方法是使用单元测试,并且对单元测试开启 Heaptrc,以便对单个函数判断是否有内存泄漏,并可以进一步将这样的判断扩展到全局。

使用宏

不得不说,fpc 自带的单元测试非常的不好用,不能单独编译指定方法,这个时候我想到的是使用 ,是的,在底层的开发中,宏才是解决各种问题的好方法,比如说要进行正式编译和单元测试,就可以这样写了:

{$DEFINE UNITTEST}

{$IFDEF UNITTEST}
// test code
{$ELSE}
// product code
{$ENDIF}

在另一些方面,宏也可以帮助完成其他的工作,如上面提到的跨平台问题:

{$IFDEF DARWIN}
// code for Mac
{$ELSE}
// code for *nux
{$ENDIF}

更有甚者,也可以用宏来实现版本管理:

{$DEFINE V3}
const API_VER = {$IFDEF V3} 'v3' {$ELSE} 'v2' {$ENDIF};
HTTPRouter.RegisterRouter('/api/%s/sample'.Format([API_VER]), @sample);

总之,宏的妙用很多,需要在实际开发中多加体会,但是需要注意的是,宏并非逻辑代码,而是一种编译预处理,它主要的目的是告知编译器,要如何进行编译过程。因此在开发中是 绝对不允许 把业务逻辑的分支写在宏内的。

使用泛型和扩展来降低维护难度

众所周知的,很多原生语言都没有泛型,而有没有泛型其实在很大程度上决定了该语言是否适合进行框架的开发。fpc 虽然没有我们所熟悉的,通常意义上的泛型,但是却提供了一种优秀的解决方案思路,可以帮助我们解决问题。

在 fpc 内要定义一个泛型非常的简单,和定义类是一样的,只需要增加一个关键字:

type
  generic TSampleList<T, R> = class(TFPSList)
    type TMapCallback = function(item: T): R;
    type TMappedList = specialize TFPGList<R>;
    function Map(item:TMapCallback): TMappedList;
  end;

这样就定义好了一个泛型类,需要注意的是,泛型类不能被继承,只能从非泛型类继承而来。这里定义了一个 Map 方法,熟悉 Kotlin 的同学肯定知道 Map 方法的作用,它可以将一个 List 里的东西,批量的映射为另一种类型的东西。

然后我们可以实现这个 Map 方法,代码如下:

function TSampleList.Map(item: TMapCallback): TMappedList;
var
  i: Integer;
  ret: TMappedList;
begin
  ret := TMappedList.Create;
  for i := 0 to Self.Count - 1 do begin
    ret.Add(item(T(Self.Items[i])));
  end;
  Exit(ret);
end;

这样一个完整的泛型类就完成了,下面就看如何使用它。需要注意的是,fpc 并不支持动态泛型,而是必须将泛型类实化(注意,实化不是实例化),也就是将泛型类型予以填充,如下:

type
  TIntStrList = specialize TSampleList<Integer, String>;

使用 specialize 关键字可以将一个泛型类实化,在此之后 TIntStrList 即变成一个可用的类型。

随后实现对应 TMapCallback 的 lambda 函数即可:

function mapImpl(item: Integer): string;
begin
  Exit(IntToStr(item));
end;

然后就是正常使用该类型:

var
  list: TIntStrList;
  list2: TMappedList;
begin
  ...
  list2 := list.Map(@mapImpl);
  ...
end.

当然了,我在这里只能说一句,聊胜于无,这个泛型的使用方法,和 Kotlin 比起来还是任重而道远啊。

使用扩展函数

上面提到了一个用于获取请求者 IP 的函数,但是真当我们要做打日志时,每次调用该函数就显得很蛋疼,代码也不美观,我更加希望有一个属性能让我拿到 IP 地址,最好是这样的:

ip := req.IP;

为了实现这个目的,你可能会想到继承 TRequest 并且加入一个属性,然而事实上,我们并不需要做得这么复杂,来看下面的代码:

{$modeswitch typehelpers}
type
  TRequestHelper = type Helper for TRequest
  private
    function GetIP: string;
  public
    property IP: string read GetIP;
  end;
  
function TRequestHelper.GetIP: string;
begin
  var
  ipAddress: string;
begin
  ipAddress:= Self.GetCustomHeader('x-forwarded-for').Trim;
  if (ipAddress = '') or (ipAddress.ToLower = 'unknown') then begin
    ipAddress:= Self.GetCustomHeader('Proxy-Client-IP').Trim;
  end;
  if (ipAddress = '') or (ipAddress.ToLower = 'unknown') then begin
    ipAddress:= Self.GetCustomHeader('WL-Proxy-Client-IP').Trim;
  end;
  if (ipAddress = '') or (ipAddress.ToLower = 'unknown') then begin
    ipAddress:= Self.RemoteAddr.Trim;
  end;
  Exit(ipAddress);
end;

这样一来,IP 这个属性就被追加到了 TRequest 上,以后就可以直接使用 req.IP 了,和使用类内原本的方法没有任何区别。

总结

到此,也算是告一段落了,该踩的坑都踩完了,也介绍了一些技巧,在具体的场景下如何开发,是我们接下去要考虑的问题。先前有同学反映过底层难学的问题,其实在我看来,就一句话 无他,惟手熟尔,学任何技术都是一样的,需要大量的练习与实践,最好带着项目一起来,才有可能学会,用熟,光是看文档或文章,那几乎都是没用的。