阅读视图

发现新文章,点击刷新页面。
🔲 ⭐

基于 QUIC 的代理软件:TUIC

QUIC 协议汲取了大量人们给 TCP 糊墙的经验教训,把连接结构优化到(目前来看)极致。但是现在市面上的代理工具还没有能完全利用 QUIC 特性的存在,所以我自己动手写了一个基于 QUIC 协议的新代理工具:TUIC

https://github.com/EAimTY/tuic

  • 1-RTT TCP 中继
  • 0-RTT UDP 中继,且 NAT 类型为 FullCone
  • 在用户空间的拥塞控制,也就是说可以在任何系统平台实现双向的 BBR
  • 两种 UDP 中继模式: native (原生 UDP 特性,数据仍被 TLS 加密)和 quic (100% 送达率,每个包单独单独作为一个 QUIC “流”,一个包的确认重传不会阻塞其它包)
  • 完全多路复用,服务器和客户端之间始终只需要一条 QUIC 连接,所有任务作为这个连接中的 “流” 进行传输(一个流的暂时阻塞不会影响其它流),所以除连接第一个中继任务外的其它任务都不需要经过 QUIC 握手和 TUIC 的鉴权
  • 网络切换时的会话平滑转移,例如在从 Wi-Fi 切换到移动数据时连接不会像 TCP 一样直接断开
  • 0-RTT 、与中继任务并行的鉴权
  • 支持 QUIC 的 0-RTT 握手(开启之后能达到 真・1-RTT TCP 和 0-RTT UDP ,但是就算不开启,多路复用的特性也能保证在绝大多数情况下 1-RTT 和 0-RTT )

TUIC 的设计介绍在仓库中 Design 一节有说明。TUIC 协议的详细内容在这里。简单来说,TUIC 的设计核心就是减少握手造成的网络往返时延( rtt ),毕竟对于网络程序这是最大的瓶颈。

对比其它使用 TCP 的代理工具( ss 、v2ray 、trojan ),TCP 握手慢,且不支持自定义拥塞控制,各工具对 UDP 的支持也各有问题。对比 Hysteria ,Hysteria 的 UDP 中继需要 1 rtt 的握手,且只支持一种 UDP 模式。

最后说说安全性和协议特征。TUIC 现在基于原生 QUIC ,不支持 obfs ,但 QUIC 连接本身就是 TLS 加密的,每个 QUIC 连接从外面看都是一样的。国内的各大厂也慢慢开始使用 QUIC 了,所以我觉得 QUIC 特征应该不是什么大问题。

🔲 ⭐

使用 teledustry,通过 Telegram bot 远程管理你的 Mindustry 游戏服务器

最近架了个 Mindustry 游戏服务器和朋友一起玩 PvP(然而没玩几天就弃坑跑去 MC 了),感觉不错,只是每次输命令和上传地图的时候都要 ssh/sftp 到服务器上有点不方便,所以就写了个 Telegram 机器人用来输命令和上传地图:

teledustry - Manage your Mindustry server through Telegram bot

我不会写 Java,所以没把这个 bot 写成 mod 的形式,而是直接把游戏服务器进程创建为子进程,然后读写子进程的 stdio。
这个 bot 用起来很简单,只要用

$ teledustry -t API_TOKEN -u YOUR_TELEGRAM_USERNAME SERVER_FILE.jar

就可以启动 Mindustry 服务器和 bot,然后去找 bot 聊天就能执行命令和上传地图了(记得用 /output 命令让 bot 把输出发到当前聊天里)。

🔲 ☆

Xuanwu - 在全角字符与半角字符间添加空格的命令行工具

Pangu 是一个有名的用来在全角字符与半角字符间添加空格的库,支持多种语言,其中 Node.js 版 pangu.js 可以作为命令行前端来使用。
但是由于是 Node.js,想要用 pangu 就需要装很多依赖,类 Unix 系统有包管理还好说,在 Windows 平台下装 node 只为了用 Pangu 实在是有点麻烦。

为了解决这个问题,我写了一个 Pangu 的命令行前端 Xuanwu,基于 pangu-rs,非常简单,只有六十行代码😂,还没有任何依赖。

需要用 Pangu 的时候,可以直接到 Release 里下载编译好的可执行文件(暂时没有 macos 的,darwin 交叉编译有点麻烦),比装 node + pangu.js 方便得多。

至于为什么叫 Xuanwu 这个名字,传说玄武大帝是盘古的儿子,这个工具基于 Pangu,那自然就要叫 Xuanwu 了😂

🔲 ⭐

用 Rust 写一个用于 OCR 的 Telegram Bot

最近一段时间在学 Rust,想写一些简单的小工具来巩固一下。之前用其它语言写过 Telegram bot,所以我就用 Rust 写 Telegram bot 吧。

Rust 相比于其它流行的语言网络上的资源比较少,中文内容更是寥寥无几。虽然我的 Rust 连入门都谈不上,代码里可能会有不少不合理的地方,但是还是想把过程记录一下,供他人参考,希望可以为 Rust 社区做一些微不足道的贡献,也算是抛砖引玉吧。

成品 bot 在 EAimTY/eaimty_bot。这是个用来练手的小项目,不止有 OCR 功能,还有一些其它的杂七杂八的功能。

设计逻辑

先来设计一下这个 OCR bot 的逻辑:

用户通过 /ocr 命令触发流程开始,bot 以一条带按钮的消息要求选择 OCR 的目标语言回复触发消息,在用户选择目标语言后显示选择的语言并提供“重新选择”选项,bot 接收用户回复的消息,如果收到的是图片就用 Tesseract 进行处理,最后发送 OCR 结果,整个流程结束。
在整个流程中需要确保只有触发流程开始的用户才可以通过点击按钮选择 OCR 目标语言,并且只接受触发流程开始的用户发送的图片。

整理一下,整个流程分为 3 个部分:/ocr 命令触发,处理用户点击按钮选择语言,处理用户发送的图片。
由于每个步骤之间都是独立的,因此需要引入 session 保存状态。

选择框架与库

用 Rust 实现的 Bot API 框架列表可以在 Telegram 官方的 Bot Code Examples#Rust 中找到。

其中的 carapax 基于另一个框架 tgbot ,是同一个作者的作品,加了一些杂七杂八的功能方便开箱即用。写这个简单的 OCR bot 用不到那么多组件,所以我直接用了 tgbot。

对于 OCR,最好的开源实现肯定是 Tesseract。Tesseract 在 Rust 下的 binding 有 tesseractleptess,我这里用的是 leptess。

编译 Bot 程序的机器上必须装 Tesseract、Leptonica 和 clang,否则会编译失败。运行 Bot 程序的机器上必须装 Tesseract(与编译的机器上必须是同一大版本) 和需要 OCR 的 Tesseract 数据包,比如 eng、jpn、chi_sim 和 chi_tra。

有了 Telegram Bot API 框架和 OCR 库,就可以开始动手了。

实现 session 存储

在写 bot handler 前,先要把 session 部分搓出来,后面才能用 session 保存 OCR 流程的状态。

定义支持的 OCR 语言

假设这个 OCR bot 支持 4 种语言:English、日本語、简体中文、繁體中文。对应的 Tesseract 的语言包名是 "eng"、"jpn"、"chi_sim"、"chi_tra"。为了把两者联系起来并且方便存储,定义一个枚举:

#[derive(Clone, Copy)]
enum Language {
    English,
    Japanese,
    SimplifiedChinese,
    TraditionalChinese,
}

impl Language {
    // 语言的数据包名称
    const ENG: &'static str = "eng";
    const JPN: &'static str = "jpn";
    const CHI_SIM: &'static str = "chi_sim";
    const CHI_TRA: &'static str = "chi_tra";

    fn from_tesseract_data_str(s: &str) -> Option<Self> {
        match s {
            Self::ENG => Some(Self::English),
            Self::JPN => Some(Self::Japanese),
            Self::CHI_SIM => Some(Self::SimplifiedChinese),
            Self::CHI_TRA => Some(Self::TraditionalChinese),
            _ => None,
        }
    }

    fn as_tesseract_data_str(&self) -> &'static str {
        match self {
            Self::English => Self::ENG,
            Self::Japanese => Self::JPN,
            Self::SimplifiedChinese => Self::CHI_SIM,
            Self::TraditionalChinese => Self::CHI_TRA,
        }
    }

    // 用来遍历所有支持语言的迭代器,之后生成语言选择键盘的时候会用到
    fn iter() -> impl Iterator<Item = Self> {
        [
            Self::English,
            Self::Japanese,
            Self::SimplifiedChinese,
            Self::TraditionalChinese,
        ]
        .into_iter()
    }
}

// 语言的显示名称
impl Display for Language {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        let lang_name = match self {
            Self::English => "English",
            Self::Japanese => "日本語",
            Self::SimplifiedChinese => "简体中文",
            Self::TraditionalChinese => "繁體中文",
        };

        write!(f, "{lang_name}")
    }
}

设计 session

怎么区分每个 OCR 流程的会话?最简单的就是用触发 “/ocr” 命令的 tg 消息的 id 了。用 [chat_id, message_id] 就可以保证会话标识的唯一性。

但是这里有个问题:用户点击语言选择按钮时,可以通过按钮所依附消息的 reply_to 属性得到最初触发 OCR 流程的命令信息的 id,也就是上面的 message_id。但是在后面用户回复要识别的图片时,bot 收到的消息的 reply_to 属性不是 message_id,而是要求用户选择语言的消息的 id,这里叫它 relay_id。想要通过 relay_id 得到 message_id,就需要某种映射:建立一个 HashMap<[i64; 2], i64>,其中 key 是 [chat_id, relay_id],value 是 message_id,这样就能解决这个问题了。

session 里需要存什么?首先是触发 “/ocr” 命令的 tg 用户 id,用来验证点击选择语言按钮和发送图片的的用户。然后是用户选择的目标语言,用户可能还没有选择语言,所以要用 Option 包起来抽象。

综上,session 的存储结构就出来了:

struct SessionPool {
    sessions: HashMap<[i64; 2], Session>,
    relay: HashMap<[i64; 2], i64>,
}

struct Session {
    user: i64,
    lang: Option<Language>,
}

垃圾回收

试想你的 bot 受到了洪泛,有一大堆触发但没有完成的 OCR 流程 session。虽然每条 session 需要的存储空间很小,但迟早还是会爆内存,所以肯定需要定时清理长时间没有完成的 session。
Session 加两个成员,顺便加上用来创建的关联函数:

struct Session {
    user: i64,
    lang: Option<Language>,

    // session 的创建时间,垃圾回收时,如果检查到这个 session 已经存在超过了设定的时间,就把它清理掉
    c_time: Instant, 

    // 用来存储在 `SessionPool` 的 `relay` 表中对应的记录的键名。垃圾回收时也同时要清除掉。在用户点击语言选择按钮前, bot 不知道自己发出的语言选择消息的 id,这时的值是 `None`。点击选择语言按钮后,值变成 `Some([chat_id, relay_id])`
    relay: Option<[i64; 2]>, 
}

impl Session {
    fn new(user_id: i64) -> Self {
        Self {
            user: user_id,
            lang: None,
            relay: None,
            c_time: Instant::now(),
        }
    }
}

这样就能写出垃圾回收方法:

impl SessionPool {
    fn collect_garbage(&mut self, lifetime: Duration) {
        self.sessions.retain(|_, Session { c_time, relay, .. }| {
            // 创建时间与现在的时间差超过 lifetime 的删掉,`relay` 表里的也别忘记删
            if c_time.elapsed() < lifetime {
                true
            } else {
                relay.map(|relay| self.relay.remove(&relay));
                false
            }
        });
    }
}

需要每隔一段时间就运行一次上面的垃圾回收方法。
bot handler 处理每个 update,以及垃圾回收都是并行的,所以要包成 Arc<Mutex<SessionPool>> 防止数据竞争。
写出SessionPool 的创建函数:

impl SessionPool {
    fn new() -> Arc<Mutex<Self>> {
        let pool = Arc::new(Mutex::new(Self {
            sessions: HashMap::new(),
            relay: HashMap::new(),
        }));

        let lifetime = Duration::from_secs(3600); // session 存在的最长时间
        let gc_period = Duration::from_secs(3); // session 垃圾回收的间隔

        let mut interval = time::interval(gc_period);

        let pool_for_gc = pool.clone();

        // spawn 出去一个 task,每隔 `gc_period` 时间运行一次垃圾回收
        tokio::spawn(async move {
            loop {
                interval.tick().await;

                let mut pool = pool_for_gc.lock().await;
                pool.collect_garbage(lifetime);
            }
        });

        pool
    }
}

需要注意,这里的 Mutex 不是必须要用 tokio 的异步版本,因为只有涉及到 I/O 操作的需要长时间等待才推荐用异步锁,对于其它情况,异步锁的 await 过程反而会增加开销。推荐用 parking_lot 库里的锁。

bot 与 handler

搭好框架

tgbot 这个框架用起来特别简单。
为了方便,错误处理用了 anyhow::Result

#[tokio::main]
async fn main() {
    let token = "TG_BOT_API_TOKEN"; // 这里是 bot 的 api token
    let api = Api::new(token).unwrap();
    LongPoll::new(api.clone(), Handler::new(api)).run().await;
}

#[derive(Clone)]
struct Handler {
    api: Arc<Api>,
    pool: Arc<Mutex<SessionPool>>,
}

impl Handler {
    fn new(api: Api) -> Self {
        Self {
            api: Arc::new(api),
            pool: SessionPool::new(),
        }
    }
}

impl UpdateHandler for Handler {
    type Future = BoxFuture<'static, ()>;

    fn handle(&self, update: Update) -> Self::Future {
        let cx = self.clone();

        Box::pin(async move {
            let res = match update.kind {
                UpdateKind::CallbackQuery(cb_query) => {
                    handle_ocr_callback_query(&cx, &cb_query).await
                }
                // bot 的命令也算一条 message,所以处理触发命令和用户回复的图片都在这个分支。从 `Message` 尝试转换成 `Command` 过程会消耗 `Message` 本身,所以在 `handle_ocr_message()` 处理完并且没有匹配到操作时再尝试转换成 `Command` 交给 `handle_ocr_command()`
                // 这里用了不稳定特性 `try block`,把两个 handler 的结果合并成一个 `Result<()>`。自己手写 match 也能达到一样的效果,只是写出来有点丑而已
                UpdateKind::Message(msg) => try {
                    if !handle_ocr_message(&cx, &msg).await? {
                        if let Ok(cmd) = Command::try_from(msg) {
                            handle_ocr_command(&cx, &cmd).await?
                        }
                    }
                }
                _ => Ok(()),
            };

            // 遇到错误时打印到 stderr
            if let Err(err) = res {
                eprintln!("{err}");
            }
        })
    }
}

// 处理收到的命令的 handler
async fn handle_ocr_command(cx: &Handler, cmd: &Command) -> Result<()> {
    todo!()
}

// 处理收到的消息附加键盘点击时的 callback query 的 handler
async fn handle_ocr_callback_query(cx: &Handler, cb_query: &CallbackQuery) -> Result<()> {
    todo!()
}

// 处理收到的所有消息的 handler,只有消息发送方用户对应了 session,并且消息是图片并回复了要求用户选择语言的消息,此时返回 `Ok(true)`,没有匹配成功的话返回 `Ok(false)`
async fn handle_ocr_message(cx: &Handler, msg: &Message) -> Result<bool> {
    todo!()
}

处理收到的命令

async fn handle_ocr_command(cx: &Handler, cmd: &Command) -> Result<()> {
    if cmd.get_name() == "/ocr" {
        let msg = cmd.get_message();

        if let Some(user_id) = msg.get_user_id() {
            let chat_id = msg.get_chat_id();
            let msg_id = msg.id;

            let mut pool = cx.pool.lock().await;
            let session = Session::new(user_id);
            pool.sessions.insert([chat_id, msg_id], session);

            let send_message = SendMessage::new(chat_id, "请选择 OCR 目标语言")
                .reply_markup(get_lang_select_keyboard())
                .reply_to_message_id(msg_id);

            drop(pool);

            cx.api.execute(send_message).await?;
        }
    }

    Ok(())
}

这里注意中间的 drop(pool)。在执行涉及耗时长 I/O 的异步操作前先把锁释放掉,否则在 bot 与 telegram 服务器通讯的整个过程里锁都是被独占并堵住的。

处理收到的消息附加键盘点击时的 callback query

下面可以看出 Rust 的解构能力和抽象能力非常强,而且是零成本的。

async fn handle_ocr_callback_query(cx: &Handler, cb_query: &CallbackQuery) -> Result<()> {
    if let CallbackQuery {
        id,
        from: user,
        message: Some(msg),
        data: Some(cb_data),
        ..
    } = cb_query
    {
        if let (Some(data), Some(cmd_msg)) = (parse_callback_data(cb_data), &msg.reply_to) {
            let cmd_msg_id = cmd_msg.id;
            let msg_id = msg.id;
            let chat_id = msg.get_chat_id();
            let user_id = user.id;

            let mut pool = cx.pool.lock().await;

            if let Some(session) = pool.sessions.get_mut(&[chat_id, cmd_msg_id]) {
                if session.user == user_id {
                    let edit_message = if let CallbackData::Select(lang) = data {
                        session.lang = Some(lang);
                        session.relay = Some([chat_id, msg_id]);
                        pool.relay.insert([chat_id, msg_id], cmd_msg_id);

                        EditMessageText::new(
                            chat_id,
                            msg_id,
                            format!("目标语言:{lang},请以需要识别的图片回复此条消息(以图片方式发送)"),
                        )
                        .reply_markup(get_lang_unselect_keyboard())
                    } else {
                        session.lang = None;

                        EditMessageText::new(chat_id, msg_id, "请选择 OCR 目标语言")
                            .reply_markup(get_lang_select_keyboard())
                    };

                    let answer_callback_query = AnswerCallbackQuery::new(id);

                    drop(pool);

                    tokio::try_join!(
                        cx.api.execute(edit_message),
                        cx.api.execute(answer_callback_query)
                    )?;
                } else {
                    drop(pool);

                    let answer_callback_query = AnswerCallbackQuery::new(id)
                        .text("不是命令触发者")
                        .show_alert(true);

                    cx.api.execute(answer_callback_query).await?;
                }
            } else {
                drop(pool);

                let answer_callback_query = AnswerCallbackQuery::new(id)
                    .text("找不到会话")
                    .show_alert(true);

                cx.api.execute(answer_callback_query).await?;
            }
        }
    }

    Ok(())
}

// 把收到操作抽象为一个枚举:选择语言和取消选择
enum CallbackData {
    Select(Language),
    Unselect,
}

// 解析 callback query 携带的字符串
fn parse_callback_data(data: &str) -> Option<CallbackData> {
    let mut data = data.split('-');

    if let (Some("ocr"), Some(target), None) = (data.next(), data.next(), data.next()) {
        if target == "unselect" {
            return Some(CallbackData::Unselect);
        } else if let Some(lang) = Language::from_tesseract_data_str(target) {
            return Some(CallbackData::Select(lang));
        }
    }

    None
}

// 生成选择语言列表按钮
fn get_lang_select_keyboard() -> InlineKeyboardMarkup {
    let vec = Language::iter()
        .map(|lang| {
            vec![InlineKeyboardButton::new(
                lang.to_string(),
                InlineKeyboardButtonKind::CallbackData(format!(
                    "ocr-{}",
                    lang.as_tesseract_data_str()
                )),
            )]
        })
        .collect();

    InlineKeyboardMarkup::from_vec(vec)
}

// 生成重新选择语言按钮
fn get_lang_unselect_keyboard() -> InlineKeyboardMarkup {
    let vec = vec![vec![InlineKeyboardButton::new(
        "重新选择",
        InlineKeyboardButtonKind::CallbackData(String::from("ocr-unselect")),
    )]];

    InlineKeyboardMarkup::from_vec(vec)
}

处理收到的消息

async fn handle_ocr_message(cx: &Handler, msg: &Message) -> Result<bool> {
    if let (MessageData::Photo { data, .. }, Some(user_id), Some(relay_msg)) =
        (&msg.data, msg.get_user_id(), msg.reply_to.as_ref())
    {
        let msg_id = msg.id;
        let chat_id = msg.get_chat_id();
        let relay_msg_id = relay_msg.id;

        let mut pool = cx.pool.lock().await;

        if let Some(cmd_msg_id) = pool.relay.get(&[chat_id, relay_msg_id]).copied() {
            if let Some(Session {
                user,
                lang: Some(lang),
                ..
            }) = pool.sessions.get(&[chat_id, cmd_msg_id])
            {
                if user_id == *user {
                    let lang = *lang;

                    pool.sessions.remove(&[chat_id, cmd_msg_id]);
                    pool.relay.remove(&[chat_id, relay_msg_id]);

                    drop(pool); // 及早释放掉锁,越早越好

                    let PhotoSize { file_id, .. } = unsafe {
                        data.iter()
                            .max_by(|a, b| (a.width, a.height).cmp(&(b.width, b.height)))
                            .unwrap_unchecked()
                    }; // 根据 telegram bot 的文档,返回的图片文件列表不可能为空,所以这里 unwrap 是安全的,可以直接用 unchecked 的 unwrap 减少开销

                    let get_file = GetFile::new(file_id);

                    if let File {
                        file_path: Some(path),
                        ..
                    } = cx.api.execute(get_file).await?
                    {
                        let mut stream = cx.api.download_file(path).await?;

                        let mut pic = Vec::new();

                        while let Some(chunk) = stream.next().await {
                            pic.put_slice(&chunk?);
                        }

                        let mut leptess = LepTess::new(None, lang.as_tesseract_data_str())?;
                        leptess.set_image_from_mem(&pic)?;
                        let res = leptess.get_utf8_text()?;

                        let send_message =
                            SendMessage::new(chat_id, res).reply_to_message_id(msg_id);

                        cx.api.execute(send_message).await?;
                    } else {
                        let send_message =
                            SendMessage::new(chat_id, "图片获取失败").reply_to_message_id(msg_id);

                        cx.api.execute(send_message).await?;
                    }

                    return Ok(true);
                }
            }
        }
    }

    Ok(false)
}

到此为止,我们用四百行代码就写出了一个安全、高性能的 OCR telegram bot。我把全部代码放到了 gist 一份:
https://gist.github.com/EAimTY/451f7d48ade325777303b7d062039eb9

🔲 ☆

牛年伊始,自我总结

又是整整一年过去,是时候该总结一下之前的一年了。

过去的一年我干了什么?无非只是吃饭、睡觉、摸鱼、应付考试而已。有时候心血来潮会学习或写一些新东西,可惜都是三天打鱼两天晒网,没有向哪个方向深入就停下了。深知自己没什么技术,可是却一直提不起干劲。

由于大流行,去年有近9个月的时间都是在家中度过的,经常熬夜,靠外卖度日,下半年回到学校后也是经常摸鱼。过了这样一年“懒散”的生活,再加上作息与饮食的不规律,我感觉自己变“老”了。这里的“老”指的并不是年龄的增长,而是指精神状态变差。我能实在地感觉到自己的思维貌似变慢了,敏锐不再,而且对思考新问题的兴趣也变低了。我之前经常嘲讽蔑视那些因循保守,不愿意接触新事物的“笼中人”。然而现在我觉得自己也在慢慢地成为他们其中的一员。

不知这种情况是由何导致的,但是看来今年必须要处理一下了。从高中后半段开始,我就感觉自己的精神状态一天比一天差,持续到现在,睡眠质量也不断下降,而且由于身体原因,我一直以来都很少运动。在高中前半段时我还很瘦,然而现在已经变成中等偏胖的体型了。这可能是甲减和睾酮低的症状?所以我准备从适量运动开始改善精神状态。当然,这些都是推测,但运动总归是利大于弊的。饮食和睡眠也要更规律一些,不能像之前一样经常熬夜到两三点或者一天只吃一顿饭。
还有就是尽量多做一些“正经事”。很多时候我就算觉得无聊,无事可做,也不愿意学或写点新东西。之后尽量督促自己多做更有意义的事吧。

本来准备1月初就写出这篇文章,但是由于期末考试与放寒假之后的习惯性懒散,现在才静下心来总结。发文励己,今年加油吧。

🔲 ⭐

请不要让你的博客仅存在于互联网档案馆

晚上睡不着,于是翻了翻博客里之前的那些评论,发现之前的评论者们所留下的网址,已经有一大半无法访问了...

我觉得这是一件很可悲的事。人们认真创造的网页、写成的文字已不再存在于它们最初产生的地方了。

互联网档案馆是什么?它是一个非营利组织,运营着一个“时光机”项目,存档它的爬虫所能收集到的所有开放网页。
也就是说,当你的博客仅存在于互联网档案馆时,你的博客本身,就已经不存在了。

土豆写过一篇文章:《为什么我要写独立域名的博客》,其中写到了他建立博客的原因。不论是不是“独立域名博客”,包括我在内的很多人应该都是抱着这种想法才开始写博客的。但是我更想讲的是“坚持下去”,不管内容多么简短、更新多么缓慢,也不要直接关掉网站,让博客无法访问。

在现在的环境下,能让大家看到微小的“你”的最佳方式就是通过高密度的信息载体──文字,也就是你的博客──不论是你自己通过web程序搭建的,还是建立在 wordpress.com 或是 GitHub Pages 之类这些已有平台上的。博客与社交平台不同,博客所记录并展现的更多地是你的想法。

博客是给大家看的,但同时也是留给你自己看的。就算访问量很少,甚至没有访客都没有关系,因为你自己也可以是访客。当你看到自己曾经写的文章时,也就看到了自己当初的想法。这种思维的碰撞会让人感到非常神奇,就像是真正的“时光机”一样,能让你感到你自己的变化。我觉得这才是博客的最大用途。

“EAimTY 的博客”的历史不是很长,从建立到现在只有短短不到5年时间。尽管如此,我每翻到曾经所写的文章时,都会感叹我自己想法的变化,而这些变化是在生活中难以察觉的。有时我会觉得自己曾写的文章幼稚,甚至会感到羞耻,这不就意味我有所提高吗?假如没有这种感觉,反而会说明自己没有长进。

我记得我博客文章的评论者们所留下的网址中有很多十分有趣的博客,各有各的特点,读起来也很开心,但是其中很多现在已经无法访问了。这很令人悲哀。

现在想要建立并运营一个博客的成本几乎为零,你可以找到免费的域名、免费的空间、免费的程序、免费的主题。唯一的成本可能就是时间──你需要花时间将自己的想法记录下来,但这绝对是很值的。

总之,就算是每年只更新一篇很短的文章,也请不要放弃你的博客,否则,你将会失去展示你有趣灵魂的机会,同时也会失去自己本应获得的成就感与快乐。

🔲 ☆

使用 Openbox 作为基底打造你自己的 Linux 桌面环境

这篇文章会教你如何使用 Openbox 作为基底打造你自己的 Linux 桌面环境。

很久没有写过这类教程了,如果发现文章中的问题或不足,可以在评论中告诉我😁

开始之前

Openbox 是什么?

对于能够找到这篇文章的你,这应该不是你想问的问题吧...

Openbox 是一个 WM(Window Manager/窗口管理器)用来显示并管理每个 GUI 程序的窗口。
经常被人们提到的 Gnome、KDE 之类的东西是 DE(Desktop Environment/桌面环境)。一个 DE 通常会包括一个 DM(Display Manager/显示管理器,通常用于用户登录并启动桌面环境)、一个 WM(用于显示窗口)、一个 compositor(或许可以翻译为“合成器”?用于渲染特效、透明效果等)和一大堆附加组件(如窗口列表、dock 栏、托盘)。
理论上讲,各个 DE 中的每个部分都可以被替换掉,例如用 LightDM 替换掉 Gnome 自带的 DM:GDM,或用 openbox 替换掉 XFCE 自带的 WM:xfwm。

为什么不用已有的桌面环境?

原因如下:

  • 打包好的桌面环境总会包含你不需要的组件。这就像你去饭店点了一份不要辣椒的菜,结果上的菜里还是有辣椒,老板说这辣椒是送你的,免费的──你觉得 compositor 影响性能,不想要它,但大多数桌面环境(就算是最小化安装的也一样)都会“免费”送你一个,虽说你可以禁用掉它,但是有你完全用不到的组件在你的系统里,还由于软件包依赖问题无法卸装,这还是挺恶心的。
  • 重量级的桌面环境总是会有类似于 Windows 注册表的东西,例如 dconf。一个文本配置文件就能解决的问题,非要搞得这么复杂。
  • 你很难搞清桌面环境中每一个组件、每一个软件包的用途,这导致出现 bug 时的问题排查与解决变得非常麻烦复杂。
  • 你就是喜欢折腾,你就是喜欢与众不同🥴

自己动手拼凑桌面环境就不会有这些问题。你喜欢 LXDE 的 Panel、Xfce 的终端、Gnome 的截图工具?没有任何问题,你可以你自己喜欢的所有组件放在一起。这就是不使用已有桌面环境的最大优点。

你的桌面环境是什么样的并不重要,重要的是它是否能让你用得顺手,符合你的习惯,提升你的效率。

为什么选用 Openbox?

Openbox 可以说是一个较为“传统”的窗口管理器。它并不支持 wayland,只能运行在传统的 X11 上,但这也保证了它的稳定性,并且对 N 卡有更好的支持。
另外,Openbox 在交互上偏向于使用鼠标。虽然你可以按照你的喜好设置一大堆快捷键,但如果你对键盘操作特别钟情,或是讨厌操作鼠标,可能 i3(X11)、sway(wayland)类的桌面更适合你。

相较于重量级的 DE,如 Gnome、KDE,Openbox 极其轻量,并且基本不存在什么依赖。
对于其他轻量级 WM,openbox 可以说是配置起来最简单的,并且有几乎是最完备的生态和社区。

目标与准则

这篇文章会以我的桌面环境的配置为例,详细地介绍如何拼凑一个桌面环境。我会详细介绍我自己的方案,但也会给出其它方案,你可以按照自己的喜好来选择。就算WM相同,两个人的桌面也可以从外观和使用上完全不同。

我自己的准则:

  • 选择稳定、不频繁更改功能、但仍在开发中的组件
  • 不使用已过时或已停止开发/支持的组件,也不使用有已过时依赖(如python2)的组件
  • 尽量使用图形库比较新的组件,也就是尽量不用使用了 GTK2/QT4 的组件
  • 效率优先,不在外观与特效上花太多心思,当然成品看起来也不能丑
  • 在日常使用上,能用 GUI 解决的操作,绝不用 terminal
  • 使用配置起来简单的组件,就是那些就算用直接改配置文件的方式更改设置也不会很难的组件
  • 用到的所有组件都可以透过 Arch 官方源与 AUR 安装,当然手动编译也不难

再次强调,你不需要完全照搬我的配置,我也不推荐你这么做,毕竟只有尝试过更多方案才能选择出最适合你的。这正是 Linux 的精髓。

配置过程

在这里,假设你的系统是 Arch(其他系统也大同小异,只不过可能需要手动编译一些组件),且系统中还没有安装桌面环境与 X Window System。

下面列出的程序全部都是没有过时或停止开发/支持并且依赖也没有过时的。

基础软件包:X Window System、Display Manager 和 Openbox

首先需要的是处在最底层的 X Window System。一般来说只需要 xorg-server 包和它的依赖。
另外,这里还需要安装你的显卡对应的 xf86-video 包,如 xf86-video-intel。如果你的机器同时有核显与独显,最好暂时只安装核显的包。

你还需要一个 DM 来启动 Openbox,我用的是 LightDM,当然还有其它选择,如 LXDM。顺便一提,如果你准备使用 LXDM 的话,要注意它有 GTK2 与 GTK3 两个版本,一般来说选择 GTK3 版本的比较好。
如果你和我一样选择了 LightDM,那么你还需要一个 Greeter 用来在 GUI 下登录。这里有很多选择,可以参考 Wiki 中的 Greeter 一节。我使用的是默认的 lightdm-gtk-greeter,主要原因是它可以使用与桌面环境相同的 GTK 主题来保持界面风格的一致。

当然,不用 DM 也是完全可以的。利用 Xinit 你可以在终端中启动 X Window System,并且可以通过切 tty 来实现多用户。

最后,别忘了安装 Openbox。这里最好顺便装上 xterm,因为刚刚安装好的 Openbox 的应用菜单默认是硬编码的,常用的终端只有 xterm 在菜单中,后面将你喜欢的 GUI 终端添加到 Openbox 应用菜单后你可以直接将 xterm 卸装掉。不装 xterm 也没有任何问题,只不过稍微麻烦一点,最初几步中你需要切换到其它 tty 来执行命令。

将你的 DM 设置为自启后,重启电脑,不出意外的话重启后你会进入 DM 的登录界面。登录后,你便可以进入 Openbox。

使桌面环境“可用”

进入一个未经配置的 Openbox,你只会看到黑色的背景,没有任何其它东西,鼠标右键会显示应用菜单。

这时你的桌面系统还远远达不到“可用”,至少还需要窗口列表和系统托盘。

窗口列表与系统托盘

这里有几种不同方案:

方案一:使用一个 Dock 栏和一个系统托盘

这种情况下,你没有窗口列表来显示已打开的窗口,所以需要一个可以显示并重新打开最小化后的窗口的 Dock 栏,这里列出 2 个可选项:

  • Cairo-Dock──可定制程度比较高,但我个人不太喜欢,因为它不是很轻量,特效太多,而且一些功能需要 compositor 来实现。在我的笔记本(只开了核显)上打开它后散热风扇竟然都会开始转...
  • Plank──非常轻量化,可定制性也不错,只是缺少工作区切换的功能,但可以通过设置 Openbox 快捷键或使用其它小组件实现

窗口列表实现了,但你还需要一个系统托盘,可以选择 Stalonetray,或 trayer-srg,或 taffybar

方案二:使用一个 Panel

这是我选择的方案。

Panel 一般会包含窗口列表、工作区切换器、系统托盘之类的东西。通常来说,有一个 Panel 已经足够让你的桌面环境“可用”了。
下面列出几个可选的 Panel:

  • tint2或许是最大名鼎鼎的独立 Panel,很轻量,也高度可定制化
  • LXPanel是LXDE桌面环境的默认 Panel,也没什么依赖,可以脱离 LXDE 而单独运行,轻量且定制性也不错,有 GTK2 和 GTK3 版本。在我这里 GTK2 版本可以完美运行,但 GTK3 版本系统托盘中的图标貌似有点 bug,不能实时更新。
  • lxqt-panel和 LXPanel 差不多,是 LXQt 桌面环境的默认 Panel。
  • Xfce Panel是我最终的选择(后面会讲到为什么),它是 xfce 桌面环境的默认 Panel,也没什么依赖可以独立运行。它足够简单,可以定制的部分也不少,并且有很多使用的插件。

方案三:使用一个 Panel 和一个 Dock 栏

通常 Panel 也可以作为 Dock 栏使用,但如果你很想再揉进去一个独立的 Dock 也没有问题。

决定是否将桌面作为一个目录

要不要在桌面上堆放文件?

需要在桌面上堆放文件

如果你喜欢平时把一些文件和目录直接放在桌面上以便操作,就遵照这个部分进行。
下面列出几种选择让你能够在桌面放置文件和目录:

  • PCManFM 是一个文件管理器,不过它有将桌面作为一个目录进行管理的功能。它有 GTK2、GTK3 和 QT 版。但要注意,PCManFM 与 LXPanel 共同依赖于 libfm 库,而 libfm 分为 GTK2 和 GTK3 版本且相互冲突。也就是说,如果你同时使用 LXPanel 与 PCManFM,那么两者必须是同一 GTK 版本的。PCManFM-GTK3 在我这里有拖动文件时不显示的 bug,而 GTK2 毕竟是 10 年前的老设计,个人感觉不太美观,所以最后弃用了这个方案。
  • xfdesktop 是 XFCE 用来管理桌面的程序,简单易用,但它依赖 Thunar 文件管理器,如果你本身就准备使用 Thunar 的话倒是没什么,但如果你不喜欢 Thunar,xfdesktop 可能不是个好的选择。
  • ROX 其实本身就可以认为是一个桌面环境了,不过它也可以用来管理桌面。我没有用过 ROX,所以就不做更多表述了。

一般来说,用户的“桌面”文件夹在 ~/Desktop,但修改起来也不难。我就是直接将我的 home 目录作为桌面目录的。

不需要在桌面上堆放文件

如果你为了为了整洁的外观或是其它原因不准备在桌面上放置文件,在这一步你最好开始编辑 Openbox 的右键菜单与壁纸。

编辑 Openbox 的右键菜单

ArchWiki 中有很详细的介绍,可以直接参照这里

设置壁纸

在桌面模式下运行的 PCManFM、xfdesktop 和 ROX 都支持设置壁纸,但在不用它们的情况下,Nitrogen 可以用来设置壁纸。

让程序自启动

只需要在 ~/.config/openbox/autostart~/.xprofile 加入需要自动执行的命令。两个文件的区别在于,~/.xprofile 中的命令是在 X Server 启动时执行的,而 ~/.config/openbox/autostart 中的内容是在 Openbox 启动后才执行的,一般来说两者区别不大。

一个例子:

xfdesktop &
xfce4-panel &
nm-applet &
optimus-manager-qt &
pasystray &
blueman-applet &
xfce4-power-manager &
fcitx5 &

“设置”程序

Openbox 有自己的设置程序 ObConf,需要单独安装。

lxappearance 可以用来设置 GTK 主题等一些选项,虽说从名字里看它是 LXDE 的组件,但它完全可以独立运行。推荐安装它的GTK3版本。

如果你的电脑同时有核显和独显,Arch 系可以用optimus-manager配合 GUI 前端 optimus-manager-qt 切换显卡,体验极佳。

一些重要的程序

到这里,你的 Openbox 已经达到“可用”状态了,但还需要一些如文件管理器、终端之类的程序。

文件管理器

有很多选择,之前提到的 PCManFM、Thunar 都是很不错的轻量级文件管理器,而且在网上随便查查就能找到不少其它的选择。

注意,大多数文件管理器会通过 gvfs 实现回收站和自动挂载可移动设备的功能,并且可能需要通过一个 daemon 进程监听。

我选择的是 Thunar。

终端

之前我们一直临时在用 xterm 作为终端。有很多更好的终端,我自己在用的是 Sakura,非常简洁轻量。xfce4-terminalTermite 也是不错的选择。

文本编辑器

同样有很多选择。单纯作为文本编辑器,mousepad 很不错。如果你喜欢 Windows 上 Notepad++ 的体验,notepadqq 几乎一模一样,只是现版本的行首缩进貌似有些 bug。
我用的是 Gvim,这个就不用多解释了。

用来锁定、退出、关机和重启的面板

我用的是 Oblogout

系统托盘中的东西

这里列出我使用的组件:

  • nm-applet 是 NetworkManager 的托盘程序,我还在用 NetworkManager 的原因就是其它网络管理程序没有什么好的 GUI──Wicd 因为使用 Python2 而被 pass 掉,netctl 也没有可以与 nm-applet 媲美的 GUI 前端。
  • blueman-applet 配合 blueman-manager 可以很方便地管理蓝牙。想要通过 PulseAudio 使用 APTX 或 LDAC 的话可以使用 pulseaudio-modules-bt,Arch 系可以直接通过 AUR 安装,同时要安装 libldac。
  • pasystray 是 PulseAudio 的托盘,功能非常强大,配合 pavucontrol,操作音频设备就非常容易了。
  • xfce4-power-manager 是 XFCE 管理电源和屏幕亮度的托盘程序,可以脱离 XFCE 独立安装运行。
  • optimus-manager-qt 之前提到过,是 optimus-manager 的 GUI 前端,用来切换显卡。

定义键盘快捷键

Openbox的键盘快捷键可以通过编辑 ~/.config/openbox/rc.xml 修改,当然也有 GUI 前端可以修改快捷键,但是我觉得没有必要,因为 rc.xml 修改起来非常简单。

可以参考 Arch Wiki 中 Openbox 词条的 Keybinds 章节进行配置。

下面列出我的 rc.xml 中的 <keyboard> 段,其中包含一些有用的小组件,下文会提到:

<keyboard>
    <!-- Oblogout 面板 -->
    <keybind key="C-A-Delete">
        <action name="Execute">
            <command>oblogout</command>
        </action>
    </keybind>

    <!-- 窗口与工作区相关 -->
    <keybind key="W-a">
        <action name="ToggleShowDesktop"/>
    </keybind>
    <keybind key="A-Left">
        <action name="PreviousWindow"/>
    </keybind>
    <keybind key="A-Right">
        <action name="NextWindow"/>
    </keybind>
    <keybind key="C-Left">
        <action name="DesktopLeft">
            <dialog>no</dialog>
            <wrap>no</wrap>
        </action>
    </keybind>
    <keybind key="C-Right">
        <action name="DesktopRight">
            <dialog>no</dialog>
            <wrap>no</wrap>
        </action>
    </keybind>
    <keybind key="C-A-Left">
        <action name="SendToDesktopLeft">
            <dialog>no</dialog>
            <wrap>no</wrap>
        </action>
    </keybind>
    <keybind key="C-A-Right">
        <action name="SendToDesktopRight">
            <dialog>no</dialog>
            <wrap>no</wrap>
        </action>
    </keybind>

    <!-- 用 pactl 控制音量 -->
    <keybind key="XF86AudioMute">
        <action name="Execute">
            <command>pactl set-sink-mute 0 toggle</command>
        </action>
    </keybind>
    <keybind key="XF86AudioRaiseVolume">
        <action name="Execute">
            <command>pactl set-sink-volume @DEFAULT_SINK@ +5%</command>
        </action>
    </keybind>
    <keybind key="XF86AudioLowerVolume">
        <action name="Execute">
            <command>pactl set-sink-volume @DEFAULT_SINK@ -5%</command>
        </action>
    </keybind>

    <!-- 用 playerctl 控制上一曲、下一曲、播放与暂停 -->
    <keybind key="XF86AudioPrev">
        <action name="Execute">
            <command>playerctl next</command>
        </action>
    </keybind>
    <keybind key="XF86AudioPlay">
        <action name="Execute">
            <command>playerctl play-pause</command>
        </action>
    </keybind>
    <keybind key="XF86AudioNext">
        <action name="Execute">
            <command>playerctl previous</command>
        </action>
    </keybind>

    <!-- 用 escrotum 截图 -->
    <keybind key="Print">
        <action name="Execute">
            <command>escrotum -C</command>
        </action>
    </keybind>
    <keybind key="C-Print">
        <action name="Execute">
            <command>escrotum -Cs</command>
        </action>
    </keybind>
    <keybind key="S-Print">
        <action name="Execute">
            <command>escrotum</command>
        </action>
    </keybind>
    <keybind key="C-S-Print">
        <action name="Execute">
            <command>escrotum -s</command>
        </action>
    </keybind>
</keyboard>

一些好用的程序与小工具

  • escrotum 是一个非常轻量的截图工具。
  • DeaDBeeF 神似 foobar2000,很轻量好用的音乐播放器,可惜用的不是 MPRIS D-Bus Interface Specification 标准,不过可以在应用内设置快捷键达到一样的效果。
  • playerctl 可以控制使用了 MPRIS D-Bus Interface Specification 标准的播放器进行上一曲、下一曲、播放与暂停操作。
  • GPicView 是 LXDE 默认的图片查看器,轻量好用。有 GTK2 和 GTK3 版本,一般用 GTK3 版本就可以了。
  • Engrampa 是 MATE 默认的压缩文件管理器,个人认为 Linux 下最好的 GUI 压缩文件管理器。
  • Parcellite 是一个轻量的剪切板管理器,简单够用。

关于 compositor

首先你需要问问自己,你是否真的需要 compositor?
诚然,compositor 可以装饰窗口、实现透明效果,Compiz 这样重量级的 compositor 甚至可以实现很多炫酷的特效。但这值得吗?这些效果无法带来实质上的效率提升。compositor 在后台会占用很多 GPU 资源从而导致帧率的下降,如果你是游戏玩家的话这尤为关键:在我的电脑上,开启 compositor 后 CS:GO 的帧率会下降接近四分之一。
另外,没有正确配置的 compositor 可能会导致令人很不舒服的画面撕裂等问题。

如果你一定要使用 compositor,我推荐以下两个:

  • picom 是已经停止开发的 Compton 的后继开发产物,简单轻量,适合不需要太多装饰与特效的人。
  • Compiz 是最有名的重量级 compositor,它甚至可以单独作为一个 WM 使用。能实现非常多效果,并且有完备的生态与社区。

对触控板的调校

可以参考我的另一篇文章《对 Linux 下触控板按键、加速和手势的优化(libinput)》

写作初衷

去年我买了一台游戏本作为主力 PC,当时觉得,既然是游戏本,那用 Windows 应该是理所当然的,就把我的整个工作环境迁移到了 Windows 上。用了大概半年,我彻底受够了 Windows:催命般的 Windows Update、相当于没有的包管理...最恶心的还是注册表,这东西简直就是人类科技的倒退。

今年一月,我切换到了最近大红大紫的 Manjaro,具体版本是 Manjaro XFCE Edition Minimal。
这一用就是半年,我最终还是决定换掉它,原因就是:它注重“傻瓜化”。但我不是傻瓜啊...Manjaro 为了达到“开箱即用”的状态集成了太多没用的组件,就算是 Minimal 版本也是如此。例如,yay 已经够好了,然而 Manjaro 还是要再带一个 pamac,虽然说 pamac 的重点在于它的 GUI,但市面上 pacman 和 AUR Helper 的 GUI 已经很多了,使用体验不错的也有不少。另外,不知为何,Manjaro 经常会在更新时搞坏一些东西,默认的 theme 就在某次大版本更新时坏掉过,导致很多 GTK 程序变得辣眼睛;Optimus Manager 也在某次更新过后出过问题,需要重启 2 次 DE 才能成功切换显卡。每次处理这类问题都十分麻烦耗时,这也是庞大臃肿系统桌面环境的通病。
我对 Manjaro 的最终印象是:适合小白与非常懒的用户(连手动配置一次环境都懒得做的那类人),且由于 Manjaro 基于 Arch,有 AUR 这样一个方便的软件包来源,几乎没有手动编译软件的需要。

最终我还是换回了 Arch,自己动手拼凑了桌面环境。
感觉在中文互联网上关于自己拼凑 Linux 桌面环境的文章少之又少,于是便有了写这篇文章的想法。在鸽了 N 个星期后,终于写完并发布了这篇文章。

🔲 ⭐

为灵刃更换电池

在之前的文章《我的灵刃可能要返修了──为什么我对雷蛇又爱又恨?》中,我提到了我的这台机器出现的各种问题:A 面 Logo 不亮、屏幕漏光、性能衰弱过热降频...

又两个月过去了,这台笔记本又出现了一些奇奇怪怪的问题:

  • 触控板难以按下
  • 关闭独显,只通电牙膏厂核显时,在某些程序(例如 Telegram Desktop)中会出现渲染错误问题

我实在无法再忍,于是准备处理一下这些问题。

一打开 D 面后盖,我傻眼了:

这电池怎么鼓得像个馒头似的...🙃

从侧面看:

电池高出一大截...

这次算是搞清了病灶所在。这台机器出现的问题或多或少都与电池鼓包扯上了关系...
毕竟是“烧烤煎炸蛇”,机器内部温度达到五六十度并不少见。原本硬盘与机器的金属外壳之间有散热贴,但因为电池太高,外壳被撑起了不少,导致硬盘无法通过金属外壳散热,机器内部的高温加上硬盘读写时的发热,最终导致了 IO 问题。

这可能是性能衰弱的主因。另外,鼓起电池给了主板很大的压力可能也是导致某些问题的原因。触控板难以按下是因为电池太高,从底部撑起了触控板...

最有趣的是,这台电脑的续航貌似没有被电池鼓包所影响...可能是因为 tlp 太厉害了😂

于是,我联系了雷蛇的客服,但由于是保外维修,我被“天价”维修费吓了回来:

  • 送修来回的邮费需要我承担
  • 到达维修站并检测后即使不维修,我也要支付 120 元检测费
  • 电池售价 410 元
  • 人工费 350 元
  • 如果想修 LOGO 与略微漏光的屏幕,需要将 A/B 面整个换掉,而新的 A/B 面售价 2076 元,这还未包含人工费

也就是说,就算只换一块电池,走官方维修也需要花将近千元,而如果还要修 LOGO 与屏幕的话,四千都不一定能解决。
我这个穷学生怎么可能支付得起这天价维修费?四千元在现在都能买一台性能不错的红队架构的笔记本了🤦‍♂️

说实话,屏幕些许的漏光并不影响日常使用,毕竟是通病。虽然这台电脑 90% 的价格都花在了这 A 面 LOGO 上,但就算它不亮也不太会影响到正常的使用,不是吗🙉
于是我决定只换电池,在万能的马云家选了一家看起来比较靠谱的店,花 380 元买了一块“原装”电池。几天之后,电池就到了。

更换灵刃的电池非常简单,电池只有一根排线与主板连接,拧下螺丝、断开排线,电池很容易就能拆下。然而装上新电池后,出现了一个大问题:

有几个螺丝的孔位对不上!!!

🤦‍♂️🤦‍♂️🤦‍♂️

发消息给那家店的客服,结果客服半天都没理我...

算了,先只上 3 颗螺丝吧:

由于旧的散热贴与外壳之间的那一面已经积灰严重,我从柜子里翻了半天,找出了这条散热贴:

这是 3 年前我折腾的 X58 架构服务器(具体记录在《用 900 元组装一台 CPU 算力(较)强劲的工作站》)留下的为数不多的“遗物”,想不到它终于熬到了出头之日,能发挥余热了🥴

在发热最严重的几个位置贴上了散热贴,使热量能更高效地通过金属外壳发散:

装好后盖,开机,电池信息没什么问题。跑了一些性能测试,这台机器在数据上已经接近当初刚购买时的水平了。只开核显跑了一遍续航测试,跑出了轻度使用 6.5 个小时的成绩。

爷青回...🤣

淘宝店客服也回复了我,让我把螺丝孔位的金属片弯折一下,这样就能对齐了。虽然有点将就,但总之是正常装上了...😑

对比一下新旧电池(上 - 旧,下 - 新):


(嗯...是比亚迪的电池?😂)

插曲:我的所有拆机工具都放在宿舍,这次拆机只能去再买一套螺丝刀了。然而,一分价钱一分货,我花 10 块钱买的螺丝刀彻彻底底就是垃圾。拧 D 面螺丝时,螺丝没拧下来,螺丝刀的头反而花了...这破螺丝刀还拧花了好几颗螺丝,最后实在是没办法,只好去修电脑的店里让人帮忙把螺丝顶部整个磨掉...🤬现在 D 面也只上了 3 颗螺丝,有好几个孔位里都留着没有头的螺丝😷

2021 年 4 月 25 日 更新

电池又鼓了…还好之前换的这块电池还有几天的保修,直接拿去换新了🤦

🔲 ☆

对未部署 SSL 的网站能否保证数据传输安全的探究

由于瘟疫的影响,我校从开学到现在一直在进行网上授课。

网上授课本身没有什么问题,然而有些平台,特别是那些杂七杂八“XX 慕课”平台,在拥有大量用户的情况下不知廉耻地对保护用户数据没有任何做为,比如拒不使用 HTTPS——甚至在用户登录流程中也是。
不过,假如有某种方式可以保证在没有 SSL 保护时用户数据的安全,就不能排除这些平台使用了这种方式保障安全,从“没有 SSL”批评这些平台的这个切入点也就不存在了。所以我决定试着探究一下“未部署 SSL 的网站能否保证数据传输安全”这个问题。

最近正好在写一个对安全要求比较高的小工具,当中涉及到了相关问题,所以写了这篇一半是笔记、一半是吐槽的文章。

通常,介绍 HTTPS 的文章大多是从“为什么用了 SSL 会安全”这个角度写的。我准备换个角度,写一写“为什么不用 SSL 会不安全”。

下面以用户登录为例,来探究无 SSL 安全传输数据的可能性。

明文传输数据的风险

当用户与服务器间的连接未被加密时,数据在传输途中经过的几乎所有设备都可以进行对其进行截取甚至修改。因此,在这种情况下最需要防范的就是重放攻击与中间人攻击。

重放攻击

爱丽丝给鲍勃写了一封信:亲爱的,我遇到了些麻烦,现在需要一千块钱,请寄给我,谢谢!
邮递员马洛里私自打开了这封信,把信的内容原封不动复印了一份留下。信件照常送到了鲍勃手中,鲍勃发了一封内有一千块钱的信件给爱丽丝,爱丽丝正常地收到了。
马洛里到处鬼混把工资花光了,于是他将之前复印的那封信交给了鲍勃,鲍勃依旧发了一封内有一千块钱的信件,但是这次钱落进了马洛里手里。

这就是重放攻击。攻击者抓取到了用户向服务器传输的登录凭证的数据包(不需要是明文),攻击者可以向服务器发送相同的数据包从而通过用户验证流程,侵入系统内。

HTTPS 连接天生免疫重放攻击,在每次连接握手的过程中,都会产生一串随机的加密密钥,用以加密这个连接会话中传输的数据。就算攻击者抓到了用户发送的登录凭证数据包,也不能用它通过用户验证流程,因为每个连接的密钥都是不同的。

中间人攻击

爱丽丝给鲍勃写了一封信:亲爱的,我遇到了些麻烦,现在需要一千块钱,请寄给我,谢谢!
邮递员奥斯卡私自打开了这封信,将“一千块钱”改成了“两千块钱”。信件送到了鲍勃手中,鲍勃发了一封内有两千块钱的信件给爱丽丝,奥斯卡拿走了其中的一千块钱,把剩下钱的交给了爱丽丝。

这就是中间人攻击。中间人可以截获并修改客户端与服务器之间传输的数据,从而达到目的,侵入系统内。

HTTPS 连接可以在一定程度上防御中间人攻击,因为连接是非对称加密的。但是如果客户端设备上安装了“虚假的证书”,那么中间人攻击者就可以通过这个证书劫持并解密连接内容。
考虑到你国绝大多数人在计算机方面的知识水平,欺骗他们安装“虚假的证书”非常容易,毕竟他们用电脑从来不看提示信息,从来不看对话框的内容就点“下一步”。
连 HTTPS 的中间人攻击都这么容易实现,更何况是 HTTP。

下面来探究HTTP连接是否可以通过某些方式防御上面两种攻击。

客户端向服务器传输数据

如何使客户端加密的数据只能在对应的服务器上解密?

首先,对称加密不可能做到这一点,因为加密所用的密钥不论是客户端还是服务器生成的,都不可避免地需要明文传输。
因此我们很自然地会想到用诸如 RSA 之类的非对称加密算法来保护数据。

使用非对称加密

我们可以设计出类似如下的流程:

  1. 服务器收到用户端对登录页面的请求
  2. 服务器在返回登录页面的同时返回预先设置好的 RSA 公钥
  3. 用户输入登录信息后点击登录按钮,此时用 JS 将用户输入的内容用 RSA 公钥加密,之后 POST 给服务器
  4. 服务器收到 POST 后使用存储的 RSA 私钥解密数据,并与存储的信息进行比对
  5. 依据验证是否成功向用户端返回响应的页面并在 session 中记录

使用非对称加密是为了达到“只能用公钥加密”、“只能用私钥解密”的效果,对于用户端使用服务器发送的 RSA 公钥加密后的数据,除非持有对应的私钥,否则根本无法解密,因此中间人通过监听截获的只是一串无法解密的无意义乱码。

以上流程看起来是不是没有任何问题?很多登录系统采用的就是这种设计。

然而答案却是否定的。事实上,这种流程没有任何卵用。

对于重放攻击,这个 RSA 加密没有任何用处,它与明文传输唯一的区别仅在于攻击者不能得到明文密码,但攻击者仍然可以用抓到的数据包通过用户验证,从而侵入系统。

对于中间人攻击,这种流程也不会起到任何数据保护作用,可能会出现如下情况:

  1. 服务器收到用户端对登录页面的请求
  2. 服务器在返回登录页面的同时返回预先设置好的 RSA 公钥
  3. 中间人劫持服务器返回的数据,并将服务器返回的公钥替换为“虚假的公钥”
  4. 用户输入登录信息后点击登录按钮,此时用 JS 将用户输入的内容用“虚假的公钥”加密,之后 POST 给服务器
  5. 中间人劫持用户端发送的数据,用“虚假的私钥”解密用户发送的数据
  6. 用户的密码落到了中间人手中

这只是让攻击的流程多了几步而已,攻击者还是可以得到用户密码。
我们发现,只要有密钥传输的过程,中间人总可以“套”出明文密码。另外,只要传输的数据没有随机的因子,重放攻击就可以实现。

使用散列算法并加入一次性因子

发现这些问题后,下面我们可以有针对性地改善这些缺点,两个比较重要的点:

  • 传输时使用散列而非加密
  • 为散列使用一次性的盐

由此得到以下流程:

  1. 服务器收到用户端对登录页面的请求
  2. 服务器随机生成一串字符,并将其存储至当前 session 中
  3. 服务器在返回登录页面的同时返回这串字符
  4. 用户输入登录信息后点击登录按钮,此时用 JS 将这串字符作为盐,散列计算已处理过的用户输入的内容,之后 POST 给服务器
  5. 服务器收到 POST 后使用在 session 中存储的这串字符作为盐,散列计算保存的用户信息,并与收到的 POST 数据进行比对
  6. 依据验证是否成功向用户端返回响应的页面并在 session 中记录

这样,就算中间人修改了字符串并监听得到了用户 POST 的数据,他也无法得到明文的用户密码,毕竟加过盐的散列结果很难破解,只要你用的不是 MD5、SHA-1 之类的弱算法,并且进行了多次散列。
同样的,由于作为盐的字符串是一次性的,含有登录凭证的数据包被抓到也无法二次使用,所以重放攻击无法实现。
这么搞,数据是不是就安全了呢?

非也!

拥有用户与服务器之间路由的人能做的可远远不止上面那些,攻击者可以这样:

  1. 服务器收到用户端对登录页面的请求(同上)
  2. 服务器随机生成一串字符,并将其存储至当前 session 中(同上)
  3. 服务器在返回登录页面的同时返回这串字符(同上)
  4. 用户输入登录信息后点击登录按钮,此时用JS将这串字符作为盐,散列计算已处理过的用户输入的内容,之后POST给服务器(同上)
  5. 攻击者监听到了用户凭证数据包,保存后立即将其丢弃,服务器无法收到任何请求
  6. 攻击者伪造 session(非常容易,session 一般是通过 cookies、隐藏表单记录的,中间人可以轻松获取),然后重放这个包,由于服务器端仍在使用那串字符作为盐,所以攻击者可以通过用户验证从而进入系统

某堵墙可以丢掉你请求访问 Google 的数据包,攻击者凭什么不能丢掉你请求登录的数据包?

综上,可以看出,在没有 SSL 保护的情况下,保证客户端向服务器传输数据的安全非常非常难,这是否能说明没有 SSL 的话一切保护数据的努力都是徒劳?
先不急着得到结论,我们先讨论下一个部分。

服务器向客户端传输数据

通常情况下,登录各个网课系统后,页面上会显示出你的名字、学校、班级、学号之类的信息。
如果这些信息没有经过加密传输(很遗憾,很多网站的确没有对其加密传输),会是对用户隐私安全的毁灭性打击。因此我们需要向一些方法加密它们。
由于用户端是解密端,所以用非对称加密不合理。所以,需要设计一种能够保证密钥安全的对称加密流程。

我只能想到下面这一种实现方式:

  1. 用户在输入密码后,取散列后的密码的一部分(比如散列后得到一串 512 位的字符串,取其 16~32 位),再次 hash,可以多次重复这个过程,然后将结果存储于 HTML5 Web Storage 中
  2. 服务器端需要安全地向用户端传输数据时,取保存的用户登录凭证用相同的算法处理,作为密钥
  3. 用户端收到信息后调用存储于 HTML5 Web Storage 中的字符串进行解密

这里使用 HTML5 Web Storage 而非 Cookies 存储密钥是因为 HTML5 Web Storage 相对安全一些。Cookies 会在请求 header 中被明文传输,使用 Cookies 存储密钥和一叶障目差不多。
这是权衡后的实现,既能在一定程度上保证用户密码、密钥的安全性,也能保证密钥无需传输。

然而,就算能保证服务器向客户端传输数据的安全,还是会涉及到那个问题:

先有鸡,还是先有蛋?

客户端不向服务器传输数据,服务器向客户端传输什么数据?

由此,我们可以得出结论:

保证HTTP连接的数据传输安全性?无解。

从理论上讲,保证 HTTP 连接的数据传输安全是不可能的,以上一切的努力都是徒劳。
就算你找到了你认为更好的加密流程,只要不用 SSL,中间人永远都能获取他想要的信息。
你在 JS 里写了一大堆算法,中间人只需要插入一小段 JS,将用户输入的发送到攻击者的服务器,你的所有努力就都白费了。
你做的只是微微增加了破解难度,但根本无法阻止破解。

了解过 HTTPS 原理的朋友应该能看出,上面那些对数据传输安全的尝试几乎就是在自行实现一个简单 SSL 协议了。这可不是一个网站该干的事情,况且本身就是无法实现的。
HTTPS 的根基,是在客户端预装可信的根证书,这保证了用户与服务器之间的加密无需交换密钥。几段简简单单的 JS 怎么可能做到这点?

想自行实现 SSL?不是傻就是疯。都 2020 年了,为什么不乖乖上 SSL?更何况现在连免费的证书都出现了!

不用 SSL,就是对用户最大的不尊重。

最后的吐槽

令人无奈的现状

当年应该有很多人玩过“贴吧云签到”,还记得这些程序是如何实现百度帐号登录的吗?
没错,是 BDUSS 这个 cookie。
只要你拿到某个百度帐号的 BDUSS,你就可以非常轻松地登入这个帐号,甚至无需用户名与密码。

连百度这种体量的公司都不舍得用一次性 token,可见中国的企业在用户安全上做得有多差。

当年 CSDN 被脱裤、爆出明文存储用户密码后,我觉得应该不会再有大企业敢这么干了。毕竟 CSDN 仅仅是一群脑残用户自嗨的地方,影响面不会很大。

然而就在前几天,微博被曝脱裤了,这次的影响面可不小。还是同样的愚蠢问题,竟然被这些大公司一次次地重演,只能感叹可悲了。

出现这类事故,责任在谁身上?写代码的程序员?我觉得他们的失误并非这些事件最大的原因。

真正应该付全责的应该是这个大环境。

那些服务的用户们,他们不就是那群“每天没事就刷抖音的人”吗?他们不会关注、也不想关注自己的信息卖给了谁,他们也不会试图去搞清自己的信息有没有被加密传输。

写这些代码的程序员,无非是每天 996、每天忙着在完成任务的前提下抽空摸鱼的普通人。人们都想着在高强度工作下自保,用户信息的安全?屁!拿这些信息换 KPI 才是正确的选择!

人是有极限的,这些程序员在高强度工作下能写出真正的好代码吗?明显不能。他们只会碌碌无为地 Copying and Pasting from Stack Overflow,甚至有些人连 Stack Overflow 是什么都不知道,Copying and Pasting from CSDN。等到了 35 岁转行去送外卖。

可悲的是,由于这个国家劳动力的富足,以及大量“每天没事就刷抖音的人”涌入计算机行业,企业不会缺少为他们 996 的人。

他们就是韭菜,割了一茬,又长一茬。

我们能做些什么?

如果你认为“谁闲得无聊会去偷你的个人信息?”,不好意思,请赶快关掉这个浏览器页面,以节省您宝贵的时间去多刷刷抖音,顺便还能减轻我服务器的负担。
你是个丑八怪老太婆,没胸没屁股,没人会强奸你,那么你就可以不穿衣服出门吗?

如果你讨厌现在的网络安全环境,请你:

  • 不要写出对用户不负责的代码
  • 在自己管理的网站上部署 HTTPS 与 HSTS
  • 向更多人普及网络安全知识
  • 尽量不用无法确保数据安全的网络服务
  • 如果不得不用那些服务,设置足够长、足够随机的密码,且尽量不要填写太多的真实信息
  • 不要当韭菜

由于我的技术水平限制,在这篇文章中,小到用词错误、大到概念错误,都可能会出现。如果你在文章中发现了问题或有更好的解决方案,请在下面评论,谢谢!😂

🔲 ☆

新的 materiality-typecho-theme

时间过的真快啊,从 2017 年 3 月 16 日我提交了这个主题的首个 commit,到现在已经整整 3 年过去了。
在之前的三年里作为一个完成度极低的主题能收获几十个 star 我已经很满意了,不过经过这两个月间的重构与完善,这个主题应该会更好用,所以特意写了这篇文章来宣传😂

当初这个博客刚刚建立时,由于我对 MD 的无脑粉,我使用了 HanSon/typecho_material_theme。但是...这个主题用的是 material bootstrap,所以我总觉得缺那么一股味道...

于是,我顺藤摸瓜地找到了 Google 官方的 Material Design Lite。我照着 HanSon 的 typecho_material_theme,用 Material Design Lite 仿造出了一个简陋的主题:EAimTY/typecho_material_theme。我对前端的知识量几乎为零,所以那个主题几乎丑到不能看...

一次,我偶然发现了 MDUI 这个库。相比与 Material Design Lite,MDUI 的完成度高得多,功能与组件丰富,用起来简单方便,而且还包含了一个 JS 工具库 mdui.JQ,体积比 jquery 小很多,但功能也够用。
我用 MDUI 重写了博客的主题,产物就是 materiality-typecho-theme
由于我对很多前端知识只是一知半解,况且当时在上高中没有太多空余时间,一直到 2019 年末,这个主题的完成度仍旧不高。尽管如此,MDUI 的作者仍然将这个主题放在了 MDUI 项目的首页进行展示,十分感谢!

转眼间三年就过去了,Google 推出了“更加扁平的 Material Design 2”来变相搞死 MD,我对其执着也逐渐变淡了。期间我对这个主题的更新“很不上心”。直到 2020 年寒假,我有了大把的空闲时间,才开始对这个主题的大规模修改。

为了填当年挖的坑,同时也为了让主题值得被继续“挂”在 MDUI 的官网上,我明确了这个主题要实现的功能与侧重点:简洁、文字显示,并在 2020 年初的两个月里提交了比前面三年间提交总数还多的 commits...

这是主题最近的 CHANGELOG:CHANGELOG.md

现在这个主题的完成度已经不是那么低了,所以为了保持简洁性,主题今后的更新将以修复漏洞为主,不再会像之前两个月一样非常频繁地加入新功能。

🔲 ☆

我的灵刃可能要返修了──为什么我对雷蛇又爱又恨?

没错,才用了不到一年,我的灵刃就需要返修了。

发生了什么?

  • A 面 Logo 不亮,屏幕有些的漏光,特别是左下角,而性能也不知为何“衰弱”了
  • 我这台的 3DMark Time Spy 显卡跑分只有不到四千分,灵刃 15 2018 精英版的 1070 Max-Q 在正常情况下跑 4500~4600 应该没问题吧?
  • 过热降频还极其严重,跑 CS:GO 死斗 Dust2 这张图平时帧率在 150 左右浮动,但有时候竟然能掉到 20~30 fps,还伴随强烈的 input lag...曾经玩 MC 能开着 SEUS 光影和 512 的材质包跑 60 fps 以上,现在把材质包分辨率降到 128 跑起来还是一卡一卡的

本来以为是太长时间没清灰才导致这个问题,结果用吸尘器和吹风机把机器内部仔仔细细清理了一遍之后问题依旧。
最麻烦的是,学校推迟开学了,我最早也要等到 4 月中旬才能回广州,而我现在所在的这个二线城市还没有雷蛇的维修点...

但其实我早就有了心理准备,预料到灵刃迟早会出问题。


灵刃的“脆弱”早已众人皆知。
Linus 对灵刃 15 2018 的评测 中就曾提到,他给员工们发的灵刃 14 在一段时间后都或多或少出现了问题:

在网上也经常看到不少“我的灵刃突然就坏了 QAQ”之类的哀嚎。
之前有一个朋友被我安利买了灵刃,最近他的机器也出了问题:

Emm...虽然我很难想象出“拖拉机一般的风扇”是什么样的,但这恐怕也是雷蛇的常规操作吧。

到这里,恐怕有些人会奇怪:为什么你明知道灵刃总会出大大小小的问题还偏要去踩坑?

我为什么选择雷蛇灵刃?

讲讲我的故事。

大概是五、六年前吧,我还是个初中生。那时候我还没有像样的电脑,只有一台配有 Athlon 64 X2 3800+ 的古董机(准确来说是我爸换新机器而替换下的电脑)和一部很差的 Android 手机。

一次偶然,我在 Bilibili 看了一个视频。虽然是仅仅一个视频,我还是成功地被那个操着加拿大口音的矮个子吸引成为了他的粉丝,从此再没有落下他的任何一期视频。
“他”是谁?很容易猜到,他就是 Linus Sebastian。而一直以来作为他的 daily driver laptop 的,就是 Razer Blade 系列。

我第一眼看到那台笔记本就被它深深地吸引了:低调而优雅的外观、强劲的性能...我当时梦想着有一天也能用上一台这么“完美”的笔记本。

不久后,我攒钱买到了第一台真正属于自己的电脑。
那是一台翻新的富士通笔记本,售价仅仅800rmb。我还很清楚地记得它的厚度,大约有半分米,也就是三倍于灵刃吧。i5-560M,2C4T,4G DDR3 RAM,没有固态硬盘。

就是这么一台低端笔记本,陪我走过了近2年的时光。我不知道用它赢弱的内核编译过多少次程序、写过多少行代码、推过多少游戏、看了多少期 Linus 的视频。

近2年以后,我卖掉了它,之后踩了不少各种各样的坑:双路 X58、X79、m-atx X58、Chromebook...

虽然我有了 SSD,有了 6C12T,有了 16GB RAM,有了 GTX970,但灵刃仍旧在我的心中占据着不可替代的地位。

终于,在我有机会、有能力选择市面上大多数笔记本时,我没有一丝犹豫地选择了灵刃。
因为她一直以来都是我的梦想,是我心目中最好的电脑。

这个故事告一段落了,故事中的我已成了现在的我。

当我第一次真的把灵刃拿在手里时,我就知道我的选择是无比正确的。

她有着所有笔记本电脑中最漂亮、非常简洁的外观,却同时有着最华丽、最张扬的 Logo;有着与 Macbook 不相上下的做工,单手开合不在话下;有着非常强劲的配置,却有着堪比轻薄本的厚度与在游戏本中优秀的续航;有着 Windows 阵营中最好的触控板和超一流的屏幕...
把她的 Logo 遮住,她就是低调优雅而效能超群的办公用具;露出 Logo,她就是使用者对他人“狂放”的自我介绍。
雷蛇对细节、对完美的追求造就了这样一款笔记本电脑,用她来嘲讽粗俗、无趣的其它厂商。

纵使灵刃有不少缺点:噪音、价格(轻薄税、雷蛇税)、故障率...她依旧是市面上最好的电脑。


上面的内容参杂了不少我的个人感情,但事实证明,不仅有我这种出于感情而选择灵刃系列的人,还有很多人出于各种原因选择灵刃系列:

My Laptop DIED - Time to Pick a New One!

7 个月前,Linus 的灵刃潜行版坏掉了,他面前摆了满桌子的高端轻薄本,可他最后还是选择了新款灵刃潜行版。

I Broke My Razer Blade

1个月前,Dave Lee 的 2018 款灵刃被他摔坏了,他把市面上的其它笔记本“批判了一番”,然后把主力机换成了 2019 款灵刃。

类似的例子还有很多。
虽然灵刃系列有着各种各样的小毛病,但总是瑕不掩瑜。

那么,

我的下一台机器还会是雷蛇灵刃吗?

答案是否定的

上面花了这么大的篇幅夸灵刃,最后还是要抛弃灵刃,你这人怎么回事?!

这是我的灵刃出现问题后这段时间里考虑后得到的答案。

我觉得我之前的思路可能是错误的。

灵刃的主要卖点是“轻薄却性能强大”。
然而,世间万物没有“全能”的存在。每个方面都想顾及,到头来可能每个方面都不能做到出众。
便携性与续航不如轻薄本,性能不如台式机,这就是我看来灵刃最大的问题。
当然这不是灵刃本身的错,而是“游戏本”整个类型电脑的通病。
况且,若将所有工作托付给一台机器,出现“单点故障”是很危险的。

所以,如果我要换电脑,我会选择轻薄本+台式机这样的配置。

我会选择哪款轻薄本呢?

我现在也无法完全确定,但基本上已经做好了选择。

除了灵刃之外,曾经还有一款在设计上惊艳到我的笔记本:HP Spectre 13

这台机器在最初发布的时候是世界上最薄的笔记本。我最喜欢的是它铰链的设计,感觉像工艺品一样十分精妙。
遗憾的是,这个系列已经很久没有更新过了。如果惠普之后能够推出保留原有的设计语言的新型号,我也会重新考虑它的。

于是,现在市面上符合我需求的产品只剩下了两个:MacBook系列和雷蛇灵刃潜行版

出于我对雷蛇的信赖与对 Linux 的需求,没有意外的话,
我的下一台笔记本会是 Razer Blade Stealth

🔲 ☆

ZFS──瑞士军刀般的文件系统

你是否曾遇到过如下情况:

  • 应该给这个分区分配多少空间?另一个呢?
  • 我正在覆写一个文件,突然间设备断电了!我的源文件和更改全都损坏了!
  • 硬 RAID 卡太贵了,mdadm 用起来又太麻烦...
  • 我不想用 RAID,但是想把两块硬盘上的空间分配至一个分区中,有没有除了用 LVM 外的其它办法?
  • LUKS 用起来好麻烦,每加载一个分区都要输入一次密钥...
  • 每次备份系统都要把整个系统打包,太浪费空间了!
  • 我的硬盘太太太大了!
  • 我的某个文件太太太大了!

那么为何不试试这个瑞士军刀般的文件系统──ZFS?


玩过 NAS 的朋友们应该对 ZFS 不陌生,FreeNAS 之类的很多存储服务器系统都原生支持 ZFS 文件系统,配置简单而优点众多。

ZFS 有非常多优点,然而为什么在 PC 上使用 ZFS 却不多见呢?我很早之前就听说过 ZFS 的各种优点,于是在写这篇文章前一个月,我把自己的主力 PC 的整个文件系统(包括根分区)迁移至 ZFS。经过一个月的使用体验,我发现:

ZFS 真香!

注:
①这是一篇介绍与安利 ZFS 的文章,不会深入分析其架构与实现。同时本文也不是使用教程,如果你想尝试在 Linux 下 ZFS,请移步 ZFS on Linux Wiki 与各大发行版的 Wiki;
②我的所有体验都是在 Linux(Debian sid) 下完成的,也就是说下面内容基于 ZFS on Linux 项目,Solaris 系、BSD 系对 ZFS 的支持应该会更好。另外,我使用的 Kernel 版本是5.4,太低的内核版本可能会影响使用体验。


ZFS 的前生今世

ZFS 的开发并非是一帆风顺的。

诞生

ZFS 与 Solaris 是密不可分的。
让我们回到 20 年前。当时,Sun 公司如日中天,1992 年发布的 Solaris、1995 年发布 Java 都是其辉煌的证明。1993 年,Sun 进入了财富 500 强。

在公司业绩蒸蒸日上的环境下,ZFS 诞生了。

2001 年,Solaris 已诞生了整整 10 年,Sun 希望改进 Unix 文件系统(Unix File System)以解决一些问题。Sun 内部的 Jeff Bonwick 就是在此时有了对 ZFS 架构的基本构思。他说服了 Sun 高管,组建了 ZFS 的开发团队。

2004 年 9 月 14 日,Sun 正式宣布了 ZFS 文件系统。2005 年 6 月 14 日,Sun 公司将正在开发中的 Solaris 11 的源代码以 CDDL 许可开放,这一开放版本就是 OpenSolaris。2005 年 10 月 31 日,ZFS 并入了 Solaris 开发的主干源代码,并在 2005 年 11 月 16 日作为 OpenSolaris build 27 的一部分发布。

波折

好景不长,2009 年 4 月 20 日,甲骨文公司宣布以 74 亿美金收购 Sun 公司。此时的 Sun 已如夕阳。由于甲骨文公司对 OpenSolaris 计划没有积极支援的意图。OpenSolaris 委员会于 2010 年 7 月 12 日对甲骨文给出“最后通牒”,要求在 8 月 16 日派出一位代理人商讨计划的走向,否则将在 8 月 23 日的委员会会议中做出回应。由于甲骨文未加回应,委员会于该日达成共识,解散 OpenSolaris 委员会,社区将不再提供新的源码,计划的控制权由开发员社区交还给甲骨文。

至此,Sun 的 ZFS 已成为 Oracle 的专有软件,ZFS 成为 Oracle 的注册商标,而 OpenSolaris,包括其中的 ZFS,由 illumos 项目 继续着开发。

壮大

但 ZFS 并没有至此没落。社区在 2013 年成立了 OpenZFS 以配合 ZFS 在 illumos 中的开源发展,由 Jeff Bonwick 当年组建的 ZFS 开发团队中的 Matt Ahrens 作为 OpenZFS 项目的负责人。在开源社区的努力下,ZFS 不断增加新特性、提升稳定性。

回到 2007 年,苹果将 ZFS 移植到了 Mac OS X 上,但该项目在 2009 年被关闭,之后 MacZFS 项目继续维护着代码;2008 年,ZFS 随 FreeBSD 7.0 正式进入了 FreeBSD;同年,ZFS 开始被原生移植至 Linux,后来成为了 OpenZFS 下属的 ZFS on Linux 项目。如今,几乎所有主流操作系统都支持了 ZFS,甚至有 OpenZFS on Windows 项目 为 Windows 提供 ZFS 移植!

我们今天所说的 ZFS 在绝大多数情况下指的都是 OpenZFS。


什么是 ZFS

ZFS 这个名字本身没有含义,只是"Zettabyte File System"的首字母缩写。但 ZFS 本身并不具备任何的缩写意涵,只是想阐述做为一个具备高扩展容量文件系统且还有支持许多延伸功能的一个产品。
它是一个 128 位的文件系统,也就是说它能存储 1800 亿亿(18.4×10^18)倍于当前 64 位文件系统的数据。ZFS 的设计如此超前以至于这个极限就当前现实可能永远无法遇到。

Jeff Bonwick 曾说过:

要填满一个 128 位的文件系统,将耗尽地球上所有存储设备。除非你拥有煮沸整个海洋的能量,不然你不可能将其填满。

并解释填满ZFS与煮沸海洋的关系:

尽管我们都希望摩尔定律永远延续,但是量子力学给定了任何物理设备上计算速率与信息量的理论极限。举例而言,一个质量为 1 公斤,体积为 1 升的物体,每秒至多在 10^31 位信息 上进行 10^51 次运算。一个完全的 128 位存储池将包含 2^128 个块 =2^137 字节 = 2^140 位;因此,保存这些数据位至少需要 (2^140 位) / (10^31 位 / 公斤) = 1360 亿公斤的物质。

但单纯的“大”并不能成为 ZFS 的代替其它文件系统的原因,因为如今有很多文件系统(如 XFS)也可做到这点,虽然不及 ZFS,但数据存储量也暂时不会成为其瓶颈。

ZFS 的优点远不止“大”,下面来讲 ZFS 的众多优秀特性。


ZFS 的特性

虚拟设备(VDEV)、存储池(zpool)、数据集(dataset)、虚拟卷(zvol)

这些是 ZFS 的实现核心,是 ZFS 易用性的基础,ZFS 的绝大多数优点是在其上实现的。

我们在使用传统文件系统时,“分区”与“分区表”是相互独立的,基于不同的技术实现。ZFS 则不同,可以认为它同时承担了分区与分区表的角色。

虚拟设备(VDEV)

VDEV 类似于 Linux 中的 Device Mapper 层,可以认为是 ZFS 架构中的最底层,它用来定义使用设备的类型。
为何叫做虚拟设备?因为一个 VDEV 并不需要是一个特定的独立设备(例如一块硬盘),它可以是一块硬盘、一个 RAID 阵列、RAID-Z(ZFS 实现的 RAID 方式,下文会详细介绍)、甚至是一个文件。

ZFS 支持以下 VDEV 类型:

  • 文件(File)
  • 一块磁盘或一个 RAID0 阵列(Stripe)
  • 标准 RAID1 阵列(Mirror)
  • RAID-Z(包括 RAID-Z1、RAID-Z2、RAID-Z3)
  • 热备盘(Spare)
  • L2ARC(Cache,用 ARC 算法实现的二级缓存,保存于高速存储设备上,通常使用 SSD)
  • ZIL(Log,记录两次 transaction group 之间发生的 fsync,保证突发断电时的一致性)

存储池(zpool)

存储池建立于 VDEV 之上,可以认为与传统文件系统中的“分区表”对应。它拥有类似于 LVM 的功能,能管理一个或多个 VDEV。类似于内存,存储池大小会是所有设备的大小总和。这意味着在一个存储池中,可以同时包含一块硬盘、一个 RAID1 阵列和 RAID0 阵列等不同部分!
LVM 不能作为最终文件系统,建立的 LV 需要格式化为最终文件系统(如 ext4、fat32、NTFS、F2FS 等)后才能使用。不同于 LVM,建立好的 ZFS 存储池不需要任何操作即可直接使用。

数据集(dataset)、虚拟块(zvol)

将数据集与虚拟块放在一起介绍是因为它们是“平级”的,都建立于存储池之上,可以认为与传统文件系统中的“分区”对应。

虽然存储池可以直接使用,但是有时我们需要实现一些特殊的需求,例如:你将 ZFS 作为根系统挂载,在快照系统时不想将 /tmp 下的文件和目录加入快照,怎么办?这时你可以定义一个“tmp”数据集,并设置其属性为 com.sun:auto-snapshot=false
细心的朋友可能会发现,即使在没有创建“tmp”数据集时,/tmp 仍然是存在的,因为在 ZFS 中,可以认为数据集是为设置权限,限额,快照,挂载点等高级特性才存在的。
在没有定义数据集大小的情况下,ZFS 会自动为其分配存储池中的空间。ZFS 也允许子数据集的存在,在没有特别设定的情况下子数据集将继承父数据集的属性。

虚拟块是 ZFS 提供的块设备方式,类似于数据集,虚拟块为 block 设备,可以被格式化,可以被 iSCSI 分享。举个例子,你想在一块被分入存储池的空间上划分出一个 F2FS 文件系统格式的“分区”,你需要使用的就是虚拟块。

由此可见,在ZFS下,操作一个“分区”就像是操作一个目录一样简单,并且“分区”是动态大小的!

写时复制(copy-on-write,CoW)

在绝大多数文件系统下,当我们覆写一个文件时,已覆写的部分会完全丢失,在覆写的中途想要取消是不可能的。
ZFS 不同,当你执行覆盖文件操作时,新数据会先存储在不同的区域,在写入完成后再将文件系统元数据信息指向新写入的区域。
这能极大地提升系统的稳定性与安全性。突然断电时,正在操作的文件也不会损坏。所以如果使用了 ZFS,就可以完全抛弃 fsck 了。

当然,这么做有一个显著的缺点:文件会散落在磁盘的各个区域,也就是我们常说的“磁盘碎片”。
但是,随着 SSD 的普及,磁盘碎片已经变得无伤大雅,而传统的机械硬盘也更多地成为了“长期存储设备”,例如用于 NAS,其上数据不会非常频繁地进行覆写操作。

数据完整性验证、自动修复

写入的数据时,ZFS 会创建数据的校验和,校验和是数据的256位散列,校验和功能的范围可以从简单快速的 fletcher4(默认)到强加密散列(如 SHA256)。
读取数据时,ZFS 会检查读到数据的校验和,如果与写入时的检验和不符,那么就说明遇到了错误,ZFS 会尝试自动修正错误。这能在很大程度上保证资料完整性。

RAID-Z

上文在介绍 VDEV 时提到了 RAID-Z,这是 ZFS 实现的 RAID 方式,不需要任何其它硬件或软件。
现时 RAID-Z 有 3 种类型:RAID-Z1、RAID-Z2、RAID-Z3。

  • RAID-Z1,类似于 RAID5,一重奇偶校验,至少需要三块硬盘;
  • RAID-Z2,类似于 RAID6,双重奇偶校验,至少需要四块硬盘;
  • RAID-Z3,三重奇偶校验,ZFS 独有,至少需要五块硬盘。

快照(Snapshot)

这时 ZFS 在文件系统层级实现的备份功能。
当 ZFS 写入新数据时,可以保留包含旧数据的块,因而能够维护文件系统的快照版本。而因为组合快照的所有数据都会被储存,且整个存储池通常每小时会进行几次快照,所以快照的创建速度非常快。任何未变动的数据会在文件系统及其快照之间进行共享,因此也具备空间高效性。快照本质上是只读的,确保在创建后快照不会被修改。快照可以被整个恢复,也可以恢复快照中的某些文件或目录。

ZFS 也可以创建可写快照:“克隆(Clone)”。“克隆”让两个独立的文件系统共享一组块。对克隆文件系统的修改都会创建新的数据块以反映这些更改。但是无论存在多少个克隆,未变动的块仍然会被共享。这是写入时复制原则的实施方式。

去重(Deduplication)

ZFS 提供文件级别(file-level)、块级别(block-level)、字节级别(byte-level)的去重。
文件级别去重通过比对校验和来发现重复,是一般使用的级别,对性能影响最小。
其它两个级别的去重可以用于某些特殊场合,能节省更多的存储空间。

透明压缩(Compression)

ZFS 原生提供透明压缩功能,也就是在读写文件时实时进行压缩与解压缩。ZFS 会尝试压缩文件的头部,如果压缩率不佳,会自动放弃压缩将原数据写入。
压缩可以在一定程度上“提高 IO”,但代价是占用CPU性能,要在考虑 IO 性能与 CPU 性能后再选择是否开启。
ZFS 支持以下压缩算法:

  • lz4,默认且推荐的压缩算法,对系统性能影响小,存取数据的速度几乎与不采用压缩时一样;
  • gzip,这个不用解释,ZFS 默认的 gzip 级别是 6。对性能有一定的要求,但如果存储的数据是文档类型,可以考虑使用 gzip;
  • lzjb,与 lz4 类似,但更推荐 lz4;
  • zle,简单来说就是“去 0”。

一般来说,不推荐同时开启ZFS的压缩与块级别以上的去重。

透明加密(ZFS Native Encryption)

这是 ZFS 支持的最新特性,能够加密存储池或数据集。
默认下,ZFS 使用 aes-128-ccm 作为加密算法,目前为止是绝对安全的,如果不放心,还可以选择更高级别的加密。
传统的 LUKS 需要为每一个加密分区提供密钥,而 ZFS 可以对存储池加密。
当然,开启加密必定会影响性能,要在考虑性能后再选择是否开启。


综上,不难发现 ZFS 不只是一个单纯的文件系统,它还提供了一系列工具来提高使用体验。

但是,拥有如此多的优点,

为什么 ZFS 没有在更大程度上流行?

回到 2005 年,最初的 ZFS 随着 OpenSolaris 的开源而进入了开源世界,但同时也因许可证问题与 Linux “天生不合”。
OpenSolaris 开源时采用通用开发与散布许可证(CDDL),从而 ZFS 也成为了 CDDL 协议下的开源软件。
Oracle 收购 Sun,终止 OpenSolaris 开发,让我们口中的 ZFS 变成了 OpenZFS,OpenZFS 项目因无法联系到所有 ZFS 的初期开发者而无法变更许可证。CDDL 协议与 GPL 协议不兼容,所以很多 Linuxer 无法接触到 ZFS,从而导致了 ZFS 无法在更大程度上流行。

但最近 ZFS 也在慢慢融入 Linux 家庭中,上文中提到的 ZFS on Linux 项目对此作出了巨大的贡献。
2019 年,Canonical 在 Ubuntu 19.10 中默认加入了 ZFS 的支持。这是 ZFS 融入 Linux 的历史性的一步!虽然有着许可证问题,但是更多人能逐渐理解并包容这一历史遗留问题。Canonical 目前为止还没有因 ZFS 的引入而被缠入法律纠纷。有了第一个“吃西红柿的人”,相信之后会有更多的发行版拥抱 ZFS。

今天,ext 在某些方面的缺点已经成为瓶颈,BtrFS 因开发缓慢且不够稳定而暂时无法成为生产环境下的文件系统,ZFS 应是最佳文件系统的候选之一。

多年以后,当 BtrFS 成为主流时,也请不要忘记 ZFS,不要忘记这个优秀的开源社区,不要忘记这个伟大的文件系统探路者。


本文参考了:

如果你在文章中发现任何纰漏,请在评论中告诉我,谢谢!

🔲 ☆

进入二十年代后的第一篇文章

哎呦老兄去年一整年你只发了3篇文章这博客是不是快要倒闭了

没有,我还活得好好的!(╯`□′)╯~ ╧╧

与“2019”那篇庆祝开博3年的文章仅隔了2篇文章,就又该庆祝4周年了,按这个势头下去这个博客怕是要变成“年份变更的提醒BOT”了😂(欢迎大家订阅本站的RSS,专为您提供新年提醒哦)

这一年,我会多发一些文章,免得网站里空荡荡得闹鬼😂
我重新思考了这个博客上到底该放些什么文章:其实归根结底博客就是一个别人有权限阅览的私人日记本(或是周记/月记/年记),之前我太拘泥于文章的“实用性”(好吧就我这点儿破伎俩好像根本不实用...),所以我准备换个思路来更新这个博客。
首先,这个博客一直会保持非营利,我绝对不会挂任何广告、接任何推广(好吧怕是这辈子也不会有人找我做推广了🥴)
另外,我会更新更多非技术类型的文章,比如生活、想法、音乐、游戏什么的,让内容更丰富一些。
毕竟这网站本质就是个笔记本,有什么不能放上来?😂

下面说说博主的近况。
去年6月高考结束了,但成绩没达到我的目标,所以最终去了某个一线城市里的某所高校读软工。
大一上学期课业不忙,写了几个小工具自己用,有机会的话也会开源出来(虽然放出来也没人用...)
11月的时候因为气候不适应(大概吧)得了一次有点严重肺炎(废话,肯定不是COVID-19...),作为宅本来我也不是很强壮,所以花了差不多一个月才痊愈。

我的 Pixel 2 XL 被我玩坏了(物理上),NFC Boom、SIM卡读取Boom,尾插板貌似也有问题,充电慢,无法连接电脑。无奈之下,只能暂时换个手机。
说实话我个人觉得最近两年的手机市场在越走越偏,先是集体去掉耳机接口,又集体搞全面屏。恕我直言,在技术达不到99%完美的情况下强推消费者都是耍流氓,市面上一票刘海屏、水滴屏、挖孔屏甚至是加装机械结构放相机的产品真的是一个比一个蠢,还使劲堆摄像头数量忽悠消费者,我所认为的真正的“好”手机屈指可数。这是整个手机行业的迷茫期,所以我决定搞一部与Pixel 2 XL同为2年前旗舰的机器作为缓冲,等一两年行业在技术与概念上有所突破后再考虑换新机。
最后,我选择了大法的 Xperia XZ Premium,港版单卡,镜面银,4K屏,一大堆音频增强之类的功能正好配我的1000XM3,防水,该有的功能都有,还附送3.5mm耳机孔!最重要的是,炒!鸡!漂!亮!从此我再不缺镜子了。
当然这机器还是有些小缺点的:电池太小,还好有快充;影像方面着实一般,真是白费了大法这么好的硬件,之后准备试试看能不能把Google Camera全功能移植过来;系统太旧,不出意料官方没有升Q,补丁还在去年7月;还有不知为何蓝牙和WiFi争带宽,听Hi-Res的时候网络时不时会变得有些差...
总体而言,这手机还是符合我对它的预期,现在这机器解锁之后丢TA分区的问题也得到解决了,之后准备解锁后动手试试给它适配LineageOS。

寒假放假回家后2019-nCoV开始流行,不过对我这种宅宅的影响不大。
在家每天肝舰(tg频道 EAimTY's🌊Kancolle Notebook:https://t.me/kc_eaimty)。
试了试Manjaro发现真香,最终上了车把主力系统换成了Manjaro。已经换回Arch了,Manjaro过于傻瓜化,感觉不太适合...

另外,我还花了很久重构了博客的主题,完善功能。同时,我也思考了这个主题的侧重点:文字。华丽的Material Design Typecho主题有很多,大家全都搞得一模一样也没什么意思。另外不可否认,文字在大多数情况下都是高效的信息主体,所以我决定做专注显示文字内容、延续现有风格、简洁同时也美观的主题。

新年快乐!

🔲 ⭐

DNS被劫持,已修复

心中有一万只草泥马在奔腾ing...

晚上升级了一下Chrome,顺手把浏览数据清除了。之后进了一下博客,输入网址时没有写HTTPS,结果发现出问题了:

不通过HTTPS访问博客时会被DNS劫持!( ´Д`)=3

从今天起,eaimty.xyz及其所有子域名全部启用SSL加密,且全部开启HSTS Preload
顺便在服务器上yum update了一下,发现Nginx官方yum源里的软件包升级到了1.10,终于原生支持http2了,不容易啊

❌