Nicksxs's Blog

What hurts more, the pain of hard work or the pain of regret?

之前在用到valet的时候就觉得这个工具很厉害,因为本地部署很多时候都是比较费劲的,也比较简陋,就直接localhost启动下,但是有时候需要验一下回调的,就需要有域名跟https,当然之前也没想明白的是在本地其实用本地的地址也是可以的,意思就是如果回调地址没限制必须要域名,也可以用127.0.0.1或者localhost,当然如果需要域名和https那就正好是valet的用武之地,所以想把 Valet 这类工具背后的逻辑捋一下。

平时用 Valet 的时候体验是非常顺的:

1
2
valet park
valet secure demo

然后浏览器里访问:

1
https://demo.test

就能看到本地项目,而且还有 HTTPS 的小锁。

这个体验乍一看很像魔法。一个本地域名,既不用手动写 hosts,也不用自己配 Nginx,还不用去公网 CA 申请证书,怎么就能跑起来了呢?

其实拆开之后,Valet 并没有发明什么新协议,它只是把本地开发里几件比较麻烦的事情串起来了:

1
2
3
4
5
6
本地域名解析
Nginx 入口
PHP-FPM 执行 PHP
项目目录映射
本地 CA 和站点证书
框架 driver 判断入口文件

先看完整链路

先放一个整体流程,后面再一段一段拆。

假设我们访问:

1
https://demo.test/users/1

整个过程大概是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
浏览器访问 https://demo.test/users/1
|
v
macOS 发现 .test 域名要走 /etc/resolver/test
|
v
本地 dnsmasq 把 demo.test 解析到 127.0.0.1
|
v
请求到达本机 443 端口
|
v
Nginx 接收请求,并使用 demo.test 的证书完成 HTTPS
|
v
Nginx 把请求交给 Valet 的 server.php
|
v
server.php 根据 Host 找到 demo 对应的本地目录
|
v
Valet driver 判断这是 Laravel 项目
|
v
静态文件直接返回,非静态请求交给 public/index.php
|
v
Laravel 应用开始处理路由

所以可以先得出一个粗略结论:

1
2
3
4
5
6
DNS 负责把域名带到本机
Nginx 负责接住 HTTP/HTTPS 请求
server.php 负责把请求分发到具体项目
driver 负责判断不同框架的入口规则
PHP-FPM 负责真正执行 PHP
本地 CA 负责让浏览器信任本地证书

这个结论很重要,因为很多时候我们排查 Valet 问题,就是在判断链路断在哪一层。

比如:

1
2
3
4
5
域名打不开,可能是 DNS/dnsmasq 问题
80/443 端口不通,可能是 Nginx 问题
项目找不到,可能是 park/link 映射问题
PHP 报错,可能是 PHP-FPM 或项目本身问题
HTTPS 不被信任,可能是证书或系统信任问题

Valet 的配置中心

源码里可以先看 Configuration.php

Valet 会在用户目录下维护一个自己的配置目录:

1
~/.config/valet

这个目录下面会有几类东西:

1
2
3
4
5
6
7
~/.config/valet/config.json
~/.config/valet/Sites
~/.config/valet/Drivers
~/.config/valet/Nginx
~/.config/valet/Log
~/.config/valet/Certificates
~/.config/valet/CA

从源码看,Configuration::install() 做的事情就是创建这些基础目录,并确保基础配置存在。

其中最核心的是:

1
config.json

它大概长这样:

1
2
3
4
5
{
"tld": "test",
"loopback": "127.0.0.1",
"paths": []
}

这几个字段分别代表:

1
2
3
tld       本地域名后缀,默认是 test
loopback 本地回环地址,默认是 127.0.0.1
paths 被 park 的目录列表

所以 Valet 的状态不是凭空来的,它有一个非常明确的落盘位置。

比如 valet park 之后,当前目录会被写到 paths 里。

比如 valet link demo 之后,~/.config/valet/Sites/demo 里会出现一个指向真实项目目录的符号链接。

比如 valet secure demo 之后,证书会进入:

1
~/.config/valet/Certificates

对应的 Nginx 站点配置会进入:

1
~/.config/valet/Nginx

理解这个目录很有用,因为后面很多问题都可以直接去这里看。

valet install 大概做了什么

我们平时执行:

1
valet install

它背后不是单纯装一个命令,而是在本机准备一整套本地 Web 开发环境。

从源码结构上看,大体可以分成几部分:

1
2
3
4
5
Configuration.php  创建 ~/.config/valet 相关目录和 config.json
DnsMasq.php 安装和配置 dnsmasq
Nginx.php 安装和配置 Nginx
PhpFpm.php 配置 PHP-FPM 和 valet.sock
Valet.php 处理 valet 命令本身的链接和 sudoers 等辅助事情

这里先不展开 PHP 版本管理和 sudoers 等细节,只看主链路。

Valet 安装完成后,本机基本会形成这么一个结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
macOS DNS
|
v
dnsmasq
|
v
127.0.0.1:80 / 127.0.0.1:443
|
v
Nginx
|
v
Valet server.php
|
v
具体项目

也就是说,Valet 的轻量不是说它什么服务都没有,而是它复用了本机的 Nginx、PHP-FPM、dnsmasq,不需要为每个项目单独开一个虚拟机或容器。

demo.test 为什么会到本机

先看第一个问题:

1
demo.test 怎么就指向 127.0.0.1 了?

如果不用 Valet,我们最容易想到的方法是改 /etc/hosts

1
127.0.0.1 demo.test

但这样有个问题:每加一个项目,就要写一行。

Valet 不这么做。它用的是 dnsmasq 加 macOS resolver。

源码里对应的是 DnsMasq.php

DnsMasq::install() 的流程大概是:

1
2
3
4
5
确保 dnsmasq 已安装
让 dnsmasq 加载额外配置目录
创建 .test 后缀对应的 dnsmasq 配置
创建 /etc/resolver/test
重启 dnsmasq

这里有两个关键文件。

第一个是类似这样的 dnsmasq 配置:

1
2
3
address=/.test/127.0.0.1
address=/.test/::1
listen-address=127.0.0.1

它的意思是:

1
所有 .test 结尾的域名,都解析到本机

所以这些域名都可以成立:

1
2
3
4
demo.test
blog.test
api.test
anything.test

第二个是:

1
/etc/resolver/test

内容大概是:

1
nameserver 127.0.0.1

这个文件告诉 macOS:

1
遇到 .test 这个后缀,不要走普通 DNS,交给 127.0.0.1 上的 DNS 服务处理

然后本地的 dnsmasq 再把它解析回 127.0.0.1。

这里要注意一件事:DNS 这一层只解决了“域名到 IP”的问题。

也就是说:

1
2
3
demo.test -> 127.0.0.1
blog.test -> 127.0.0.1
api.test -> 127.0.0.1

但是 DNS 并不知道:

1
2
demo.test 对应哪个项目目录
blog.test 对应哪个项目目录

这个问题要留给后面的 Nginx 和 Valet 自己处理。

请求到本机后谁来接

域名解析到 127.0.0.1 之后,浏览器会根据协议和端口发请求。

如果是 HTTP:

1
http://demo.test -> 127.0.0.1:80

如果是 HTTPS:

1
https://demo.test -> 127.0.0.1:443

这时接请求的是 Nginx。

源码里对应的是 Nginx.php

Nginx::install() 大致做几件事:

1
2
3
4
确保 Nginx 已安装
写入 nginx.conf
写入 Valet 的 valet.conf
创建 ~/.config/valet/Nginx 目录

这里比较关键的是 Valet 的 Nginx 不是简单地给某一个项目写死 root。

它会准备一个统一入口,把请求转给 Valet 自己的 server.php

可以粗略理解为:

1
2
3
4
5
6
7
Nginx 接住所有本地 .test 请求
|
v
统一转给 Valet server.php
|
v
server.php 再决定这个请求属于哪个项目

这一点是 Valet 设计里比较关键的地方。

如果完全靠 Nginx,每个项目都要生成一个完整的 server block:

1
2
demo.test -> /Users/me/code/demo/public
blog.test -> /Users/me/code/blog/public

Valet 当然也会为 HTTPS、proxy、PHP 隔离这类特殊场景生成站点配置,但普通场景下,它更核心的思路是:

1
2
Nginx 做入口
PHP 层的 server.php 做动态分发

这样它才能做到 valet park 之后,目录下面新增一个项目,不需要手工改 Nginx 配置也能访问。

接着看项目目录映射。

Valet 常用两个命令:

1
2
valet park
valet link

它们都能让本地域名对应到项目,但思路不一样。

park 是登记一个父目录

假设我们在这个目录执行:

1
2
cd ~/Sites
valet park

然后目录结构是:

1
2
3
~/Sites/demo
~/Sites/blog
~/Sites/shop

Valet 就可以让这些域名工作:

1
2
3
demo.test -> ~/Sites/demo
blog.test -> ~/Sites/blog
shop.test -> ~/Sites/shop

从配置角度看,park 主要就是把 ~/Sites 这个路径写入 config.jsonpaths

之后 Valet 查找站点时,会扫描这些 parked path 下面的子目录。

所以 park 的心智模型是:

1
2
我把一个工作区交给 Valet
工作区下每个子目录都是一个站点

link 更像是给当前目录起一个名字。

比如:

1
2
cd ~/code/some-long-project-name
valet link demo

它会创建类似这样的符号链接:

1
~/.config/valet/Sites/demo -> ~/code/some-long-project-name

之后:

1
demo.test

就对应这个真实项目目录。

源码里 Site::link() 做的事情也很直接:

1
2
3
确保 ~/.config/valet/Sites 存在
把 Sites 目录加入配置路径
创建一个符号链接

所以可以这样理解:

1
2
park 是目录扫描
link 是显式别名

这也是排查时很好用的判断。

如果是 park 出来的站点,要看父目录有没有在 config.jsonpaths 里。

如果是 link 出来的站点,要看 ~/.config/valet/Sites 下的符号链接是否存在、是否指向正确目录。

每次请求真正进入的是 server.php

现在 DNS 和 Nginx 都说完了,请求已经进入 Valet。

真正处理请求分发的是仓库根目录下的:

1
server.php

这段源码非常值得看,因为它基本把 Valet 的请求生命周期写清楚了。

它做的事情可以简化成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$config = read_valet_config();

$uri = parse_request_uri();
$siteName = site_name_from_http_host($_SERVER['HTTP_HOST']);
$sitePath = find_site_path($siteName);

$driver = ValetDriver::assign($sitePath, $siteName, $uri);

if ($driver->isStaticFile(...)) {
return $driver->serveStaticFile(...);
}

$frontController = $driver->frontControllerPath(...);

require $frontController;

这里面有几个关键点。

第一个,siteName 来自 HTTP Host。

访问:

1
demo.test

Valet 会从 Host 里解析出:

1
demo

第二个,sitePath 来自 Valet 的站点映射。

也就是前面说的:

1
2
parked paths
linked sites

第三个,Valet 不会一上来就假设这是 Laravel 项目,而是会通过 driver 选择。

这一层是 Valet 能支持很多框架的关键。

driver 是 Valet 的框架适配层

源码里 ValetDriver.php 定义了几个关键方法:

1
2
3
serves()
isStaticFile()
frontControllerPath()

这三个方法基本就回答了三个问题:

1
2
3
这个 driver 能不能处理当前项目?
当前请求是不是静态文件?
如果不是静态文件,入口文件在哪里?

比如 Laravel 项目的 driver,也就是 LaravelValetDriver.php,判断逻辑可以概括成:

1
2
3
项目目录下有 public/index.php
并且项目目录下有 artisan
那就认为这是 Laravel 项目

这就很符合 Laravel 项目的基本结构。

对于静态文件,它会优先找:

1
2
项目目录/public/当前 URI
项目目录/storage/app/public/当前 URI

如果请求的是:

1
/css/app.css

并且真实文件存在:

1
public/css/app.css

那就直接返回静态文件。

如果请求的是:

1
/users/1

这显然不是一个真实静态文件,那就交给:

1
public/index.php

然后 Laravel 自己的路由系统再接着处理。

这就是 Valet driver 的意义。

Nginx 并不知道 Laravel、WordPress、Statamic、普通 PHP 项目的入口规则有什么区别。Valet 通过 driver 把这些规则封装起来。

所以 Valet 支持多框架的本质不是 Nginx 有多聪明,而是:

1
2
server.php 统一接管请求
driver 负责识别项目类型和入口文件

静态文件为什么不是 PHP 直接读出来

Valet 处理静态文件还有一个有意思的细节。

在 driver 判断某个请求是静态文件后,它不是简单地在 PHP 里 readfile()

Valet driver 会通过一个内部跳转机制,让 Nginx 去返回真实文件。

也就是类似:

1
2
3
4
5
6
7
PHP 判断这是静态文件
|
v
告诉 Nginx 这个文件在哪里
|
v
Nginx 直接返回文件

这么做的好处是:

1
2
PHP 只负责判断逻辑
真正传输文件这种事情交给 Nginx

这也符合 Web Server 和应用层的分工。

Nginx 擅长处理静态文件,PHP 擅长处理动态逻辑。

PHP-FPM 在链路里的位置

再说一下 PHP-FPM。

Nginx 自己不会执行 PHP。

当请求需要执行 server.php 或项目的 public/index.php 时,Nginx 会把请求交给 PHP-FPM。

Valet 里常见的通信方式是 Unix socket,比如:

1
~/.config/valet/valet.sock

可以粗略理解成:

1
2
3
Nginx 是前台
PHP-FPM 是后厨
server.php 和 Laravel index.php 是菜单上的具体菜

Nginx 接到请求后,如果需要执行 PHP,就把请求交给 PHP-FPM。

PHP-FPM 执行完 PHP 脚本,再把响应交回给 Nginx,最后返回给浏览器。

这也解释了为什么有时候 Valet 域名能解析、Nginx 也启动了,但页面仍然打不开,可能是 PHP-FPM 没起来,或者 socket 路径不对。

本地 HTTPS 是怎么来的

现在再看 HTTPS。

问题是:

1
为什么 https://demo.test 能有小锁?

公网 HTTPS 的常见逻辑是:

1
2
3
4
浏览器内置信任一批根 CA
网站证书由这些 CA 或它们的中间 CA 签发
浏览器验证证书链
验证通过后显示可信

本地 HTTPS 没法直接去公网 CA 申请 demo.test 的证书,因为它不是公网真实站点。

Valet 的思路是:

1
2
3
自己创建一个本地 CA
把这个本地 CA 加到 macOS 系统信任
再用这个本地 CA 给 demo.test 签发证书

也就是:

1
2
3
4
5
6
7
Laravel Valet 本地 CA
|
v
demo.test 站点证书
|
v
浏览器验证到系统信任的本地 CA,于是显示可信

这里最关键的一句话是:

1
浏览器信任的不是 demo.test 这个域名本身,而是它的证书链能追溯到系统信任的 CA。

所以 Valet 本地 HTTPS 的本质是:

1
2
3
本地生成证书
本地信任证书颁发者
本地 Nginx 使用这张证书

这个信任只在你的机器上成立。

换一台电脑,如果没有信任你这台机器上的 Valet CA,demo.test 的证书就不会被认为可信。

valet secure 在源码里做了什么

源码里 HTTPS 的主线在 Site.php

valet secure demo 最终会走到类似 Site::secure() 的逻辑。

它的主流程可以整理成:

1
2
3
4
5
6
保留当前站点可能存在的 PHP 版本隔离配置
确保本地 CA 存在
删除旧的站点证书和旧 Nginx 配置
创建 demo.test 的站点证书
生成 HTTPS Nginx server 配置
写入 ~/.config/valet/Nginx/demo.test

里面又可以拆成两个层次。

第一层:创建本地 CA

createCa() 负责创建本地 CA。

它会生成类似这些文件:

1
2
~/.config/valet/CA/LaravelValetCASelfSigned.pem
~/.config/valet/CA/LaravelValetCASelfSigned.key

然后通过 macOS 的 security 命令把这个 CA 加入系统钥匙串。

也就是告诉系统:

1
这个本地 CA 我信任

这是小锁能出现的根本原因。

如果这一步失败,比如钥匙串没有信任成功,证书就算生成了,浏览器也可能不认。

第二层:给具体站点签证书

createCertificate() 负责给具体域名生成证书。

比如:

1
2
3
4
demo.test.key
demo.test.csr
demo.test.crt
demo.test.conf

这些文件放在:

1
~/.config/valet/Certificates

流程大概是:

1
2
3
生成私钥
生成证书签名请求
用本地 CA 签出站点证书

所以证书关系是:

1
2
3
4
CA 私钥 + CA 证书
|
v
签发 demo.test 证书

这和公网证书的逻辑是类似的,只是 CA 从公网机构换成了你本机的本地 CA。

secure 之后 Nginx 多了什么

只生成证书还不够。

Nginx 还得知道:

1
2
3
4
5
demo.test 用哪张证书
demo.test 用哪把私钥
443 端口怎么监听
HTTP 要不要跳 HTTPS
请求最终仍然转给哪个入口

Site::buildSecureNginxServer() 会读取 Valet 的 HTTPS Nginx 模板,然后把占位符替换成真实值。

大概会替换这些东西:

1
2
3
4
5
VALET_SITE  -> demo.test
VALET_CERT -> ~/.config/valet/Certificates/demo.test.crt
VALET_KEY -> ~/.config/valet/Certificates/demo.test.key
VALET_HOME_PATH
VALET_SERVER_PATH

所以 valet secure demo 之后,Nginx 站点配置就会知道:

1
2
3
server_name demo.test
ssl_certificate demo.test.crt
ssl_certificate_key demo.test.key

同时它还会保留 Valet 的基本请求分发逻辑:

1
2
3
4
5
6
7
8
9
10
HTTPS 请求进来
|
v
Nginx 完成 TLS
|
v
请求继续交给 Valet server.php
|
v
server.php 找项目和 driver

也就是说,HTTPS 只是入口层多了一次证书和 TLS 处理,后面的项目分发逻辑并没有变。

SNI 在这里扮演什么角色

这里稍微提一下 SNI,但不展开太远。

本机可能同时有很多 HTTPS 站点:

1
2
3
demo.test
blog.test
api.test

它们都走:

1
127.0.0.1:443

那 Nginx 怎么知道应该拿哪张证书?

客户端在 TLS 握手时会带上要访问的域名,这就是 SNI。

Nginx 根据这个域名匹配对应的 server block,然后选择对应证书。

所以访问:

1
https://demo.test

Nginx 会用 demo.test 的证书。

访问:

1
https://blog.test

Nginx 会用 blog.test 的证书。

这也是为什么 server_name 和证书配置要对应起来。

proxy 又是怎么回事

Valet 还有一个很实用的功能:

1
valet proxy api http://127.0.0.1:3000

这个功能不是 PHP 项目映射,而是 Nginx 反向代理。

它的意思是:

1
2
3
4
5
6
7
api.test
|
v
Nginx
|
v
http://127.0.0.1:3000

源码里也在 Site.php,对应 proxyCreate() 这类逻辑。

它会生成一份 Nginx proxy 配置,把 api.test 的流量转发到指定地址。

如果加上 secure,本质就是:

1
2
3
给 api.test 生成本地证书
Nginx 负责 HTTPS
后端仍然代理到 127.0.0.1:3000

所以 Valet 不只能服务 Laravel 或 PHP 项目。对于 Node、Go、Docker 暴露出来的本地端口,Valet 也可以提供一个统一的本地域名和本地 HTTPS 入口。

从源码角度再串一次

现在把源码文件和职责再汇总一下。

1
Configuration.php

负责 Valet 自己的配置目录和 config.json

1
2
3
tld
loopback
paths
1
DnsMasq.php

负责 .test 这类本地域名后缀:

1
2
/etc/resolver/test
dnsmasq address=/.test/127.0.0.1
1
Nginx.php

负责安装和写入 Valet 的 Nginx 配置:

1
2
3
nginx.conf
valet.conf
~/.config/valet/Nginx
1
Site.php

负责站点层面的事情:

1
2
3
4
5
6
link
secure
unsecure
proxy
证书路径
Nginx 站点配置生成
1
server.php

是每次请求真正进入的 Valet 分发入口:

1
2
3
4
5
解析 Host
找到 site path
选择 driver
判断静态文件
加载 front controller
1
ValetDriver.php

定义项目识别和入口判断的抽象:

1
2
3
serves()
isStaticFile()
frontControllerPath()
1
LaravelValetDriver.php

是 Laravel 项目的具体规则:

1
2
3
4
有 artisan
有 public/index.php
静态文件从 public 或 storage/app/public 找
动态请求交给 public/index.php

这几个文件串起来,就能看到 Valet 的整体设计。

排查问题时可以顺着这条链路看

理解了链路之后,排查也会清楚很多。

1. 域名有没有解析到本机

可以看:

1
cat /etc/resolver/test

以及:

1
cat ~/.config/valet/dnsmasq.d/tld-test.conf

如果这里不对,问题通常还没到 Nginx。

2. Valet 配置里有没有这个路径

可以看:

1
cat ~/.config/valet/config.json

如果是 park,看 paths 里有没有父目录。

如果是 link,看:

1
ls -l ~/.config/valet/Sites

3. Nginx 配置有没有生成

可以看:

1
ls ~/.config/valet/Nginx

如果是 secure 或 proxy 站点,这里一般会有对应配置。

4. 证书是否存在

可以看:

1
ls ~/.config/valet/Certificates

也可以检查本地 CA:

1
ls ~/.config/valet/CA

5. PHP-FPM 是否正常

如果 Nginx 能接请求,但 PHP 执行异常,就要看 PHP-FPM 和 socket。

Valet 的 Nginx 配置会把 PHP 请求交给类似:

1
~/.config/valet/valet.sock

如果这个 socket 不存在,或者 PHP-FPM 没启动,就会出问题。

最后总结一下

Valet 的原理可以压缩成一句话:

Valet 用 dnsmasq 解决本地域名,用 Nginx 接住请求,用 server.php 和 driver 找到真正项目入口,用 PHP-FPM 执行 PHP,再用本地 CA 和 Nginx 配置补上 HTTPS。

它的好用之处不是某个单点技术特别神奇,而是把一堆本来要手工处理的事情自动化了:

1
2
3
4
5
不用每个项目手写 hosts
不用每个项目手写 Nginx 配置
不用自己维护 PHP-FPM socket
不用自己生成和信任本地证书
不用每个项目单独起一套容器或虚拟机

如果只看表面,Valet 好像只是让 demo.test 能访问。

但从源码和链路看,它其实是给本地开发搭了一套很轻量的“入口网关”:

1
2
3
4
5
6
所有本地域名都先进本机
所有请求都先进 Nginx
所有动态判断都进 Valet server.php
所有框架差异都交给 driver
所有 PHP 执行都交给 PHP-FPM
所有 HTTPS 信任都交给本地 CA

理解这一点之后,再看 valet parkvalet linkvalet securevalet proxy,就不太像魔法了。

它们只是分别在这条链路上改了一小段配置。

参考

JPEG 是怎么压缩图片的?

JPEG 的核心目标是:

在人眼不太容易察觉的地方丢掉一些信息,从而大幅减小照片体积。

所以 JPEG 通常是 有损压缩。它不是简单地把文件“打包变小”,而是会真的改变图像数据。

可以把 JPEG 压缩理解成几个步骤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
RGB 图像

转换成 YCbCr

色度抽样

切成 8×8 小块

DCT 变换

量化

Zigzag 扫描

游程编码

熵编码

生成 JPEG 文件

1. RGB 转成 YCbCr

我们平时看到的图片通常可以理解成 RGB:

1
2
3
R = 红色
G = 绿色
B = 蓝色

但 JPEG 不直接用 RGB 压缩,而是会先转换成 YCbCr:

1
2
3
Y  = 亮度信息
Cb = 蓝色色度信息
Cr = 红色色度信息

这么做是因为人眼对 亮度变化 很敏感,但对 颜色细节 没那么敏感。

比如一张人脸照片:

1
2
轮廓、明暗、边缘变化很重要
颜色细节稍微模糊一点,人眼不一定明显察觉

所以 JPEG 会把图像拆成:

1
2
更重要的亮度通道 Y
没那么重要的颜色通道 Cb / Cr

后面压缩时,颜色通道可以压得更狠一些。


2. 色度抽样

既然人眼对颜色细节没那么敏感,JPEG 就可以减少颜色信息的采样数量。

常见色度抽样有:

1
2
3
4:4:4  不减少颜色信息
4:2:2 横向减少一半颜色信息
4:2:0 横向和纵向都减少颜色信息

最常见的是 4:2:0

它大概意思是:

1
2
亮度信息完整保留
颜色信息减少到原来的一部分

举个简化例子:

1
2
3
4
5
6
原始:
每个像素都有 Y、Cb、Cr

4:2:0 后:
每个像素仍然有 Y
但多个像素共用一组 Cb、Cr

这一步对照片很有效,因为自然照片里颜色通常是连续变化的。
但如果是文字截图、UI 图标、红蓝边缘特别明显的图,色度抽样可能会让边缘发糊、出现彩边。

所以:

1
2
照片适合 JPEG
截图、文字、Logo 不太适合 JPEG

这里再补充一个,就是4:2:0究竟是啥意思

YCbCr 是啥意思

YCbCr 会把一个颜色拆成三部分:

1
2
3
Y  = 亮度,也就是明暗
Cb = 蓝色色度差,大概表示“偏蓝还是不偏蓝”
Cr = 红色色度差,大概表示“偏红还是不偏红”

注意:Cb / Cr 不是直接等于蓝色和红色,而是 颜色差异信息

可以粗略理解为:

1
2
3
Y  管黑白明暗
Cb 管蓝黄方向的颜色变化
Cr 管红绿方向的颜色变化

所以一张 RGB 图片转成 YCbCr 后,可以想象成三张灰度图:

1
2
3
4
5
原图

Y 图:黑白版,保留轮廓、明暗、细节
Cb 图:颜色偏蓝 / 偏黄的信息
Cr 图:颜色偏红 / 偏绿的信息

其中最重要的是 Y 亮度图


为什么可以把亮度和颜色分开?

因为人眼看图时,最敏感的是亮度边缘,而不是颜色边缘。

比如一张白底黑字图片:

1
白底 + 黑字

你能很清楚看见文字,是因为亮度差异很大。

再比如一张人像照片:

1
2
3
4
5
脸部轮廓
鼻梁阴影
眼睛边缘
头发明暗
衣服褶皱

这些主要靠 Y 亮度信息 表达。

但颜色细节稍微粗糙一点,比如皮肤颜色、天空蓝色、墙面颜色,人的感知没那么敏锐。

所以 JPEG 的策略是:

1
2
Y 亮度:尽量保留清楚
Cb / Cr 颜色:可以减少一些

这就是色度抽样的基础。


4. RGB 转 YCbCr 是怎么转

近似公式是:

1
2
3
Y  = 0.299R + 0.587G + 0.114B
Cb = B - Y 的某种缩放
Cr = R - Y 的某种缩放

这里可以看到G是权重最大的,因为人眼对绿色最敏感,所以绿色对亮度感知贡献最大。

例如:

1
2
3
R = 200
G = 120
B = 80

那么亮度 Y 大概是:

1
2
3
Y = 0.299 × 200 + 0.587 × 120 + 0.114 × 80
≈ 59.8 + 70.4 + 9.1
≈ 139.3

所以这个像素的亮度大概是 139。

然后 Cb / Cr 负责记录它相对于这个亮度来说,颜色偏向哪里。

简单理解:

1
2
Y 记录:这个点有多亮
Cb/Cr 记录:在这个亮度基础上,它是什么颜色

刚才说的 4:4:4、4:2:2、4:2:0 是啥意思

色度通道 Cb / Cr 相对于亮度通道 Y,被保留了多少采样点。

先看一个 2×2 像素块。

原始情况下,每个像素都有:

1
Y + Cb + Cr

也就是:

1
2
3
4
像素 A: Y1 Cb1 Cr1
像素 B: Y2 Cb2 Cr2
像素 C: Y3 Cb3 Cr3
像素 D: Y4 Cb4 Cr4

6. 4:4:4:完全不减少颜色信息

4:4:4 表示:

1
2
每个像素都有自己的 Y
每个像素也都有自己的 Cb / Cr

用 2×2 看:

1
2
3
4
5
6
7
8
9
10
11
Y:
Y1 Y2
Y3 Y4

Cb:
Cb1 Cb2
Cb3 Cb4

Cr:
Cr1 Cr2
Cr3 Cr4

也就是亮度和颜色分辨率一样高。

所以:

1
4:4:4 = 不做色度抽样

质量最好,体积也更大。


7. 4:2:2:横向减少颜色信息

4:2:2 表示:

水平方向的颜色采样减半,垂直方向不减。

可以理解成:横向相邻两个像素共用一组 Cb / Cr。

比如 2×2 像素:

1
2
3
4
5
6
7
8
9
10
11
Y:
Y1 Y2
Y3 Y4

Cb:
Cb1 Cb1
Cb2 Cb2

Cr:
Cr1 Cr1
Cr2 Cr2

也就是:

1
2
第一行:两个像素共用一组颜色
第二行:两个像素共用一组颜色

亮度 Y 仍然每个像素都有。

所以:

1
4:2:2 = 横向颜色分辨率减半,纵向颜色分辨率不变

如果原来颜色采样是:

1
宽 1000,高 1000

4:2:2 的颜色通道大概变成:

1
宽 500,高 1000

8. 4:2:0:横向和纵向都减少颜色信息

4:2:0 最容易误解。它不是说“颜色没有了”,也不是说“第二行没有颜色”。

它的意思是:

色度信息在横向减半,纵向也减半。

也就是一个 2×2 像素块共用一组 Cb / Cr。

看 2×2:

1
2
3
4
5
6
7
8
9
10
11
Y:
Y1 Y2
Y3 Y4

Cb:
Cb1 Cb1
Cb1 Cb1

Cr:
Cr1 Cr1
Cr1 Cr1

也就是说:

1
2
4 个像素各自有自己的亮度 Y
但 4 个像素共用同一组颜色 Cb / Cr

所以颜色通道的分辨率变成原来的:

1
2
3
宽度 1/2
高度 1/2
总采样点 = 1/4

假设原图是:

1
1920 × 1080

Y 通道仍然是:

1
1920 × 1080

但 Cb 通道变成:

1
960 × 540

Cr 通道也变成:

1
960 × 540

这就是“横向纵向都减少颜色信息”。


这是一个前置的理解,那么关于图片格式,也想聊下怎么识别这些格式,光是后缀好像是可以被随意改动,比如这个jpeg的格式,那么我们可以从文件头来看

1
FF D8 FF E0 ... 4A 46 49 46

后面就代表的是 JFIF 这是文件头

我们也可以用一个小工具来识别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php

function detectImageType(string $path): string
{
$h = file_get_contents($path, false, null, 0, 32);

if ($h === false || $h === '') {
return 'unknown';
}

if (strncmp($h, "\xFF\xD8\xFF", 3) === 0) {
return 'image/jpeg';
}

if (strncmp($h, "\x89PNG\x0D\x0A\x1A\x0A", 8) === 0) {
return 'image/png';
}

if (strncmp($h, "GIF87a", 6) === 0 || strncmp($h, "GIF89a", 6) === 0) {
return 'image/gif';
}

if (strlen($h) >= 12 && substr($h, 0, 4) === "RIFF" && substr($h, 8, 4) === "WEBP") {
return 'image/webp';
}

if (strlen($h) >= 12 && substr($h, 4, 4) === "ftyp") {
$brands = substr($h, 8);

if (str_contains($brands, "avif")) {
return 'image/avif';
}

if (
str_contains($brands, "heic") ||
str_contains($brands, "heix") ||
str_contains($brands, "mif1")
) {
return 'image/heif';
}
}

return 'unknown';
}

echo detectImageType($argv[1] ?? '') . PHP_EOL;

这是通过文件头和brand来一起识别,brand是针对那些跟视频格式一致的可能包含容器的格式
比如HEIC / AVIF,对于JPEG、PNG、GIF、WebP这类常规格式,是一般从文件头识别
而后面的容器型则需要结合容器的brand标识,
比如一个AVIF的文件头是

1
00 00 00 20 66 74 79 70 61 76 69 66 00 00 00 00 61 76 69 66 6D 69 66 31

拆开以后就是

1
2
3
4
5
6
00 00 00 20 = box size
66 74 79 70 = ftyp
61 76 69 66 = avif
00 00 00 00 = minor_version
61 76 69 66 = avif
6D 69 66 31 = mif1

也就是

1
2
3
box type: ftyp
major_brand: avif
compatible_brands: avif, mif1

这样就大致知道了图片格式的识别

今天在尝试使用llama.cpp在mac下本地运行大模型的时候发现拉取llama.cpp的仓库还是比较慢,这点真的是挺难受,可能我们在玩一项技术的时候,有一大半时间都在克服这个问题,
这个方式也是谷歌搜到的,就是走 https://gh-proxy.com/ 转换镜像链接,
比如刚才的llama.cpp的仓库,https://github.com/ggml-org/llama.cpp.git 这个地址
通过 gh-proxy的转换就变成了

1
https://v4.gh-proxy.org/https://github.com/ggml-org/llama.cpp.git

但是呢有个问题,如果是走git clone我发现这个镜像地址也会是一开始比较快,后面就变慢直到速度很慢然后失败
所以另一个方法是直接用github的打包zip下载功能
一般是这种地址

1
https://github.com/ggml-org/llama.cpp/archive/refs/heads/master.zip

关键是它也可以走gh-proxy的中转,
转换之后就可以变成了

1
https://v4.gh-proxy.org/https://github.com/ggml-org/llama.cpp/archive/refs/heads/master.zip

这里的差别主要是git clone会按文件传,本身仓库会有很多小文件,所以通过压缩包的形式,相对来说通过分块传输会效率更高一些
至于今天本来想玩的本地模型,发现27B在mac上的确是不太跑得起来,甚至有点想买个512g或者256g的mac studio,不知道啥时候会出新款
苹果有没有可能财大气粗不受内存涨价的影响呢

上次我是实地使用llama.cpp跑了本地大模型,这种测试成本比较大,
刚好上次看到有个软件可以运行在本地来评估当前机器的性能可以跑什么样的大模型
它就是llmfit
在mac可以使用

1
brew install llmfit

windows可以

1
scoop install llmfit

安装好就可以直接运行了

可以看到有列了很多模型,包括参数量,token速度,占用磁盘大小,内存占用率,上下文大小等等
这样就很容易能看到我的电脑能跑什么样的模型,另外比较重要的是内存和硬盘的占用,
因为当我们真正使用的时候,除非这个电脑就被定位是只用来跑模型,不然我们一般也还会有同时在电脑上运行的程序
比如要一边写点文章,看会视频等,比如占用个50%的内存那差不多,如果要90%这种,显然是不太实际的
还有上下文长度,如果要运行对应的agent的话,还需要保障基础的上下文长度
它的原理主要是通过硬件驱动接口来识别当前设备的配置情况
比如n卡的话就是nvidia-smi,

1
2
3
4
5
6
llmfit在启动时使用sysinfo(用于RAM和CPU)和特定于供应商的工具组合读取你的系统规格:

NVIDIA:查询nvidia-smi,为多GPU设置聚合所有检测到的GPU的显存
AMD:通过rocm-smi检测
Intel Arc:从sysfs读取独立显存,通过lspci集成
Apple Silicon:通过system_profiler读取统一内存(显存 = 系统RAM,因为是共享池)

它还识别正在使用的加速后端——CUDA、Metal、ROCm、SYCL或CPU(ARM/x86)——,因为这直接影响速度估计。

除了购买各种订阅和api服务,还有一种选择就是本地运行模型,但是这个一般来讲还是只能运行一些参数量比较小的,
随着开源权重模型的发展,这个方向也在慢慢的改变,当然差距肯定是有的,只是本地我们可以用来做些辅助工作
之前很多情况下一般都只是能作为玩具,并且由于ollama和lm studio还是有一些性能损耗
之前有了解到llama.cpp这个开源项目,貌似很多lm studio等都是基于它构建的,那么直接用它是不是可以更充分的压榨我的渣渣显卡性能
首先可以在llama.cpp的github地址下载已经构建好的包
比如我是windows下,然后是在笔记本上的3060(6g)显卡,
cuda版本可以通过nvidia-smi查看,我的是12版本的

注意这里要下载两个包,一个是llama的主包,还有是后面跟着的[CUDA 12.4 DLLs]也是得下载的,不然会当成cpu模式在运行
后面的包解压后也放在前面主包解压的目录里,
这里主要是看下怎么设置参数,因为像之前说的,我有在本地运行hermes agent,它最低需要64k的上下文,那么我就是想试试哪个模型可以
看了下模型体积,只能上9B参数量左右的模型,否则都是直接爆显存
所以就试下qwen3.6 的 9B模型,可以在hugging face下载模型

1
.\llama-cli.exe -m F:\models\Qwen3.5-9B-Q4_K_M.gguf --ctx-size 65536 --flash-attn on --cache-type-k q4_0 --cache-type-v q4_0 --n-gpu-layers 28 --parallel 1

用的是 Q4_K_M 量化的,还是相对折中的,
--ctx-size 65536 代表上下文是64k,符合爱马仕的要求
--flash-attn on 是开启 Flash Attention,也是为了降低 attention 计算的显存占用
--cache-type-k q4_0 --cache-type-v q4_0 是设置了kv cache的量化大小,也是进行适当降低,防止显卡扛不住
--n-gpu-layers 26 是加载到显存26层,因为本身64k的上下文已经很占显存,所以只能加载26层,否则就会报显存oom这种
--parallel 1 表示并行度是1,因为我就单个会话使用
llama-cli.exellama-server.exe 的差别是你就在运行的窗口对话还是需要提供对话服务给Open WebUI、Cherry Studio 这些使用
当然也可以是爱马仕
直接cli运行的话,我测了下,能有6.6token/s, 勉强还可以使用的感觉,这样是占了5.2g的显存,并且如果模型用来做一些简单任务的话,还是比较可用的

0%