Deploy Certbot Using Docker Compose

服务器上,使用Docker Compose部署和管理各种服务,体验相当好。部署Certbot,申请和更新免费SSL证书,有些不同,记录一下相关操作。

首先,很多DNS服务商已经在提供DNS服务的同时,提供了免费SSL服务。如果这种服务能解决SSL免费证书的需求,就没必要部署Certbot了。例如Cloudflare、DNSPod等。

使用Docker Compose部署Certbot的特点:

  • Docker Compose部署和管理容器,比较清晰和方便。
    • 比如直接运行容器(即使用docker run命令运行容器),能记录容器的配置,方便查看和维护。
  • Certbot容器无需一直保持运行,只需启动一下,申请或续签SSL免费证书。
    • 只要得到SSL免费证书的文件,即可停止运行。
    • 需要设置定时运行,执行续签证书。
  • 需要使用SSL免费证书的服务(例如Nginx),需要把证书文件mount到对应的容器。

1. Docker Compose部署Certbot

创建好部署目录,以下例子采用/opt/docker/certbot

先创建Cloudflare的认证配置文件:/opt/docker/certbot/etc/letsencrypt/certbot-dns-cloudflare-credentials.ini。该文件格式如下:

dns_cloudflare_api_token=<your-cloudflare-api-token>

注意,/opt/docker/certbot/etc/letsencrypt最好设置拥有者为root用户,并保证只有root可读写,提高安全性。

sudo chown -R root:root /opt/docker/certbot/etc/letsencrypt

再创建docker-compose.yaml文件,该文件内容如下:

# https://hub.docker.com/r/certbot/dns-cloudflare
services:
  certbot:
    image: certbot/dns-cloudflare:latest
    volumes:
      - ./etc/letsencrypt:/etc/letsencrypt
      - ./log:/var/log/letsencrypt
    command: certonly --agree-tos --non-interactive -m xxx@abc.com --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/certbot-dns-cloudflare-credentials.ini -d example.com,*.example.com

networks:
  default:
    name: default-net
    external: true

注意:

  • 由于采用Cloudflare的DNS服务,所以采用dns-cloudflare的Docker镜像。
  • --agree-tos,同意ACME订阅协议(the ACME server’s Subscriber Agreement)
  • --non-interactive,非交互式运行,即运行过程中不需要询问用户输入。
    • 当客户端发现参数缺失时会给出相应的说明。
  • -m,即--email,设置域名所有者的EMail。
  • -d,设置SSL证书相关域名。
    • 多个域名可以设置多个-d实现。
    • 也可以1个-d设置多个域名,但域名之间需要英文逗号(,)分隔,并且主域名放第一位,子域名随后。
    • 支持泛域名,即*.+主域名,避免设置多个子域名。
  • 最后的networks可以不设。
    • 这里设置了默认加入网络default-net,方便通过网络访问其它容器。

最后启动。由于无需一直保持运行,所以不用添加-d参数。如果无错误输出,则成功运行。

docker compose up

2. 使用SSL证书

对于HTTP服务的容器,只需把SSL证书文件mount到相关容器即可。

比如Nginx,其docker-compose.yamlvolumes配置如下:

volumes:
  - ../certbot/etc/letsencrypt/archive:/etc/letsencrypt/archive:ro
  - ../certbot/etc/letsencrypt/live:/etc/letsencrypt/live:ro

对应网站的server配置SSL证书:

server {
  server_name example.com;
  listen 443 ssl;
  ...
  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
  ...

3. 自动续签

这里使用Systemd Timer实现定时自动renew证书。

创建服务管理单元,文件/opt/docker/certbot/deploy_conf/certbot-renew.service

[Unit]
Description=Certbot renew
Requires=network-online.target

[Service]
Type=oneshot
ExecStart=docker compose -f /opt/docker/certbot/docker-compose.yaml start 

[Install]
WantedBy=multi-user.target

创建定时器单元,文件/opt/docker/certbot/deploy_conf/certbot-renew.timer

[Unit]
Description=Auto run certbot renew
Requires=certbot-renew.service

[Timer]
# 周一和周四的凌晨3点执行
OnCalendar=Mon,Thu *-*-* 03:00:00
# 在5分钟以内,随机延迟执行
RandomizedDelaySec=5min
Unit=certbot-renew.service

[Install]
WantedBy=multi-user.target

部署:

sudo ln -s /opt/docker/certbot/deploy_conf/certbot-renew.service /etc/systemd/system/certbot-renew.service
sudo ln -s /opt/docker/certbot/deploy_conf/certbot-renew.timer /etc/systemd/system/certbot-renew.timer
sudo systemctl daemon-reload 
sudo systemctl enable certbot-renew.timer
sudo systemctl start certbot-renew.timer

注意:这里没实现自动续签成功后(即SSL证书文件更新后),自动更新Nginx。

Simplest HTTP Server for MicroPython

开发过一个项目,在刷了MicroPython的ESP32C3开发板上,运行一个HTTP服务和一个定时提交数据的自动任务。一开始,HTTP服务经常不定时宕掉。后来才发现,是内存不足。由于定时提价数据的自动任务,是通过HTTP协议POST数据,不能再精简。那么,只能对这个HTTP服务“开刀”。

这个HTTP服务,一开始是用TinyWeb开发,升级MicroPython v1.24.1后,改为使用microdot。两个框架的GitHub项目如下:

这些Web框架,功能比较全面,自然占用的内存就比较大。想要最大限度精简内存占用,最好的做法就是不使用Web框架开发,从底层实现HTTP服务。参考了以下代码:

最后编写出可以在MicroPython v1.24.1上运行的HTTP服务:

from micropython import const
import socket
import time
import logging

_logger = None
_RESP_STATUS = {
    200: 'HTTP/1.1 200 OK\n'
    ,302: 'HTTP/1.1 302 Found\n'
    ,400: 'HTTP/1.1 400 Bad Request\n'
    ,403: 'HTTP/1.1 403 Forbidden\n'
    ,500: 'HTTP/1.1 500 Internal Server Error\n'
}

def _getRequest(reqRaw: byte):
    '''获取整理后的请求数据
    
    Args:
        reqRaw (byte): 请求的原始字符串。
    
    Returns:
        dict: 整理后的请求dict对象。method:请求方法;path:请求Path;protocol:请求协议;form:POST的form数据。
	'''
    charset = 'utf-8'
    rArr = reqRaw.split(b'\r\n')
    r = str(rArr[0], charset).split()
    r = {'method':r[0].upper(), 'path':r[1], 'protocol':r[2]}
    if r['method'] == 'POST' and b'Content-Type: application/x-www-form-urlencoded' in rArr:
        r['form'] = dict(
            [tuple(item.split('=')) for item in str(rArr[-1], charset).split('&')]
        )
    return r

def _getPage(html: str, redirectUrl: str = '', redirectSecond: int = 0) -> str:
    '''生成HTML页面。主要是把传入的HTML数据,套上模板,或者实现自动跳转功能
	
    Args:
        html (str): HTML数据。
        redirectUrl (str): 跳转URL。
        redirectSecond (int): 等待指定秒数后自动跳转。

    Returns:
        str: 添加模板后的HTML页面
	'''
    redirectMeta = '' if len(redirectUrl) <= 0 else f'<meta http-equiv="Refresh" content="{redirectSecond}; URL={redirectUrl}" />'
    return f'<html><head><meta name="viewport" content="width=device-width,initial-scale=1">{redirectMeta}<title>Simple Web</title><link rel="icon" href="data:;base64,="></head><body>{html}</body></html>'

def _pageIndex(request):
    '''处理访问首页的请求。
	'''
    html = _getPage('''
<div style="text-align:center;">
<h1>Hello!</h1>
<p>Page index</p>
</div>
''')

    # Send response
    # Response header set boot status, for Shell script reading
    return html, 200

def _pageTips(request):
    '''处理提示页的请求,会在5秒后自动跳转首页。
	'''
    html = _getPage('<div style="text-align:center;">Forbidden.</div>', redirectUrl = 'index', redirectSecond = 3)
    return html, 200

# Start web server application
def run(logger: logging.Logger):
    '''启动HTTP服务

    Args:
        logger (logging.Logger): 传入logger对象。
    '''
    global _logger
    _logger = logger

    # Set up socket and start listening
    try:
        addr = socket.getaddrinfo(conf.WEB_HOST, conf.WEB_PORT)[0][-1]
        s = socket.socket()
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(addr)
        s.listen()
        _logger.info(f'Web server on {addr}')
    except Exception as e:
        _logger.error(f'Web server start failed: {e}')
        return

    # Main loop to listen for connections
    while True:
        try:
            conn, addr = s.accept()
            _logger.debug(f'Request from: {addr}')
            
            # Receive and parse the request
            reqRaw = conn.recv(1024)
            _logger.debug(f'Request content: {reqRaw}')
            req = _getRequest(reqRaw)
                       
            # Process the request
            if req['path'] in ['/','/index'] and req['method'] == "GET":
                respHtml, respStatus, respHeader = _pageIndex(req)
            elif req['path'] == '/tips' and req['method'] == "GET":
                respHtml, respStatus, respHeader = _pageTips(req)
            else:
                # Url path of request is not support
                _logger.debug(f"Path {req['path']}, 400")
                conn.send(_RESP_STATUS[400])
                conn.close()
                continue

            # Send the HTTP response and close the connection
            conn.send(_RESP_STATUS[respStatus])
            conn.send('Content-Type: text/html\n')
            if respHeader is not None:
                for k,v in respHeader.items():
                    conn.send(f'{k}: {v}\n')
            # Respone set no cache
            # Notice: Two newline characters ("\n") indicate the end of the header.
            conn.send('Cache-Control: no-cache\nExpires: 0\n\n')
            conn.write(respHtml)
        except Exception as e:
            _logger.error(f'WebServer Exception: {e}')
            try:
                conn.send(_RESP_STATUS[500])
            except:
                pass
        finally:
            try:
                if conn is not None:
                    conn.close()
            except:
                pass

注意:

  • 关于节省内存
    • 首先不要import没用到的库。甚至可以import指定库并用完后,执行del
    • 使用byte类型,代替str类型。
    • 适当的时候,手工清理内存,即执行gc.collect()
    • py文件编译为mpy,加载mpy文件更快更省内存。
  • 关于稳定性
    • 使用try ... except捕获所有异常,避免程序因异常而意外终止。

Use Systemd Timers Instead of Cron Jobs

某次逛论坛,发现使用Systemd管理的Linux发行版,应该使用Systemd Timers管理定时任务,并替代原来的Cron Jobs。

1. Systemd Units

Systemd Units(单元),是System的最小功能单位。其按功能划分为12种类型,例如target、service、timer等。“单元”之间互相依赖和调用,组成Systemd这个任务管理系统。

System Units存放路径:

  • /usr/lib/systemd/system,系统默认的单元文件
  • /etc/systemd/system,用户安装的软件的单元文件

注意:

  • Debian 12中,/usr/lib映射到/lib,所以/lib/systemd/system/usr/lib/systemd/system的文件相同。
  • Systemd读取Unit文件时,先读/usr/lib/systemd/system,再读/etc/systemd/system。所以修改原来的Unit文件,或者自定义的Unit文件,建议放在/etc/systemd/system
  • 编辑Unit文件,建议使用命令systemctl edit --full xxx.service,保存后会自动reload。

配置Systemd定时器时,需要创建两个Unit:

  • xxx.service,service类型,用于配置需要执行的任务。如果该任务已配置,则不用创建此文件。
  • xxx.timer,timer类型,对xxx.service执行定时操作。

2. 创建Systemd Service

对于Cron Job,会把定时执行的脚本或命令,与定时配置写在一起。配置是简单直接,但是不好管理。Systemd需要把定时执行的任务,配置为oneshot的Service。

例如创建文件/etc/systemd/system/sample-job.service

[Unit]
Description=sample job
Requires=network-online.target

[Service]
Type=oneshot
ExecStart=/bin/bash /path/to/sample-job.sh

[Install]
WantedBy=multi-user.target

注意:

  • Type=oneshot,表示只运行一次,不用长期运行。
  • Requires,配置了需要网络服务联网成功,才能执行。
  • WantedBy,配置了多用户命令状态时,才能执行。

3. 创建Systemd Timer

例如创建文件/etc/systemd/system/sample-job.timer

[Unit]
Description=Auto run sample job
Requires=sample-job.service

[Timer]
OnCalendar=Mon,Thu *-*-* 01:23:49
Unit=sample-job.service

[Install]
WantedBy=multi-user.target

注意:

  • Unit,配置对应的Service。
  • Requires,需要依赖的Service,可不配。
  • OnCalendar,按配置的日期时间进行定时运行。格式是DOW yyyy-MM-dd HH:mm:ss,其中DOW可以是Mon,Fri(周一和周五这2天)或者Mon..Fri(周一到周五这5天)
    • 详细参考:https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html#Calendar%20Events

4. 管理

# 启动定时器
sudo systemctl start sample-job.timer
# 停止定时器
sudo systemctl stop sample-job.timer
# 查看定时器状态
systemctl status sample-job.timer
# 列出正在运行的定时器
systemctl list-timers
# 设置开机启动
sudo systemctl enable sample-job.timer
# 取消开启启动
sudo systemctl disable sample-job.timer

参考

Detect the Current Public IP

宽带运营商,有的会提供动态公网IP(Dynamic Public IP Address)。用户可以通过DDNS(Dynamic DNS)绑定域名后,就能远程访问自家网络了。这个方案有个最重要的前提,就是要知道当前的公网IP,然后才可以进行DDNS绑定。

以下总结几个检测并获取当前公网IP的方法。另外,为了方便使用,所获取的公网IP地址,都是文本。

1. 请求第三方网站

网站服务基于TCP/IP协议,能获取客户端的公网IP。只要在当前网络请求第三方网站,并且该网站返回客户端的公网IP,就是当前网络的公网IP了。这个方案最常用。

整理了一些网站,能返回当前网络的公网IP,而且返回的IP地址都是文本格式:

2. 自建网站

既然第三方网站能返回当前网络的公网IP,那么可以自己建一个这样网站或者接口。前提是,需要网站服务器,或者小小的VPS。

相关功能,只需要配置Nginx即可:

server {
  ...
  # 请求path为"/ip"的网址,则返回当前网络的公网IP
  location /ip {
    default_type text/plain;
    return 200 $remote_addr;
  }
}

3. Cloudflare pages

如果没有网站服务器(或者VPS),可以利用Cloudflare pages实现相关功能。

首先需要开通Cloudflare的pages功能,然后进入“Workers & Pages” -> “Create”按钮 -> 选“Workers” -> “Start from a template”点“Hello World” -> “worker.js”的代码改为如下 -> 点“Deploy”执行部署。

export default {
  async fetch(request) {
    return new Response(request.headers.get('CF-Connecting-IP'), {
      status: 200,
      headers: {"Content-Type": "text/plain;charset=UTF-8"},
    })
  }
};

4. 从路由器检测

一般家用路由器,能用于网络拨号,那就能检测宽带运营商所分配的动态公网IP。这个方法虽然效率最高,但不同路由器的获取方法不同,甚至不能获取。

这里记录一下K2P刷了官改ROM后,通过telnet执行命令,检测当前公网IP。其中192.168.0.1是路由器IP地址。

(sleep 1; echo "ip addr show dev pppoe-wan"; sleep 1; ) | telnet 192.168.0.1 2>/dev/null | grep -oP '(?<=inet\ )[0-9\.]+'

Jasmine Green Milk Tea

材料

  • 茉莉花茶叶,6g
  • 热开水,150~200ml,70~80°C
  • 纯牛奶,150~200ml
  • 糖,8g
  • 小料,珍珠、小芋圆、西米、寒天等

制作

  1. 制作茶汤。茶叶倒入热水,泡7分钟。过滤茶叶后,冷藏备用。
  2. 把纯牛奶倒入茉莉花茶,再倒入小料、糖,搅拌均匀,最后加入冰块即可享用。
  3. 糖的份量,建议从5g开始尝试,一般不要超过10g。

总结

  1. 制作茶汤时,热水的温度和浸泡时间是关键,重点是提高茶汤的浓度。但过高的温度或过长的时间,会导致苦涩味。
  2. 如果不知道选用哪个茉莉花茶,可以试试“中茶,猴王牌,茉莉花茶,特制一号”。拿来直接冲泡,也可以。

Migrating Blog From Typecho to Hugo

1. 概述

使用Typecho部署的Blog,由于设置了禁止评论,就想,运行一个PHP语言驱动的Blog还有什么意义?于是就想玩玩静态化的Blog。

1.1. Typecho的问题

  • Typecho项目几乎处于停更状态。
  • 需要运行PHP-FPM + MySQL数据库,对内存小的VPS不友好。
  • 部署相对麻烦。当然,内嵌Apache的Docker镜像会简单点,但资源占用没下降。

1.2. Hugo的概述

  • 生成的Blog静态文件,几乎可以到处运行。例如无需购买VPS的GitHub Pages、Cloudflare Pages等。
  • 入门相对不容易。部署过才发现,难的不是Hugo本身,而是选用的模板。

1.3. 需求

  • 采用Hugo生成静态化Blog。
  • 功能包括:以文章列表填充的首页、全站搜索、文章分类、按时间归档、Tag。
  • 不需要评论功能。

2. 迁移步骤

总体步骤如下:

  • Typecho修正文章(post)格式。
  • Typecho导出文章。
  • 安装Hugo。
  • 创建Blog。
  • 选择主题(Theme)。
  • 配置Blog。
  • 生成静态文件。
  • 部署Blog。

参考:

2.1. Typecho修正文章格式

  • 就是把文章都调整为Markdown格式。
  • 这步略过。主要修正历史文章的格式。

2.2. Typecho导出文章

导出前,先了解Hugo的文章格式:

  • 详见官方说明:Docs > Content management > Archetypes
  • Markdown格式的文章,分为“前置项”(front matter)和“正文”(markup)两部分。
  • 我采用toml作为前置项的格式,正文就是以Markdown格式编写的文章正文。
  • 前置项的slug,是文章标题转为小写后,以“-”替换所有空格和符号的字符串。后续用于生成文章URL。

所使用的导出插件的项目源码:typecho-export-hugo

安装:Typecho部署目录的usr/plugins目录下,创建目录Export2Hugo(注意名称不能改,大小写也不能变),再把该项目的源码放进去。

导出:登录Typecho后台,在“控制台”菜单下会有一个“导出至Hugo”菜单,点击后执行导出。

注意:

  • 部署Hugo的Blog后,需要考虑文章的网址(Post的URL)。Typecho一般采用文章ID(整数自增ID),但是Hugo采用文章ID需要手工维护,不好玩。我改为使用文章的英文标题。
  • 根据上面的需求,修改了一下文章的导出格式。即把Action.php修改为如下内容:
<?php

class Export2Hugo_Action extends Typecho_Widget implements Widget_Interface_Do
{
  /**
   * 导出文章
   *
   * @access public
   * @return void
   */
  public function doExport() {
    $db = Typecho_Db::get();
    $prefix = $db->getPrefix();

    $sql=<<<TEXT
  select u.screenName author,url authorUrl,title,type,text,created,c.status status,password,t2.category,t1.tags,slug from {$prefix}contents c
  left join
  (select cid,CONCAT('"',group_concat(m.name SEPARATOR '","'),'"') tags from {$prefix}metas m,{$prefix}relationships r where m.mid=r.mid and m.type='tag' group by cid ) t1
  on c.cid=t1.cid
  left join
  (select cid,CONCAT('"',GROUP_CONCAT(m.name SEPARATOR '","'),'"') category from {$prefix}metas m,{$prefix}relationships r where m.mid=r.mid and m.type='category' group by cid) t2
  on c.cid=t2.cid
  left join ( select uid, screenName ,url from {$prefix}users)  as u
  on c.authorId = u.uid
  where c.type in ('post', 'page')
TEXT;
    $contents = $db->fetchAll($db->query($sql));
    
    $dir = sys_get_temp_dir()."/Export2Hugo";
    if(file_exists($dir)) {
      exec("rm -rf $dir");
    }
    mkdir($dir);

    $contentDir = $dir."/content/";
    mkdir($contentDir);
    mkdir($contentDir."/posts");

    foreach($contents as $content) {
      $title = $content["title"];
      $categories = $content["category"];
      $tags = $content["tags"];
      $slug = str_replace(' ','-',trim(preg_replace('/[^0-9a-z_]+/', ' ', strtolower($title))));
      $time = date('Y-m-d H:i:s', $content["created"]);
      $text = str_replace("<!--markdown-->", "", $content["text"]);
      $text = str_replace(array("\r\n", "\r"), "\n", $text);
      $draft = $content["status"] !== "publish" || $content["password"] ? "true" : "false";
      $hugo = <<<TMP
+++
title = "$title"
categories = [ $categories ]
tags = [ $tags ]
draft = $draft
slug = "$slug"
date = "$time"
+++

$text
TMP;
      
      $filename = date('Y-m-d-', $content["created"]) .$slug .'.md';

      if($content["type"] === "post") {
        $filename = "posts/".$filename;
      }
      file_put_contents($contentDir.$filename, $hugo);
      echo $contentDir.$filename;
    }
  
    $filename = "hugo.".date('Y-m-d').".zip";
    $outputFile = $dir."/".$filename;
    exec("cd $dir && zip -q -r $outputFile content");
    
    header("Content-Type:application/zip");
    header("Content-Disposition: attachment; filename=$filename");
    header("Content-length: " . filesize($outputFile));
    header("Pragma: no-cache"); 
    header("Expires: 0"); 

    readfile($outputFile);
  }

  /**
   * 绑定动作
   *
   * @access public
   * @return void
   */
  public function action() {
    $this->widget('Widget_User')->pass('administrator');
    $this->on($this->request->is('export'))->doExport();
  }
}

2.3. 安装Hugo

2.3.1. 安装概述

Linux上安装Hugo,参考官方文档:Docs > Installation > Linux

Hugo的Github发布版本,一般分为3个:

  • hugo
    • 基础版。包含Hugo的基本功能。
  • hugo_extended
    • 扩展版。在基础版上,增加了两个功能:把图片转码为WebP,把Sass转为CSS。
  • hugo_extended_withdeploy
    • 扩展部署版。在扩展版上,增加了直接部署功能,支持Google Cloud Storage、AWS S3、Azure Storage等。

由于是Golang开发的,Hugo一般就只有一个可执行文件。但是,如果用到其它插件,可能还需要用到Git、Node.js等。

安装Hugo有多种方式:

  • 从Github项目发布下载所需版本:https://github.com/gohugoio/hugo/releases
  • 操作系统自带软件库中安装。例如Debian可使用apt install hugo命令安装
  • 使用Docker部署,可用镜像:https://hub.docker.com/r/hugomods/hugo

如果只是简单使用Hugo,没涉及其它插件,建议”从Github项目发布下载“。如果需要用到Git、Node.js等,建议使用已打包的Docker镜像部署。

注意,所选用的主题(Theme),也会对Hugo版本和第三方工具有要求。例如后文会提到使用Stack主题,该主题会要求使用hugo_extended版本。

2.3.2. Docker Compose安装

参考:

使用Dockcer Compose部署的docker-compose.yaml参考如下:

services:
  hugo:
    image: hugomods/hugo:base-non-root-0.145.0
    volumes:
      - ./src:/src
      - ./hugo_cache:/tmp/hugo_cache
    ports:
      - 1313:1313
    # hugomods notice: Since 0.136.2, both of server and hugo server bind 0.0.0.0 by default.
    command: server --watch --buildDrafts --disableFastRender
    working_dir: /opt/hugo_blog/myblog

注意:

  • 这里采用hugomods的Docker镜像,不是Hugo官方出品。
  • 通过Docker Compose部署的Hugo,建议放在开发环境或者线下。生产环境或线上,没必要采用这个。
  • 示例采用非root用户的Docker镜像,其镜像内的用户ID为1000,用户组也为1000,一般对应宿主机的第一个非root用户。用起来很方便。
  • docker-compose.yaml里配置的volumes,相关宿主机目录要设置用户ID1000可读写。参考命令:sudo chown -R 1000:1000 ./src/ ./hugo_cache/

2.4. 创建Blog

Hugo创建Blog的文件目录,并把导出的Blog文章文件复制进去。

Hugo创建Blog的命令如下:

# 确定Blog所在的目录,进入该目录
cd /opt/hugo_blog
# 创建Blog,其目录为myblog,该名称可以自定义
hugo new site myblog

创建成功后,Blog存放在/opt/hugo_blog/myblog目录,进入该目录可以看到Blog的目录结构。

目录结果说明,详见:Docs > Getting started > Directory structure

myblog/
 ├─ archetypes/    # 存放创建新文章的模板文件
 │  └─ default.md  # 新文章的默认模板
 ├─ assets/        # 存放通常通过asset pipeline传递的全局资源。包括图片、CSS、Sass、JavaScript和TypeScript等。
 ├─ content/       # 存放文章和自定义页面的文件。
 ├─ data/          # 存放包含增强内容、配置、本地化和导航的数据文件(JSON,TOML,YAML或XML)。
 ├─ i18n/          # 存放用于多语言站点的翻译表。
 ├─ layouts/       # 存放将内容、数据和资源转换为完整网站的模板文件。
 ├─ static/        # 存放的文件将在建立网站时复制到`public`目录。例如:favicon.ico、robots.txt 和验证网站所有权的文件。
 ├─ themes/        # 存放一个或多个主题,每个主题拥有自己的子目录。
 └─ hugo.toml      # Blog的配置文件

注:网站构建(build)后,会自动创建publicresources两个目录,详见后文。

进入content目录,建立两个目录,pageposts。把上一步导出的文章文件,复制到posts目录下。在page目录下存放自定义页面(其格式跟文章的文本格式类似)。

其中pageposts两个目录的名称可以自定义,并修改配置文件的对应配置项,详见后文。

2.5. 选择主题(Theme)

选择所需主题,并安装到所创建的Blog。官方提供的主题列表:Complete List | Hugo Themes

我选择了Stack主题,相关介绍参考:

选择该主题的理由:

  • 喜欢其选用颜色和页面布局设计。
  • 自带全文搜索功能。跟Fuse.js类似,是通过浏览器端js实现的。
  • 首页能显示按年份、分类、标签归档。
  • 支持暗黑(Dark)模式。

安装主题:

  • 参考官方文档:Installation | Getting Started | Stack
  • 我选择了官方不推荐的方式,从Github发布中下载最新版,并解压到Blog的themes目录下。
  • 主题所存放的目录名称,要记住,后面配置要用。比如我命名该目录为hugo-theme-stack。即相关主题文件放在themes/hugo-theme-stack目录。

2.6. 配置Blog

配置Blog是最麻烦的,除了需要了解Hugo的配置,还要学习Stack主题的配置。

参考:

但是Stack的主题配置,文档不够清晰,建议直接参考其代码的示例或默认配置:

配置有两种组织方式,详见:Docs > Configuration > Introduction

  • 全部写在hugo.toml文件。
  • 写在config/_default/目录下,可以分文件组织配置。

我结合Stack主题,整理出来的hugo.toml如下。除了开头的9个配置项,后面基本都是Stack主题的配置。

baseURL = 'https://myblog.xxx.xxx/' # Blog正式部署的网址
languageCode = 'zh-CN' # 语言编号
title = "My-Blog" # Blog标题
copyright = 'myblog. All Rights Reserved.' # 版权信息

defaultContentLanguage = "zh-cn" # 默认语言
hasCJKLanguage = true # 是否含有亚洲文字
enableEmoji = true # 启用Emoji
enableRobotsTXT = true # 启用robots.txt文件
theme = "hugo-theme-stack" # 设置选用的主题,值为theme目录下的对应主题目录名称

[params]
  mainSections = ["posts"]
  #favicon = "/favicon.png"
  [params.contact]
    email = 'myblog@xxx.com' # Blog拥有者的Email
  [params.footer]
    since = 2009 # 版权信息的开始年份
  [params.dateFormat]
    published = "2006-01-02" # 发布日期的日期格式
    lastUpdated = "2006-01-02 15:04 MST" # 最后修改日期的日期格式
  [params.sidebar]
    subtitle = "All About Me" # Blog的子标题
    avatar.enabled = false # 显示Blog拥有者的头像
  [params.comments]
    enabled = false # 启用评论
  [params.article]
    headingAnchor = true
    math = false
    readingTime = false # 文章显示阅读时间
    toc = true 
  [params.widgets] # 首页右侧的模型设置
    [[params.widgets.homepage]]
      type = "search"
    [[params.widgets.homepage]]
      type = "archives"
      [params.widgets.homepage.params]
        limit = 5
    [[params.widgets.homepage]]
      type = "categories"
      [params.widgets.homepage.params]
        limit = 100
    #[[params.widgets.homepage]]
    #  type = "toc"
    [[params.widgets.homepage]]
      type = "tag-cloud"
      [params.widgets.homepage.params]
        limit = 50

[menus] # 菜单设置
  [[menus.main]]
    identifier = 'home' # 菜单ID
    name = 'Home' # 菜单名称
    url = "/" # 菜单点击跳转的URL
    weight = 10 # 菜单位置权重,值越小,位置越靠前
    [menus.main.params]
      icon = "home" # 菜单图标,这里使用主题所提供的
  [[menus.main]]
    identifier = 'search'
    name = '搜索'
    url = "/page/search"
    weight = 20
    [menus.main.params]
      icon = "search"
  [[menus.main]]
    identifier = 'archives'
    name = '归档'
    url = "/page/archives"
    weight = 30
    [menus.main.params]
      icon = "archives"
  [[menus.main]]
    identifier = 'categories'
    name = '分类'
    url = "/page/categories"
    weight = 40
    [menus.main.params]
      icon = "categories"
  [[menus.main]]
    identifier = 'about'
    name = '关于'
    url = '/page/about'
    weight = 100
    [menus.main.params]
      icon = "link"
 
[pagination] # 分页设置
  disableAliases = false
  pagerSize = 10
  path = 'list'

[permalinks]
  [permalinks.page] # 自定义文章的URL
    posts = '/post/:year-:month-:day-:slug' # 带冒号的变量,可以在文章开头找到
  [permalinks.section]
    posts = '/post/'

#[services]
#  [services.googleAnalytics]
#    ID = 'G-PPPPPPPPP' # 设置GA帐号ID,用于启用GA功能

2.7. 生成静态文件

使用hugo server可以写完文章,立马预览效果。但是需要发布时,还是重新构建一下。

# 进入Blog目录
cd /opt/hugo_blog/myblog

# 删除已构建的静态文件
rm -rf ./public

# 执行构建
hugo

注意:

  • hugo命令和hugo build命令是一样的。
  • 构建前,删除已构建的静态文件,能避免出现静态文件没更新的问题。
  • 构建时,默认取当前目录作为Blog目录,也可以使用-s参数指定Blog目录。

2.8. 部署Blog

Blog构建成功后,所有静态文件保存在public目录。把该目录部署到正式网站即可。

官方文档已列出各种可部署环境和部署说明,详见:Docs > Host and deploy

如果使用Nginx部署,注意配置Gzip压缩和浏览器缓存。参考配置:

server {
  server_name myblog.xxx.xxx;
  listen 80; 
  listen [::]:80;

  rewrite ^(.*)$  https://$host$1 permanent;
}

server {
  server_name myblog.xxx.xxx;
  #listen [::]:443 ssl ipv6only=on;  
  listen 443 ssl;
  http2 on;

  # Hugo所生成Blog的静态文件,即public目录
  root /var/www/myblog/public;
  index index.html;

  ssl_certificate /etc/letsencrypt/live/myblog.xxx.xxx/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/myblog.xxx.xxx/privkey.pem;

  gzip              on; 
  gzip_vary         on; 
  gzip_comp_level   9;  
  gzip_min_length   1k; 
  gzip_buffers      16 8k; 
  gzip_types        application/atom+xml application/javascript application/json application/ld+json application/manifest+json application/rss+xml application/vnd.geo+json application/vnd.ms-fontobject application/x-font-ttf application/x-web-app-manifest+json application/xhtml+xml application/xml font/opentype image/bmp image/svg+xml image/x-icon text/cache-manifest text/css text/plain text/vcard text/vnd.rim.location.xloc text/vtt text/x-component text/x-cross-domain-policy;
  #gzip_http_version 1.1;
  #gzip_disable      "MSIE [1-6]\.";
  
  add_header X-Frame-Options SAMEORIGIN;
  add_header Content-Security-Policy "frame-ancestors 'self';";

  location / {
    try_files $uri $uri/index.html =404;

    etag off;

    if ($request_filename ~* ^.*?\.(ico|icon|gif|jpg|jpeg|png|webp)$) {
      access_log  off;
      expires     max;
    }
    if ($request_filename ~* ^.*?\.(css|js)$){
      access_log  off;
      expires     2h;
    }
    if ($request_filename ~* ^.*?\.(html|htm|json|xml)$){
      expires     2h;
    }
    add_header  Cache-Control public;
  }
}

3. 维护

3.1. 总体规划

  • 需要分开开发环境和生产环境。
    • 一般开发环境在线下,生产环境在线上。
  • 开发环境安装Hugo。
    • 创建Blog、选择主题、写文章、构建静态文件等。
  • 生产环境只存放Hugo构建后的静态文件,即public目录。

3.2. 新增文章

概述。

  • 定义好新文章的模板,方便新建文章时自动生成想要的内容格式。
  • 文章的模板,采用了一定的自动化(例如自动取当前日期的UTC时间)。由于新建文章的命令不能传入更多的参数(例如不能传入完整的标题),还是需要新建文章后再修改。
  • 文章的文件名,需要配合Blog的管理而决定。这里定的是文件创建日期 + 文章英文标题。

先新建文章的模板,文件为archetypes/post.md,内容如下。其中[params]下的项目,是自定义的。

+++
title = "{{ replace (substr .File.ContentBaseName 11) "-" " " | title }}" 
draft = false
date = "{{ now.UTC.Format "2006-01-02T15:04:05Z" }}" 
categories = [ "" ] 
tags = [  ]   
slug = "{{ substr .File.ContentBaseName 11 }}" 
[params]
  title-cn = ""
+++

再新建文章。后面新建文章都会按模板生成初始文件。new-post-title对应该文章的标题。

hugo new content posts/$(date +%Y-%m-%d)-new-post-title.md -k post

文章的模板,相关函数和方法,参考官方说明:

3.3. 重新构建

按前面提到的命令处理。

3.4. 重新发布

public目录上传到服务器即可。自建服务器的,可以使用rsync命令同步public目录。

My Family Journey Through Chengdu

过年前,一直计划着春节假期去个旅游。经过对多个目的地的人流、天气、抢票情况、交通等问题的考虑,最后选择了四川省成都市。能给孩子带来初次坐飞机和看雪景的体验。

1. 概述

成都的景点,其实挺多亮点:带有三国历史的武侯寺、著名商业步行街的宽窄巷和春熙路、满眼大熊猫的大熊猫繁育研究基地、4千多年历史的三星堆遗址、历史文化深厚的四川省博物馆、美丽的九寨沟、教科书上的都江堰、宏伟的乐山大佛、美丽但折磨的峨眉山等等。综合了历史文化、自然风光、国宝、特色美食、热闹商圈等要素。

但是,由于是春节假期,很多推荐的小店都每开,导致吃得不太好。晚上的居民区,基本都是大门紧闭,没体会到地方特色。

另外,推荐参考“小红书”的攻略,比较详细和实用(当然,还是建议参考)。有些景点(例如峨眉山)还有每天发布景点情况。

2. 交通

抵达成都,一般选择高铁和飞机。如果能订到特价机票(几乎与高铁票等价),肯定推荐推荐飞机。

成都市内一般推荐地铁。“支付宝”或“微信”能申请“成都公交卡”,票价打折,扫码即可进站和出站。

成都市外的景点,例如“大熊猫繁育研究基地”、“三星堆博物馆”等,有旅游专线(大巴车)直达,价格不贵。一般景点都有对应车站。

再远一点的景区,例如乐山、峨眉山等,最好是提前预定动车票。不过像峨眉山,动车也就1个小时,实在买不到坐票,站一下也可以。

3. 行程

兼顾体力与时间,确定行程如下:

  • 1月30日
    • 下午,飞往成都双流机场。
  • 1月31日
    • 上午,大熊猫繁育研究基地。
    • 下午,成都市博物馆。
  • 2月1日
    • 上午,人民公园、宽窄巷。
    • 下午,三星堆博物馆。
  • 2月2日
    • 上午,前往峨眉山。
    • 下午,游玩峨眉山。
  • 2月3日
    • 上午,睡到自然醒,再回成都市区。
    • 下午,芳草街与玉林路一带闲逛。
    • 晚上,前往成都双流机场,回程。

订票顺序是,先订景点票,再订交通票(飞机和高铁),最后订住宿(酒店或旅馆)。

比较遗憾的是,选了“成都市博物馆”,主要是想着在市中心,会比较方便。要选的话,还是“四川省博物馆”的内容比较丰富。

另外,考虑到峨眉山会比较累,所以放弃了只有一座大佛的乐山。在峨眉山与九寨沟之间,选择了前者,因为峨眉山会下雪,并且有雪景(适合没看过雪的娃)。

市区的住宿选在人民公园最南端,为了省钱,离地铁站比较远,不推荐。也因为春节,即使附近有个菜市场,也没多少餐饮店开门。

4. 景点

4.1. 成都大熊猫繁育研究基地

该园区的特点是,可以看到很多大熊猫,但也只有大熊猫,甚至会看到视觉疲劳。记得提前预约买票。

总体来看,有几个点是选对了:

  • 冬天上午参观,大熊猫都在户外活动,很活泼。
  • 上午8点,从西门进入,南门离开,避开大部分拥堵的人流。
  • 放弃去看“花花”,时间都留给其它熊猫。

详细攻略可以参考“小红书”攻略。个人比较随缘,熊猫看够了、走累了,就可以满足地离开。

注意的点:

  • 地铁站“军区总医院(熊猫基地西大门)”,B出口,接驳车(摆渡车)直达西大门,票价两元。
  • 地铁站“熊猫大道(熊猫基地南大门)”,A或D出口,接驳车(摆渡车)直达南大门。
  • 整个园区超大,如果不是心态随缘,一定要做好路线规划。
  • 想省钱,可以自备水和干粮。冬天可以带保温杯,园区免费提供热水。
  • 熊猫步行街有比较集中的餐饮店(价格还好),可以考虑安排午饭,不过位置几乎在园区中间。
  • 园区内的纪念品比较贵,款式超多,最好适可而止。因为整个成都都在卖类似的东西。
  • 出了南大门,找旅游专线,可直达春熙路。

4.2. 成都博物馆

由于其地理位置在市中心,就作为一个景点。对于游客,相比“四川省博物馆”,更推荐后者。“成都博物馆”的展览,主要针对成都市。虽然不收门票费,但需要提前预约。

另外,旁边有家“蕃坊酥小蒙牛肉饼”,其牛肉饼很推荐。

4.3. 天府广场

在成都市中心的“天府广场”,除了是个人流超大的地铁站,还是个很大的地下商场。其中有个“二次元”区域,孩子很喜欢。“天府广场”的地面,是个比较有历史气息的广场(空地)。

4.4. 人民公园

个人觉得人民公园最大特点是,里面开了家“鹤鸣茶社”。在里面悠闲喝茶、采耳,被认为是体验成都“慢生活”。

人民公园有两个纪念碑:“辛亥秋保路死事纪念碑”和“川军抗日阵亡将士纪念碑”。路过的话,值得停下脚步,瞻仰和默哀一下。

另外,人民公园有个奶茶摊,在小茶壶里放个干冰,模仿水烧开的样子,挺有意思。

4.5. 宽窄巷

两条古色古香的商业步行街。高情商的说法,这里浓缩了成都的特色,一次逛个够。缺点是太有名,导致人太多,商业化也太浓。记得十年前左右,刚好没什么人,古朴与休闲的气息,还特意在“星巴克”里喝个咖啡。现在就是走一下,打个卡而已。惊喜的是,“星巴克”还在,不过他家的咖啡,已经不是我的菜了。

4.6. 三星堆博物馆

三星堆遗址其实一直都在挖掘,其历史最早能追溯到公元前4500年左右。出土众多的青铜器,在公元前1600年左右(大概对应商朝初期)。由于2021年最新发掘成果陆续发布,导致“三星堆博物馆”火了一把。最震撼的是,一大堆用作祭祀的精美青铜器,包括大型青铜树、青铜立人等。建议参观前,先了解一下相关历史和介绍,看的时候主要感受一下实物。

我们在“宽窄巷”坐旅游专线直达,比较方便。博物馆场地较大,两个主要展厅也较大,需要安排好休息。

4.7. 峨眉山

这次旅程,最值得记录的是峨眉山。冬天的峨眉山,漫山遍野都是白皑皑的雪,树木枝头挂上冰花,非常漂亮,仿似《冰雪奇缘》的世界。看过雪景的我,也感受到震撼。虽然到达金顶没有放晴,但是漫天飞雪,给金色神像增添神秘感。暗自感叹,能上来烧个香的,都是虔诚的信徒。

简要的攻略:

  • 提前订好峨眉山门票。一般建议也订了上下山的旅游大巴、缆车。其中缆车票不坐可退。
  • 高铁站“峨眉山站”才是到峨眉山的。不要买错“峨眉站”的票。
  • 高铁站有摆渡车送去“报国寺”,免费的。“报国寺”是摆渡车与上下山旅游大巴的车站。
  • 住宿可以订“报国寺”到“景区路”一带的酒店或旅馆,强烈建议带温泉或泡澡的。下山回来,泡个澡,非常舒服。我们订的“竹间私汤民宿”,总体很好,就是稍微有点偏僻。
  • “景区路”一带很多餐饮店,离“报国寺”不远。
  • 上山前,除了在“景区路”吃饱喝足,还要买好水、干粮和装备。价格比山上便宜很多。
  • 装备推荐:
    • 不建议带登山杖,景区路买竹竿才2元/根。
    • 鞋子最好防水防滑。不防水不防滑,买防滑鞋套;防水不防滑,可考虑冰爪。最好是出发前准备好。
    • 外套最好放水溅。衣服不防水的,需要雨衣。建议不要打伞,双手忙不过来的。
  • 上山是从“报国寺”坐旅游大巴(约两小时)上去,再坐缆车(约3分钟)上山顶。但是山上人太多,会导致缆车排队两小时。
  • 下山看情况。上山人多的话,下山也会是人多。我们不想在风雪中排队,就直接步行下山,大概两小时,能体验更多的风景。
  • 如果预算充足,可以考虑山上住一晚,能看日落和日出。

4.8. 芳草街与玉林路

成都市区挺多可以逛的区域,选择芳草街与玉林路,一来是上机前闲逛一下,二来找找吃的。

路线大概是:芳草街地铁站,玉林西路,玉林中学,玉林综合市场,芳华街。其中玉林西路的店比较普通,芳华街的店比较个性,玉林综合市场也就是个菜市场。

5. 美食

由于比较随缘,也由于春节,加上广东人怕辣,没有深入四川美食。吃过的,都总结一下。

  • 蹄花
    • 清炖猪蹄。一般炖得很烂,但吃到的出品是,猪气味未能僻除,不能接受。另外,很多“老妈蹄花”店,会有小字前缀,需要注意是否去错了店。
  • 豆花
    • 咸口豆腐花。相比豆腐花,没那么嫩,感觉不算特别好吃。
  • 成都牛肉面
    • 面是圆形细条,牛肉是卤过的。跟家附近菜市场的出品一致,就是我经常吃的“成都牛肉面”了。
  • 抄手
    • 四川云吞。跟广东云吞一样,皮很薄,但包成UFO形状(大概吧)。一般汤里带点紫菜。吃的是不加辣的版本,可以。
  • 凉拌折耳根
    • 吃过几次都是没有腥味的,直到吃到腥味很重,才体会到抗拒折耳根的人的感受。
  • 麻辣兔头
    • 没吃。一来是泡在辣椒汤里,二来太硬核,整个兔头很完整,并且牙齿外露,看着也罢。
  • 冰粉
    • 因为在长沙吃过好吃的冰粉,就想着在原产地吃一下正宗的。有的店,应该是水加多了,口感比较稀,不喜欢。在玉林西路某店,吃到比较符合记忆中的口感。
  • 红糖糍粑
    • 软糯与甜,没毛病。
  • 麻辣火锅
    • 太辣,劝退。另外,在春熙路那边某店吃的,除了贵,牛肉还是“科技嫩牛肉”,留下污点。
  • 糖油果子
    • 甜品都是好吃的,而且串起来卖,吃着也方便。
  • 干噎酸奶
    • 像雪糕吧。不是成都特色,但是第一次在成都吃到。
  • 跷脚牛肉
    • 据说是乐山的美食,但在峨眉山吃的。简单概括的话,就是清汤牛杂。还行,但不惊艳。
  • 麻婆豆腐
    • 辣度被调低,吃起来跟家附件的店,味道一致。不知道是不是有专门的料理包或者调味包。
  • 峨眉山叶儿粑
    • 只吃到咸口版。糯米粉做皮,包着肉馅,再粑叶包着蒸熟。圆柱状,外形挺特别,味道一般。后来才知有甜口版,没吃到。
  • 香椿炒蛋
    • 人生第二次吃香椿,用来炒蛋确实更容易接受。不过香椿的特别香味,还是不太喜欢。
  • 峨眉山竹叶菜
    • 据说峨眉山开发旅游之前,当地人拿来喂猪,也叫“猪草”。厨师用蒜蓉炒,吃起来也算好吃。只是想着“猪草”这名字,心里有点不舒服。

Generate Coffee Roasting Curves Using Frappe Charts

用温度监测模块记录了咖啡烘焙过程中的咖啡豆温度数据,保存为CSV文件。根据该数据生成“烘焙曲线”和“升温曲线”,需要用Excel之类的表格处理软件生成对应的“折线图”,比较麻烦。然后写了个简单的网页,可以选择CSV文件,自动生成两个曲线图。生成图表的JavaScript库,采用了Frappe Charts。记录一下Frappe Charts的使用。

优点:

  1. 简单。根据官方示例代码,即可生成图表。
  2. 支持混合图表。通过多种图表同时显示,更好表达出数据的含义。
  3. 折线图(line chart)支持序列数据,并优化显示。包括:平滑曲线、优化X轴标签显示(避免显示全部而导致太密集)等。

缺点:

  1. 项目太久没更新(起码3年了),很多issue都没有处理。
  2. 扩展性差。几乎没有插入特殊处理代码的地方。
  3. 可生成Y轴标签(以虚线显示),但不能生成X轴标签。目前使用条形图(bar chart)实现X轴标签的效果(指定X轴标签上显示一条竖线)。
  4. 折线图(line chart)数据点的提示框(tooltip),不能根据不同的数据做显示定制。由于提示框是生成图表时,预先生成的,并且按数据顺序执行每个数据点的各组数据的处理。例如有3组数据,那么每次执行数据处理都记录其自增ID,再用该ID除以3求余数,就能确定当次所处理的是第几组数据,并做对应的处理。
  5. 折线图(line chart)不能设置缺失数据,也不能隐藏指定数据点。暂时无解决方案,只能用零值表示缺失数据。

Yashixiang Lemon Tea

某天脑里有坑,想自制“鸭屎香柠檬茶”。参照了视频 鸭屎香柠檬茶保姆级教程!赶紧码起来 做出来,味道不及预期。后来细看,才发现视频有bug。修正后,终于调出满意的味道。可是,夏天已经过去了[苦笑]……

材料

  • 鸭屎香茶叶,5g
  • 热开水,150g
  • 冰块,75g + 220g

    替代:冰水,75g + 220g

  • 冰糖浆,20g(按口味调整)

    替代:糖粉,20g

  • 广东香水柠檬,30g(按酸度调整)

    替代:冻干柠檬,3片,约5g

制作

  1. 制作鸭屎香茶汤。材料配比,茶叶:热开水:冰 = 1:30:15。水烧开后,倒入茶叶,焖泡10分钟。过滤茶叶后倒入75g的冰块中,做成茶汤备用。
  2. 制作柠檬汁。使用广东香水柠檬的话,柠檬切片后加少量冰块(大概30g),锤打出香味。使用冻干柠檬片的话,加40ml热水浸泡,约10分钟。
  3. 混合出品。柠檬汁里倒入冰糖浆(或糖粉)、茶汤、220g冰块,摇匀,然后享用。

总结

  • 使用“冻干柠檬片”代替“广东香水柠檬”,有柠檬酸味,但是少了柠檬香味。
  • 采用的冰糖浆或糖粉,主要是更易于低温溶解。其用量需要根据口味调整。
  • 没有冰块的话,冰水也行(也方便称取份量),缺点是不够冷。

Egg and Scallion Crepe

这个葱花鸡蛋煎饼,材料简单,容易做,适合作为早餐。

参考小高姐的视频:【小高姐】鸡蛋煎饼 三样食材的美味家常早餐饼

材料

这个份量能煎6个饼,适合两人吃。

  • 鸡蛋,2个
  • 面粉(中筋或高筋),35g
  • 水,125g
  • 盐,2g
  • 葱花,适量

做法

  1. 鸡蛋、面粉和水混合并搅匀。重点要把鸡蛋搅散。可以先把鸡蛋搅散再混合,或者混合后再搅。
  2. 放入盐和葱花,混合均匀。
  3. 不粘锅刷薄油,开小火(约100°C),倒入1/6的份量,煎1~2分钟,翻面再煎1分钟,即可出锅。

补充

  • 葱花增加香味,没有或者不喜的,可不放。
  • 可以作为卷饼的皮。