如何用stream filter直接解压gzip格式的压缩流

PHP 提供了流过滤器功能,可以直接在流的读写过程中透明地进行加密、压缩、计算校验和等操作。然而压缩过滤器的官方文档上只介绍了 zlib.deflatezlib.inflate 两个过滤器可以处理 zlib 压缩的数据流,没有提供直接处理 gzip 格式压缩流的方法。

上一篇文章中我分享了使用 DEFLATE 算法的三种压缩格式,可以知道 zlib 和 gzip 使用的都是 DEFLATE 数据压缩算法,只是头尾长度和内容不同而已。这里我再分享一下直接用 zlib 过滤器处理 gzip 压缩流的方法,毕竟流过滤器有着不必接触原始文件,直接进行数据处理的优势。

方法非常简单,只要在挂载 zlib 流过滤器时,提供参数 window=31 即可。以下是范例代码,我们从 S3 中读取 gzip 压缩过的 CSV 文件流:

$stream = $s3_client->getReadStream('somefile.csv.gz');
stream_filter_append($stream, 'zlib.inflate', STREAM_FILTER_READ, ['window' => 31]);
$line = fgets($stream);
if (trim($line) !== '') {
    $fields = str_getcsv($line);
    // ...
}

根据 PHP 的官方文档,这个 window 参数的值应该在 8 到 15 之间才对,表示压缩时的窗口大小为 28 到 215 之间,为什么可以指定为 31 呢?这可以算是 zlib 库的一种高级用法。根据 zlib 库的文档windowBit 可以介于 8..15,意思是窗口大小在28 到 215 之间,也可以指定 -8..-15 表示要输出没有 zlib 封装的 DEFLATE 原始数据,或者还可以加上 16 来改用 gzip 封装格式。zlib 库中的 windowBit 正好对应了 PHP stream_filter_append$params 参数里的 window 项目。

可能是因为 zlib 文档中声明这些高级用法是只针对当前版本有效,所以 PHP 官方文档没有将这些特殊值收录进来。不过至少当前版本我们可以放心使用,可以在 composer.json 中的 platform 部分声明一下兼容的 ext-zlib 版本为 ^1.0,这样版本有变化的时候,composer 会给予提示。

PHP该用哪个压缩方法gzdeflate、gzcompress、gzencode

Gzip 是一种非常常见的压缩格式,PHP 也有一系列 gz 开头的函数用于操作 gzip 格式的压缩文件。然而令人困惑的是,同样是做压缩处理,却有着 gzdeflategzcompressgzencode 三个函数可用。那么它们都有着什么区别,平时又究竟应该用哪个呢?

这三个函数使用的压缩算法是一样的,都是 DEFLATE 压缩算法,三者的区别仅在于数据的封装格式不同。

  • gzdeflate 函数输出的是 DEFLATE 算法生成的原始数据,也被称作 RAW 格式的数据。由 RFC1951 定义
  • gzcompress 函数输出的是 zlib 格式的数据,它在 DEFLATE 生成的原始数据开头增加了两个字节的 header,末尾增加了四个字节的 ADLER-32 校验和。由 RFC1950 定义
  • gzencode 函数输出的是 gzip 格式,它是 gzip 工具定义的文件存储格式,理论上讲允许使用不同的压缩算法,但实际上目前只有 DEFLATE 一种实现。gzip 格式在 DEFLATE 生成的原始数据开头加入了一个至少10字节的变长header,末尾加入了固定8个字节的 tailer。由 RFC1952 定义

了解了三个函数的区别,就可以根据需要选择具体用哪个函数了。

  • gzdeflate 适用于可靠数据传输和存储环境下,需要减少数据量的情况。比如把数据压缩后存入 redis 或者 MySQL 的 blob 字段。
  • gzcompress 适用于需要处理 zlib 格式数据的场景。比如向 Accept-Encoding: deflate 的浏览器输出压缩的数据流。
  • gzencode 适用于需要被 gzip 工具解压的情况。比如把数据压缩并保存到 OSS 上,命名为 xxx.gz 的文件,允许用户下载后自行解压。

对了,PHP 还有一个 zlib_encode 函数,允许开发人员指定 $encoding 参数,它可以是 ZLIB_ENCODING_RAWZLIB_ENCODING_DEFLATE 或者 ZLIB_ENCODING_GZIP 三者之一,正好对应了 gzdeflategzcompressgzencode 三个函数的效果。如果读代码的人怕搞混,不妨考虑直接使用这个函数。

mysqldump导出emoji表情损坏的解决办法

默认情况下,mysqldump会以utf8作为字符集执行数据导出,如果记录中存在emoji表情符号,有可能导出后变成问号。 对于版本5.5.4以上的mysqldump,可以手动指定utf8mb4作为默认字符集。

# mysqldump --default-character-set=utf8mb4 ...

这样导出的SQL就能正常保存emoji符号了。

在树莓派Zero W上安装alpine linux系统

树莓派Zero W

PI-Zero-W.jpg

Raspberry Pi Zero 系列是我见过的最小的电脑系统。Zero W 是 Zero 之后发布的升级版本,它有一个支持硬件浮点运算的ARM架构1GHz 单核 CPU,512MB 的 RAM ,一个 Mini HDMI 接口,一个 USB OTG 接口,使用 Micro USB 供电。相比 Zero , Zero W 加入了 WiFi 和蓝牙模块,让连网变得更加方便。

Alpine Linux

Alpine Linux 是一款面向专业用户的 Linux 发行版,以小巧、简单、安全为主要特点。它使用 musl libc 和 busybox 构建,相比其它 Linux 发行版,它的核心只有 8MB ,安装之后也只有大约 130M。非常适合用在性能有限的设备上。

开始前的准备

在安装之前,先需要准备好必要的设备:

  • Micro SD 卡(树莓派没有内置存储空间, SD 卡相当于它的硬盘)
  • Micro SD 读卡器(用于在 PC 上写入树莓派的系统镜像)
  • 支持 HDMI 接口的显示器(其它接口也可以但需要转接线)
  • Mini HDMI 转 HDMI 转接头
  • USB 键盘
  • USB OTG 线
  • 5V 2A 的 USB 电源适配器(普通 Android 手机充电器就可以)
  • Micro USB 电源线( USB Type-C 的线用不了)

下载 Alpine Linux 镜像

在 Alpine Linux 的下载页有树莓派专用镜像,选择下载 armhf 版本。 armhf 是带硬件浮点运算支持的 ARM 架构 CPU,除梅莓派一代之外,其它机型的 CPU 都带有 hf 支持。

磁盘分区

我们需要将 Micro SD 卡分成两个磁盘分区:一个 256MB 的 FAT32 格式启动分区,树莓派只能识别 FAT32 ,不支持最新的 exFAT;一个 ext4 的 Linux 分区占用剩余的空间,我们将把 Alpine Linux 安装到这里。

我的 PC 上装的是 Ubuntu Linux 系统,我将使用 fdisk 命令进行磁盘分区。如果你使用 Windows 或者 Mac 系统,可以先用磁盘管理器创建一个 256MB 的 FAT32 的分区用于放置启动文件,ext4 格式的分区可以在安装过程中创建。

在插上读卡器之前,先用ls命令查看一个现在的硬盘分区都有哪些:

# ls /dev/sd*
/dev/sda  /dev/sda1 /dev/sdb /dev/sdb1 /dev/sdb2

我的电脑上有两块硬盘,一块 SSD 和一块机械硬盘,分别是sdasdb。然后将 Micro SD 卡插入读卡器,连接到 PC 上。再次用ls命令查看硬盘分区:

# ls /dev/sd*
/dev/sda /dev/sda1 /dev/sdb /dev/sdb1 /dev/sdb2 /dev/sdc /dev/sdc1

于是sdc应该就是新接上的 SD 卡无疑了。用fdisk命令对它进行分区:

# fdisk /dev/sdc

先用p命令打印一下现有的分区,千万不要搞错磁盘,不然会丢数据。


Welcome to fdisk (util-linux 2.27.1). Changes will remain in memory only, until you decide to write them. Be careful before using the write command. Command (m for help): p Disk /dev/sdc: 7.4 GiB, 7948206080 bytes, 15523840 sectors Units: sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disklabel type: dos Disk identifier: 0x00000000 Device Boot Start End Sectors Size Id Type /dev/sdc1 8192 15523839 15515648 7.4G b W95 FAT32

我用的是一张 8GB 的 SD 卡,可以看到出厂的时候已经分好了一个 FAT32 的分区。我会重新创建分区表,并划分分区。用o命令重新创建一个全新的 MSDOS 分区表。

Command (m for help): o
Created a new DOS disklabel with disk identifier 0xa159d333.

然后用n命令创建分区,选择p主要分区,编号1,容量+256MB

Command (m for help): n
Partition type
   p   primary (0 primary, 0 extended, 4 free)
   e   extended (container for logical partitions)
Select (default p):

Using default response p.
Partition number (1-4, default 1):
First sector (2048-15523839, default 2048):
Last sector, +sectors or +size{K,M,G,T,P} (2048-15523839, default 15523839): +256M

Created a new partition 1 of type 'Linux' and of size 256 MiB.

默认创建出来的分区类型是 Linux 类型,需要再自己修改分区类型。用t命令修改分区类型,L查看所有类型列表, FAT32 的分区类型码是c W95 FAT32 (LBA)。

Command (m for help): t
Selected partition 1
Partition type (type L to list all types): L

 0  Empty           24  NEC DOS         81  Minix / old Lin bf  Solaris
 1  FAT12           27  Hidden NTFS Win 82  Linux swap / So c1  DRDOS/sec (FAT-
 2  XENIX root      39  Plan 9          83  Linux           c4  DRDOS/sec (FAT-
 3  XENIX usr       3c  PartitionMagic  84  OS/2 hidden or  c6  DRDOS/sec (FAT-
 4  FAT16 <32M      40  Venix 80286     85  Linux extended  c7  Syrinx
 5  Extended        41  PPC PReP Boot   86  NTFS volume set da  Non-FS data
 6  FAT16           42  SFS             87  NTFS volume set db  CP/M / CTOS / .
 7  HPFS/NTFS/exFAT 4d  QNX4.x          88  Linux plaintext de  Dell Utility
 8  AIX             4e  QNX4.x 2nd part 8e  Linux LVM       df  BootIt
 9  AIX bootable    4f  QNX4.x 3rd part 93  Amoeba          e1  DOS access
 a  OS/2 Boot Manag 50  OnTrack DM      94  Amoeba BBT      e3  DOS R/O
 b  W95 FAT32       51  OnTrack DM6 Aux 9f  BSD/OS          e4  SpeedStor
 c  W95 FAT32 (LBA) 52  CP/M            a0  IBM Thinkpad hi ea  Rufus alignment
 e  W95 FAT16 (LBA) 53  OnTrack DM6 Aux a5  FreeBSD         eb  BeOS fs
 f  W95 Ext'd (LBA) 54  OnTrackDM6      a6  OpenBSD         ee  GPT
10  OPUS            55  EZ-Drive        a7  NeXTSTEP        ef  EFI (FAT-12/16/
11  Hidden FAT12    56  Golden Bow      a8  Darwin UFS      f0  Linux/PA-RISC b
12  Compaq diagnost 5c  Priam Edisk     a9  NetBSD          f1  SpeedStor
14  Hidden FAT16 <3 61  SpeedStor       ab  Darwin boot     f4  SpeedStor
16  Hidden FAT16    63  GNU HURD or Sys af  HFS / HFS+      f2  DOS secondary
17  Hidden HPFS/NTF 64  Novell Netware  b7  BSDI fs         fb  VMware VMFS
18  AST SmartSleep  65  Novell Netware  b8  BSDI swap       fc  VMware VMKCORE
1b  Hidden W95 FAT3 70  DiskSecure Mult bb  Boot Wizard hid fd  Linux raid auto
1c  Hidden W95 FAT3 75  PC/IX           bc  Acronis FAT32 L fe  LANstep
1e  Hidden W95 FAT1 80  Old Minix       be  Solaris boot    ff  BBT
Partition type (type L to list all types): c
Changed type of partition 'Linux' to 'W95 FAT32 (LBA)'.

将分区1标记为可启动:

Command (m for help): a
Selected partition 1
The bootable flag on partition 1 is enabled now.

创建第二块分区, Linux 类型,占用剩余所有空间:

Command (m for help): n
Partition type
   p   primary (1 primary, 0 extended, 3 free)
   e   extended (container for logical partitions)
Select (default p): p
Partition number (2-4, default 2):
First sector (526336-15523839, default 526336):
Last sector, +sectors or +size{K,M,G,T,P} (526336-15523839, default 15523839):

Created a new partition 2 of type 'Linux' and of size 7.2 GiB.

可以再用p命令看看分区是否正确:

Command (m for help): p
Disk /dev/sdc: 7.4 GiB, 7948206080 bytes, 15523840 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xa159d333

Device     Boot  Start      End  Sectors  Size Id Type
/dev/sdc1  *      2048   526335   524288  256M  c W95 FAT32 (LBA)
/dev/sdc2       526336 15523839 14997504  7.2G 83 Linux

一个 256MB 的可启动 FAT32 分区,一个 7.2GB 的 Linux 分区,没有问题按w保存分区表并退出。

Command (m for help): w
The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks.

mkfs命令格式化磁盘,将分区1格式化为 vfat 格式,分区2格式化为 ext4 格式:

# mkfs -t vfat /dev/sdc1
mkfs.fat 3.0.28 (2015-05-16)
# mkfs -t ext4 /dev/sdc2
mke2fs 1.42.13 (17-May-2015)
Creating filesystem with 1874688 4k blocks and 469568 inodes
Filesystem UUID: 7dca0553-48b1-4c5e-96af-c1e84d995648
Superblock backups stored on blocks:
    32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632

Allocating group tables: done
Writing inode tables: done
Creating journal (32768 blocks): done
Writing superblocks and filesystem accounting information:
done

释放下载好的镜像

创建一个用于挂载新分区的目录/mnt/c,并将格式化好的 FAT32 分区挂载到其中:

# mkdir /mnt/c
# mount -t vfat /dev/sdc1 /mnt/c

将下载好的树莓派 armhf 镜像释放到/mnt/c下:

# cd /mnt/c
# tar xzf ~/Download/alpine-rpi-3.7.0-armhf.tar.gz

释放过程中会看到许Cannot change ownership to uid 1000, gid 1000: Operation not permitted这样的错误信息,这是因为 FAT32 文件系统不支持用户所有权属性导致的,直接无视它们就好。

然后需要在 FAT32 分区里添加一个usercfg.txt的配置文件,并在其中写入一行enable_uart=1,否则树莓派启动后,控制台会不停输出提示Can't open /dev/ttyS0

# echo 'enable_uart=1' > /mnt/c/usercfg.txt

下载 WiFi 驱动

默认 Alpine Linux 的树莓派镜像不是为 Zero W 型号设计的,所以没有加入 WiFi 模块的固件。然而我们在安装过程中还是会需要联网下载最新的软件包,所以我们需要自己下载安装。从Github项目中下载固件文件,放在 FAT32 分区的firmware/brcm目录下。

# mkdir -p /mnt/c/firmware/brcm
# cd /mnt/c/firmware/brcm
# wget https://github.com/RPi-Distro/firmware-nonfree/raw/master/brcm/brcmfmac43430-sdio.bin
# wget https://github.com/RPi-Distro/firmware-nonfree/raw/master/brcm/brcmfmac43430-sdio.txt

下载好 WiFi 模块的固件之后,即可卸载 SD 卡开始安装了。

# cd /
# umount /mnt/c

启动树莓派

将 SD 卡插入树莓派,连上显示器和键盘,通电点亮树莓派。稍候即可看到 OpenRC 的启动界面, OpenRC 是 Alpine Linux 使用的服务启动管理器。看到登录提示之后,输入用户名root,安装时无需输入密码。

安装 WiFi 工具

在启动安装脚本之前,我们需要先安装 WiFi 工具,这样才能在安装过程中设置无线网络。

# apk add wireless-tools wpa_supplicant

装好之后运行iwconfig命令可以看到wlan0网卡的信息,此时应该是关闭的状态。

# iwconfig
lo        no wireless extensions.
wlan0     IEEE 802.11bgn  ESSID:off/any
          Mode:Managed  Access Point: Not-Associated   Tx-Power=14 dBm
          Retry short limit:7   RTS thr:off   Fragment thr:off
          Encryption key:off
          Power Management:off

安装脚本

现在可以运行安装脚本,安装系统了。

# setup-alpine

在安装的过程中,会提示你设置键盘布局、设置 WiFi 网络、设置时区、设置时间同步程序、设置apk软件源,设置root密码等等,按向导提示安装即可。

如果是普通安装,Alpine Linux 将会把整个系统安装到 FAT32 启动分区中,每次启动电脑的时候,会自动创建一个内存盘作为根分区。这种默认的安装方式比较简单,但却让 Zero W 本来就有限的内存显得更加捉襟见肘。

sys 模式

于是我选择了另一种安装方式—— sys 模式——将整个系统安装到一个 ext4 分区中。这样不需要内存盘作为根分区,可以节省出保贵的内存,也能让系统更可靠。请注意使用这种安装方式,在安装向导中需在save configsave cache步骤的提示中输入none

走完setup-alpine脚本之后,我们还需要安装 ext4 文件系统的支持,这部分需要联网下载安装。

# apk update
# apk add e2fsprogs

如果使用 Windows 或者 Mac 系统做的初始分区,可以在此时创建第二块分区,并格式化。

将格式化好的 ext4 分区挂载到/mnt下,注意树莓派上的设备是以mmcblk为前缀命名的。

# mount /dev/mmcblk0p2 /mnt

运行setup-disk命令将系统安装到另一分区。因为setup-disk脚本还不太完善,安装过程中可能会提示一些错误,忽略即可。

# setup-disk -m sys /mnt

重新挂载当前 FAT32 分区为可写模式:

# mount -o remount,rw /dev/mmcblk0p1

清理旧的,已经用不着的启动文件:

# rm -f /media/mmcblk0p1/boot/*  
# cd /mnt       # 切换到 ext4 分区
# rm boot/boot  # 删除无用的符号链接

将启动镜像和 Alpine Linux 的init ram移动到 FAT32 分区中的正确位置:

# mv boot/* /media/mmcblk0p1/boot/
# rm -Rf boot # 删除 ext4 分区中的 boot 目录
# mkdir media/mmcblk0p1   # 下次重启后 FAT32 分区的新挂载点

建立一个软链,这样以后升级系统后无需人工复制到 FAT32 分区中。不用担心错误提示:

# ln -s media/mmcblk0p1/boot boot

更新/etc/fstab分区挂载配置:

# echo "/dev/mmcblk0p1 /media/mmcblk0p1 vfat defaults 0 0" >> etc/fstab
# sed -i '/cdrom/d' etc/fstab   # 显示树莓派上没有光驱
# sed -i '/floppy/d' etc/fstab  # 也没有软驱

如果你要使用开源社区维护的软件,比如vim、php、nginx等等,修改etc/apk/repositories 中的配置,开启community软件源。

修改 FAT32 分区上记录的启动配置,将 root 分区改为 ext4 分区:

# cd /media/mmcblk0p1
# sed -i 's/^/root=\/dev\/mmcblk0p2 /' cmdline.txt

最后重启系统:

# reboot

安装之后

你可能发现重启之后 WiFi 断了,这是因为 wpa_supplicant 没有自动启动导致的。

先手动启动它:

# service wpa_supplicant start

然后设置它自动启动:

# rc-update add wpa_supplicant boot

由于树莓派上没有用于保持时钟的电池,我们需要调整设置使用软件时钟,以及使用网络授时。

# rc-update del hwclock boot
# rc-update add swclock boot
# service hwclock stop
# service swclock start

你可能想马上升级一下系统:

# apk update
# apk upgrade

一般来说,常用的软件也可以装一下:

# apk add vim htop openssl curl

至此树莓派 Zero W 已经安装好Alpine Linux 了,如果想要更安全的 ssh 方案,可以考虑用密钥登录或者创建限权账号,并安装sudo

参考资料

  • https://wiki.alpinelinux.org/wiki/Raspberry_Pi
  • https://wiki.alpinelinux.org/wiki/Classic_install_or_sys_mode_on_Raspberry_Pi
  • https://wiki.alpinelinux.org/wiki/Connecting_to_a_wireless_access_point#Broadcom_Wi-Fi_Chipset_Users
  • https://bugs.alpinelinux.org/issues/6959

批量加载关系对象和属性

Relation Load 的性能问题

长期以来,我们一直使用ORM技术进行数据库操作。相比直接用PDO写SQL并操作结果数组,利用ORM类有诸多好处,其中最好用的两点在于:

  1. 相比数组,Model类提供了一个编写getter和setter的地方,可以更灵活地处理和转化数据库中的字段。
  2. 利用关系加载技术让可以直接像访问普通属性一样访问相关的Model对象,无需再编写相应SQL。

然而一直以来,上述两种常见的ORM用法,存在严重的性能问题。因为getter和自动关系加载都针对单个对象操作,ORM库常常会为一页的记录生成上百条SQL查询语句,执行起来耗时较长。

比如要查询以下报表:

学员姓名 手机号 所属分校 所在高校 所在班级 负责老师 最近沟通

报表中的学员姓名、手机号来自student表,所属分校来自school表,所在班级来自class表,所在高校来自colleage表,负责老师来自teacher表,最近沟通来自contact_latest表。表关系如下:

-----------
| teacher |
-----------
     ^
     |
-----------
| school  |
-----------
     ^
     |
-----------     ------------------
| student | --> | contact_latest |
-----------     ------------------
     |
     V
-----------
|  class  |
-----------
     |
     V
------------
| colleage |
------------

每查询一页的报表数据,假设一页50条记录,需要生成两条select student的SQL(一条count一条分页选取,在之后的关系加载过程中,需要再生成250条select语句,用于为每条student记录查询其它相关记录,虽然每条关联记录都是用主键id查询。

用with方法提升性能

引发上述性能问题的关键原因在于,每个ORM记录对象单独做关系加载时,并不知道其它对象也要做相似的关系加载,所以只能生成大量的单条查询SQL。这样在和数据库通讯RTT值高的情况下,性能下降尤其明显。

为了解决这种性能问题,x2ts的从2.5版开始,向Model类新增了一个with方法,它允许我们定义一系列需要批量载入的关系属性,并在下一次many或者sql调用时生效。比如:

X::model('student')->with(
    'school.master',
    'contact_latest',
    ['class', 'colleage']
)->many('user_id IS NOT NULL', [], 0, 50);

上面这段代码会在载入50条学员记录之后,自动载入和它们关联的分校、老师、班级、高校、联系记录。总共生成6条SQL语句,除student外的5条关联查询SQL,都使用IN查询取得集合数据,有效避免了大量单条查询语句的生成。

with方法接收一个或者多个参数,每个参数是一条关联查询定义,可以是字符串、也可以是数组,还可以是x2ts\db\orm\BatchLoader接口的实例。

with方法的参数说明

字符串描述的是关联关系的名称,可以用点分隔做次级加载。比如:

'school.master'

表示关系载入school属性,然后再载入schoolmaster属性。


数组描述的是多级加载关系。['class', 'colleage']数组等价于'class.colleage'字符串。


有时会对某一级关系对象的多个下级存在加载需求,可以用关联数组实现。比如:

['school' => ['master', 'creator']]

表示关系载入school之后,再载入schoolmastercreator两个属性。


关联数组的key可以用点分隔表示多级,规则和字符串一样,key必需是数组中的最后一项。比如:

['school.master' => ['role', 'ext_info']]

表示关系载入school后,再载入schoolmaster属性,然后载入masterroleext_info属性。

这种写法等价于

['school', 'master' => ['role', 'ext_info']]

多层数组可以相互嵌套,比如:

[
    'class.colleage' => [[
            'creator' => [
                'role',
                'ext_info',
            ],
        ], [
            'master' => 'role'
        ],
        'location',
    ]
]

表示要载入class属性,以及加载classcolleage属性,然后加载colleagecreatormasterlocation属性,还要加载creatorroleext_info属性,masterrole属性。

用with批量关系加载的错误和异常

只有BelongToRelationHasOneRelation实现了批量关系加载。如果声明对HasManyRelation或者ManyManyRelation作批量关系加载,会抛出MethodNotImplementException异常。

指定关系加载时的名称应当是关系名称,如果在Model的relations数组中没有定义该名称,则会抛出UnresolvableRelationException

自定义批量加载器

前面的例子全部都在进行关系加载,有的时候数据表上没有建立外键关系,但我们通过自己写的getter实现单条数据的载入,也面临类似的性能问题。比如:

class QuestionFilter extends x2ts\db\orm\Model {
    public function getSubject() {
        return X::model('subject')->load($this->subject_id);
    }
}

如果我们查询出QuestionFilter的列表,然后需要对每条记录单独查询所属学科。从而产生大量select语句,性能不佳。

可以通过自定义批量加载器实现关联关系的批量加载。

// 重新定义getter和setter,允许写入subject属性
class QuestionFilter extends x2ts\db\orm\Model {
    private $subject;
    public function getSubject() {
        return $this->subject ?? 
            X::model('subject')->load($this->subject_id);
    }

    public function setSubject($s) {
        if ($s instanceof Subject) {
            $this->subject = $S;
        }
    }
}

//实现一个自定义的批量加载器
class QFSubjectLoader implements x2ts\db\orm\BatchLoader {
    public function name():string {
        return 'subject';
    }

    /**
     * @param Model[] $models
     * @param array $subLoaders
     *
     * @return void
     */
    public function batchLoadFor($models, $subLoaders) {
        $subjectIds = [];
        foreach ($models as $model) {
            $subjectIds[] = $model->subject_id;
        }
        $inStr = implode(',', $subjectIds);
        $subjects = X::model('subject')
            ->with(...$subLoaders)
            ->many("id IN ($inStr)");
        $subjectMap = [];
        foreach ($subjects as $subject) {
            $subjectMap[$subject->id] = $subject;
        }
        foreach($models as $model) {
            $model->subject = $subjectMap[$model->subject_id] ?? null;
        }
    }
}

//使用自定义的批量加载器
$filters = X::model('question_filter')
    ->with(new QFSubjectLoader())
    ->many('user_id=:uid', [':uid' => $currentUser->id], 0, 50);

批量加载器的name方法定义了加载器的属性名,batchLoadFor方法负责批量载入相关数据,并写入到$models各元素中,$subLoaders需要透传到当前属性Model的with方法里,实现次级批量加载。

如果自定义的批量加载器不支持次级批量加载,或者当前载入的不是Model对象,则应该忽略$subLoaders,如果$subLoaders不为空,应输出warning

使用ReferenceBatchLoader加载器

上面介绍的学科可以用自定义加载器载入,然而这种没有外键却需要加载相关Model对象的情况非常常见,所以x2ts框架提供了一个ReferenceBatchLoader加载器,以解决这种使用场景。

上例中的QFSubjectLoader可以不必自己开发,直接用ReferenceBatchLoader

$filters = X::model('question_filter')
    ->with(new ReferenceBatchLoader(
        'subject',
        'subject_id',
        X::model('subject')
    ))
    ->many('user_id=:uid', [':uid' => $currentUser->id], 0, 50);

ReferenceBatchLoader的构造函数接收三个参数:第一个是加载器的名称,也是加载进来之后的属性名,需要自己为该属性写好getter和setter,或者可以直接声明public的属性;第二个参数是当前Model对象中保存外部对象的主键的属性名;第三个参数是要加载的外部Model的一个对象实例。

如果当前对象的外部引用字段为空,或者引用的id在数据库里找不到,批量加载器会给相应属性赋值为null