xts 发布的文章

在树莓派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

将PHP程序打包成可执行的phar文件

最近写了一个命令行脚本,涉及composer安装的几个库,还有自己封装的四五个类。不希望把一堆php脚本拷来拷去,最好能像phpunit那样就直接封装成一个可执行的文件。phpunit的做法是把所有相关文件打包封装到一个phar包里去分发,我也可以这么干。

首先配置php.ini里的phar.readonly=0,默认Php解释器对phar是只读访问的,不能修改phar的内容,以免意外修改整体交付的软件包。修改配置之后才可以用来打包生成phar。改好配置之后,建立程序目录结构如下:

.
├── bin
├── generate-phar.php
└── src
    └── index.php

其中src目录里有完整的代码文件,包括composer引入的第三方库。我们用generate-phar.php程序生成phar文件,并把它放到bin目录里去。

<?php
//在bin目录下创建phar文件
$phar = new Phar(__DIR__ . DIRECTORY_SEPARATOR . 'bin/mytool.phar');

//从src目录构建phar包
$phar->buildFromDirectory('src');

//定义默认执行入口为index.php
$defStub = Phar::createDefaultStub('index.php');

//设置php解释器shell头,让phar可以自己执行
$phar->setStub("#!/usr/bin/env php\n$defStub");

//用bzip2库压缩phar包里的文件(此步要求PHP安装了zlib和bz2扩展,可以跳过)
$phar->compressFiles(Phar::BZ2);

//将phar包改名,去掉phar扩展名
rename('bin/mytool.phar', 'bin/mytool');

//授予phar包可执行权限
chmod('bin/mytool', 0755);

现在可以用bin/mytool直接执行包中的程序了,phar会自动使用系统中安装的PHP解释器,如果想在任意目录运行此程序,可以把phar包拷到$PATH路径覆盖的目录中,比如/usr/local/bin

配置nginx为php-fpm合并多个X-Forwarded-For

最近用阿里云的容器服务遇到一个问题,docker里的PHP程序用X-Forwarded-For取用户IP的时候,只能取到SLB的地址。经tcpdump抓包确认,是请求经过阿里云的acsrouting的时候,在HTTP请求头中加入了多个X-Forwarded-For。查了一下,阿里云用HAProxy做负载均衡服务,又搜了一下HAProxy的资料,发现许多人在用HAProxy时遇到了同类问题,有人给HAProxy提了issue,但对方答复是根据RFC2616,多个同名的header和单个逗号分隔列表构成的header是等价的:

Multiple message-header fields with the same field-name MAY be present in a message if and only if the entire field-value for that header field is defined as a comma-separated list [i.e., #(values)]. It MUST be possible to combine the multiple header fields into one "field-name: field-value" pair, without changing the semantics of the message, by appending each subsequent field-value to the first, each separated by a comma. The order in which header fields with the same field-name are received is therefore significant to the interpretation of the combined field value, and thus a proxy MUST NOT change the order of these field values when a message is forwarded.

所以HAProxy不处理已有的X-Forwarded-For,只是简单地在header末尾加入一个新的X-Forwarded-For。可惜php-fpm对header的处理方式是同名的header只保留最后一个,所以无法取得正确的用户IP。

我找到一个解决方案是让nginx来合并多个X-Forwarded-For记录。在docker内的nginx配置中加入以下选项(一般在location ~ \.php$部分或者在fastcgi_params配置文件里):

fastcgi_param  HTTP_X_FORWARDED_FOR $http_x_forwarded_for if_not_empty;

配置好之后docker内的nginx服务器就会预先合并多个X-Forwarded-For请求header记录为逗号分隔格式,然后再传给php-fpm。

在平行空间中运行程序

介绍

思考这样一个实例:老师答疑时要下发通知给学员的App。把老师提交的答案保存到数据库只需50ms,调用第三方的WebService接口完成推送却需要等待500ms乃至数秒!我们希望老师可以马上看到答案提交成功,并且开始为下一位学员答疑,不必等待App推送完成。x2ts框架提供了一个新的功能来达成这一目的,你可以提交一段代码,让这段代码在独立的进程中运行,不会阻塞php-fpm。用起来就像这样:

X::parallel(function($userId, $title, $message) {
    $ret = X::curl->post(...); //请求推送接口
    X::logger()->info($ret);
})->run(
    $askSubject->user_id,
    $askSubject->author . '回答了你的问题',
    $askSubject->first_ask
);

例子中的代码把调用推送接口的代码写在了一个匿名函数中,然后调用run方法把参数传过去,这个匿名函数会在另一个进程中运行。这里的“另一个进程”,就是Parallel Runner的守护进程。

使用说明

想要使用Parallel Runner首先要用composer引入库:

composer require x2ts/x2ts-parallel

然后在配置文件中加入组件配置:

'parallel'     => [
    'class'     => \x2ts\parallel\Runner::class,
    'singleton' => true,
    'conf'      => [
        'name'       => 'sk-parallel',
        'sock'       => '/var/run/sk/parallel.sock',
        'pid'        => '/var/run/sk/parallel.pid',
        'lock'       => '/var/run/sk/parallel.lock',
        'workerNum'  => 16,
        'backlog'    => 128,
        'maxRequest' => 500,
    ],
],

X类中加入parallel方法申明

/**
 * @method static x2ts\parallel\Runner parallel(Closure $func = null)
 */
class X extends x2ts\ComponentFactory {}

编写Parallel Runner启动脚本:

<?php
// cliroot/parallel.php
require_once dirname(__DIR__) . '/webroot/xts.php';
X::parallel()->start();

最后,启动Parallel Runner守护进程:

mkdir /var/run/sk
chown -R www-data:www-data /var/run/sk
php cliroot/parallel.php

现在就可以像前面的例子一样,把程序放到Parallel Runner中运行了。

限制

因为匿名函数其实是在Parallel Runner进程中运行,所以必然会有一些限制:

  1. 不能使用use关键字引入外层变量,因为在另一个进程中没有此匿名函数的执行上下文。
  2. 不能使用$this变量,理由和上一条相同,但可以在内部的匿名类定义中使用$this,此时$this指向该匿名类的实例。
  3. 不能按引用传递变量,因为变量实际会通过IPC的方式发到另一个进程,按引用传递变量是没有意义的。
  4. 不能传递不可被序列化的变量,因为要通过IPC的方式发送到另一进程,resource和不可序列化的object不能作为参数传递。
  5. 不能期待返回值,因为程序在另一个进程中运行,执行结果只能通过修改数据库、Redis等方式反映,所有return的数据都会被丢弃。
  6. 不能期待echo输出,因为程序在另一个进程中运行,echo输出的内容并不会显示在页面上,因为是守护进程,也不会输出到屏幕,需要输出信息供阅读和调试,请通过日志输出。