作者: meathill

  • 正确使用 height: 100% 和 flex: 1

    正确使用 height: 100% 和 flex: 1

    HTML+CSS 实现页面布局的时候,盒模型是很重要的概念。早期没有明确布局概念的时候,HTML 元素主要分两大类:行(inline)元素与块(block)元素。默认情况下,块元素会占据父元素的一整行矩形区域,宽度是 100%,高度由内容决定。如果我们希望子元素跟父元素一样高,可以设置 height:100%

    接下来我们有了弹性盒模型, display: flex。弹性盒模型是一种主动布局,即我们先决定怎么布局,浏览器则负责填充内容和渲染。所以直觉上,我以为:

    1. 父元素 height: 100%; display: flex; flex-direction: column
    2. 某个子元素 flex: 1
    3. 子元素的子元素 height: 100%,应该能自动填充剩下的高度
    4. 如果子子元素同时 overflow: auto,那么应该可以自动出滚动条。

    结果不行。

    问题一

    如上图,我厂的 Showman 产品。它的高度自适应屏幕高度,顶部通栏、导航、动作按钮栏高度固定,编辑器和日志输出窗口填满剩下的空间。我希望用户可以调节编辑器和日志输出窗口的比例,以适应开发与调试不同的场景。于是保存日志输出窗口的高度,编辑器的高度自适应(flex:1)。

    因为 Vue2 组件要求有唯一的根元素,且整个应用有多个不同的路由,所以编辑器和日志输出窗口只能作为子子元素存在,大概的结构是这样的:

    <div id="app">
      <header id="main-nav">
      <!-- <router-view> -->
      <div id="main-body">
        <header id="second-nav">....</header>
        <div id="action-bar">....</div>
        <div class="editor-output">
          <div id="editor">....</div>
          <div class="drag-splitter"></div>
          <div id="output">....</div>
        </div>
      </div>
    </div>

    CSS 大概如此:

    html, body, #app {
      height: 100%;
    }
    #app {
      display: flex;
      flex-direction: column;
    }
    #main-nav, #second-nav, #action-bar {
      height: 40px;
    }
    #main-body, .editor-output {
      flex: 1;
      display: flex;
      flex-direction: column;
    }
    #editor {
      flex: 1;
    }
    #output {
      height: 100px;
    }

    这样的结果是,执行时,大量日志输出,就把界面顶开了。而不是预期中那样,出现滚动条,多余的部分被隐藏。

    经过一番 google,发现问题在高度计算。虽然定义了 height: 100%display: flex,但是浏览器在计算高度的时候,并不会从外往里一层一层算,而是按照规范:

    百分比
    指定一个百分比的高度。这个百分比是相对于父元素的盒子的高度计算的。如果父元素没有明确指定高度,并且该元素不是绝对定位的,该值将计算为 “自动”。

    auto
    由其它属性决定。

    于是因为上图中的界面存在嵌套关系,所以在需要计算高度的时候,子元素的高度虽然应该是 100%,但是父元素并没有被明确指定,所以就变成了 auto,继而被子元素撑开。解决方案就是沿着你需要 height: 100% 的元素往上,添加明确的 height,可以是百分比,也可以是绝对数值。

    于是,我在 #main-body 上添加 height: calc(100% - 49px),问题解决。

    😓 等下,不是每级都要加么?为啥只加一个具体高度就可以了?这个问题,我还要再研究一下。目前猜测,因为这个元素是竖直方向排列的(flex-direction: column)。

    问题二

    后来,在编辑器和日志输出窗口的右侧,增加了资源缩略图侧边栏。于是又遇到第二个问题:我以为 #main-nav 的高度确定,那么作为 display:flex,默认 align-items: stretch,它的子元素的高度应该都等于它的高度。所以给子元素设置 overflow: auto 就应该可以限制高度,出现滚动条。结果又失败了。

    然后我想起来 BFC。虽然直觉上 BFC 应该跟 display:flex 应该没什么关系,不过因为测试起来比较简单,可以先试试。

    于是我就在 div.d-flex 上添加了 .overflow-hidden 样式,果然问题就解决了。因为没找到明确的文档解释,所以我只能猜测:

    1. 类似 BFC 的逻辑在 display:flex 元素上依然存在。
    2. 父元素 display:flex;flex:N,根据上下文它应该有个确定的高度
    3. 但如果子元素高度超过它的高度,默认会撑开
    4. 如果父元素 overflow: hidden,会触发某个 xFC,于是整体高度就被限制了
    5. 于是子元素的滚动条就出来了

    总结

    行文至此,其实两个问题我都没找到具体的文档或者规范,只能说是摸索着解决了,然后再自己猜测原理。希望日后能找到具体的解释和规范吧。(要不要去翻翻张鑫旭的《CSS 世界》呢,都送完了,还得再买……)


    参考阅读:

  • 解决 FFMPEG 合并视频没有声音的问题

    解决 FFMPEG 合并视频没有声音的问题

    FFMPEG 是个非常强大的视频工具,操作音视频必备。

    使用 FFMPEG 合并音视频很简单,只需要把待合并的文件名都放在列表文件里,然后使用 -f concat 连接即可。

    列表文件是纯文本,格式如下:

    # 可以用 # 写注释
    file '/path/to/file1.mp4'
    file '/path/to/file2.mp4'
    ....

    然后调用命令,其中,files.txt 就是列表文件:

    ffmpeg -f concat -safe 0 -i files.txt -c copy -y output.mp4

    解决没有声音的问题

    这些只是基础,实际应用要复杂的多。比如今天遇到一个问题,合并后的视频没有声音。

    第一步,很快搜到 Concatenating videos with ffmpeg produces silent video when the first video has no audio track 这个答案。按照里面的说法,因为第一个视频(即封面视频)没有声音,所以后面就都没有声音。解决方案是,给第一个视频添加一条空白音轨,这样就不影响其它视频的声音了。

    答案里的方法是:

    ffmpeg -i INPUT -f lavfi -i aevalsrc=0 -shortest -y OUTPUT

    试了一下不好使,而且合并时大量报错:

    [aac @ 0x55e495967e80] decode_band_types: Input buffer exhausted before END element found
    Error while decoding stream #0:1: Invalid data found when processing input

    仔细阅读答案的评论,有位同学说:

    For the silent audio make sure to match the channel layout and sample rate of the other inputs. For example, 44.1kHz stereo audio: -i anullsrc=cl=stereo:r=44100

    生成空白音轨的时候,要确保声道和采样率与其它输入视频一致。比如,44.1KHz 立体声:-i anullsrc=cl=stereo:r=44100

    似乎有点关系。我立刻使用 ffprobe 查看源视频的信息:

    ffprobe -i input.mp4 -show_streams -select_streams a -loglevel error

    得到:

    sample_rate=24000
    channels=2
    channel_layout=stereo

    于是,我修改前面的空白音轨生成方式,这次终于成功了:

    ffmpeg -i input.mp4 -f lavfi -i anullsrc=cl=stereo:r=24000 -shortest -y input-new.mp4

    一点推测

    • FFMPEG 会以第一个视频为基底,往上合并其它视频,所以第一个视频的音轨就很重要
    • 合并视频前最好先进行转码,然后 -c copy 就好了。

    其它 FFMPEG 操作笔记在:FFMPEG 笔记

    参考资料:

  • 在 Puppeteer 里使用代理服务器科学上网

    在 Puppeteer 里使用代理服务器科学上网

    使用 Puppeteer 录制视频的时候,如果服务器在国内,可能会有一些网站打不开。这个时候,我们可以要求 Puppeteer 使用代理服务器。

    0. 配置科学上网

    参考两篇旧文,其实原理一样,只是用的软件不一样:

    配置完成之后,通过浏览器应该可以正常访问。

    1. 使用代理服务器

    启动 Puppeteer 的时候,可以传入参数 args,进行各种调整,完整的列表请参考:List of Chromium Command Line Switches

    关于代理服务器,有若干个选项,我们要用的是 --proxy-server,方法是:

    puppeteer.launch({
      args: [
        '--proxy-server=socks5://127.0.0.1:1080',
      ],
    });

    2. 使用 PAC 文件

    但是这样所有流量都会走代理服务器,也不符合我们的期待,所以最好使用 PAC 文件。参数名称是:--proxy-pac-url,但请注意,因为 Chromium 的 bug,这个参数只在 headless: false,即有界面的时候才会生效。好在我们是为了录视频,所以本来就要打开界面。

    所以最终的启动代码就是:

    puppeteer.launch({
      headless: false,
      args: [
        '--proxy-server=socks5://127.0.0.1:1080',
        '--proxy-pac-url=http://localhost/autoproxy.pac',
      ],
    });

  • 用 express.js 实现流式输出 HTTP 响应

    用 express.js 实现流式输出 HTTP 响应

    0. 前言

    我们先来总结一下客户端与服务器端的数据交互方式:

    1. ajax,即 XMLHttpRequest,最常见的数据交互方式,适用于较轻量级的一次性请求,比如用户登录、CRUD 等。
    2. SSE,服务器端事件,适用于有大量服务器端推送、且不需要从客户端发送数据的需求,比如盯股票行情
    3. WebSocket,适用于上下行数据都很多、都很频繁的情况,比如在线聊天、网络游戏等

    单从技术角度来看,大概是这几个。根据需求变化,我也可以组合出更多方案。比如,类似 Showman 生成视频这种需求,耗时很长,对服务器资源占用很高,就比较适合:

    1. 发起请求,产生一条任务记录,进入队列
    2. 服务器上运行独立的进程,从队列中取任务出来执行,并记录日志
    3. 客户端不断检查任务进度

    1. 需求

    今天的需求,介于轻量一次性,与需要服务器长时间执行之间,即 FastTest 中的发布功能。这里,我们有以下需求:

    1. 管理员点击 Publish 按钮,开始发布静态网站
    2. 服务器需要先保存数据,然后调用 webpack 发布所有语言版本
    3. 只有少数管理员会使用此功能,服务器压力不大
    4. 管理员希望能了解发布进度,及每一步的状态

    所以,提交任务后等待服务器慢慢跑就不合适;等待请求完成一次性看到所有结果也不合适;最合适的,就是基于长链接,不断获取返回信息,并在前端实时看到进度。也即是:流式输出 HTTP 响应。

    2. 实现

    2.1 后端

    后端实现我选择 node.js + express.js,在后端领域,这两个我比较熟悉。express.js 是在 node.js 网络模块上进行封装得来,使用起来很简单,也支持原生 node.js 方法。

    首先我们创建一个服务器:

    const express = require('express');
    const app = express();
    const port = 3100;
    
    app.listen(port, () => {
      console.log('FastTest Admin API at: ', port);
    });

    然后,我们在需要流式返回响应的接口里设置相应头 Content-type: application/octet-stream。接下来,只要我们不断向输出流写入内容就可以了。哦,对了,结束的时候,我们还要关闭输出流。

    app.post('/data', async(req, res, next) => {
      res.setHeader('Content-type', 'application/octet-stream');
      
      // 第一次返回
      res.write('Local data saved. Start to build dist。files.\n');
    
      // 数次返回
      for (const item of items) {
        await doSomething(item);
        res.write(`${item} done successfully.\n`);
      }
    
      // 最后,全部完成
      res.write('All done.');
      // 关闭输出流
      res.end();
    });

    2.2 axios

    axios 是很流行的 ajax 库,它进行了 Promise 封装,用起来很方便。这里我们要用 onDownloadProgress(即 axios 对 XMLHttpRequest.progress 的封装)获取下载进度,它会在每次服务器返回响应字符串的时候更新,我们只需要截取上次响应之后,这次响应新增的内容,即可。

    function publish(data, onDownloadProgress) {
      return axios.post('/data', data, {
        onDownloadProgress,
      });
    }
    
    async function doPublish() {
      if (isLoading.value) {
        return;
      }
    
      isPublishing.value = true;
      message.value = status.value = null;
    
      try {
        const { cases, lang } = store.state;
        let offset = 0;
        await publish({ cases, lang }, ({ target: xhr }) => {
          // responseText 包含了从一开始到此刻的全部响应内容,所以我们需要从上次结束的位置截取,获得新增的内容
          const { responseText } = xhr;
          const chunk = responseText.substring(offset);
          // 记录这一次的结束位置
          offset = responseText.length;
          currentStatus.value = chunk;
        });
        status.value = true;
        currentStatus.value = '';
        message.value = 'Published successfully.';
      } catch (e) {
        message.value = 'Failed to publish. ' + e.message;
      }
      isPublishing.value = false;
    }

    2.3 Vue3

    相对来说,Vue3 的部分最容易。这里我用了 animate.css 的 flash 效果,让信息更显眼。除此之外,就是简单的赋值。

    <template lang="pug">
    .alert.alert-info.mb-0.me-2.py-1.px-3.animated.flash.infinite.slower(
      v-if="currentStatus",
    ) {{currentStatus}}
    </template>

    3. 效果演示

    流式输出效果演示

    4. 部署

    一般来说,我们很少会直接用 node.js 当服务器,多半会启动 node.js 服务,然后用 nginx 反向代理给用户访问。这里需要注意,nginx 默认会将响应内容存入缓冲区,然后批量返回给客户端。这会导致流式输出无效,变成常规的执行完毕后一次性输出。

    所以我们必须修改 nginx 的配置:

    # 仅展示有关配置
    location /data {
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header Host $http_host;
            proxy_pass http://localhost:3100;
            # 关闭代理缓冲区,所有响应实时输出
            proxy_buffering off;
    }

    5. 总结 & 扩展阅读

    前些天我帮朋友做了个小项目,叫 FastTest。项目很简单,不过我主动扩展了技术选型,使用了非常多的技术,都是浅显入门的程度,非常适合用来做基础入门学习。本文也是从这个项目中提炼出来的。建议大家有空的时候看看,拉到本地,跑起来试试看效果。

    文中的几段代码,分别位于:

    项目的详细介绍请见:超全面全栈入门项目:fastest

    扩展阅读

  • 检查超宽元素的脚本

    检查超宽元素的脚本

    有时候我们制作页面,搞着搞着发现超宽,出现横向滚动条。于是我们就要想办法调整样式,但是往往超宽的只有那么一两个元素,并不是很好找,所以我就写了下面一个脚本,在页面里跑一下就能找到超宽的元素,然后针对性调整一下样式就可以了:

    // 这里的 375 主要针对基于 iPhone 6 开发移动端页面时
    function traverse(parent) {
      let target;
      for (const elem of parent.children) {
        const rect = elem.getBoundingClientRect();
        const {left, width} = rect;
        if (left + width > 375) {
          target = elem;
          break;
        }
        target = traverse(elem);
        if (target) {
          return target;
        }
      }
      if (target) {
        return target;
      }
    }

    使用的时候,打开页面的开发者工具,将这段代码复制到 console 里面,然后执行 traverse(document.body) 就可以找到超宽的元素,然后想办法调整它即可。

    当然我们也可以继续用这个函数探索可疑元素,找到更具体的超宽元素;或者找到其它超宽元素。这些就留给大家自行探索吧。

  • 超全面全栈入门项目:fastest

    超全面全栈入门项目:fastest

    TL;DR.

    前些天做了个覆盖面很广的项目:FastTest。涉及到静态网站、node.js 后端 API、响应式、多语言、Vue3 全家桶、前端工具链、nginx 配置等一系列技术,都是很基础的应用,相信对大家入门很有帮助。

    GitHub:https://github.com/meathill/fasttest

    欢迎阅读学习,有任何问题均可提问或提 issue。


    前些日子有个朋友找到我,想请我帮他做个项目。我看了一眼需求,不太想接:这个项目太小,要多了不合适;要少了感觉又不值当——有那时间还不如砍两把胖虎。

    后来我仔细一想,感觉这个项目很适合拿来做教程,而且是非常全面的全栈入门教程。

    先来看需求。他的需求很简单:开发一个测速应用。用户打开页面,点击按钮,然后下载一些东西,下载完成之后,告诉他他的网速是多少。与之前的测速产品不同,这个应用不下载大文件,而是从各种 CDN 下载 JS 库和 SDK,因为大部分 web 应用都依赖这些资源,所以可能更接近真实体验。

    关于需求我们不深入讨论(我觉得挺有道理,但也未必有道理到哪儿去……),只看实现。为了满足这个需求,我们需要开发这些东西:

    1. 静态页,让用户能够测速。这就需要:
      1. HTML+CSS+JS,静态资源
      2. 响应式,支持桌面和移动端
      3. 多语言支持
    2. 后台,能调整待测资源、修改翻译、控制广告、查看数据。这就需要:
      1. 友好的 Admin panel:vue 全家桶
      2. 后端 API:express.js + node.js server
      3. 自动发布静态页(webpack 前端工具链)
    3. 部署到服务器:
      1. 静态服务供给用户端页面和资源
      2. SPA 服务,提供后台 Admin Panel 给管理员
      3. 反向代理 express.js API
      4. 配置 CDN
    4. 其它,比如版本管理、前端预处理工具,等等

    这些东西可以说是非常全栈,除了没有移动 App、没有数据库操作,其它 Web 开发的东西都用到了。而且涉及到技术也都很基础,没有特别深入的东西。再加上,这个项目会上线,会迭代,是个真实产品。所以,很适合拿来做教程,大部分观众,不管是什么背景,都可以拿来入门。

    于是我就答应了,然后断断续续在直播的时间把它做了出来。当然还有一些问题,不过大部分功能都就绪了。

    项目放在 GitHub 上:https://github.com/meathill/fasttest,有需要的同学请自由取用。对其中任何技术点有问题都可以提问或者开 issue。

    网站地址在 https://afasttest.com

    视频还需要一些时间来整理,将来慢慢放出来吧。着急且有时间的同学可以自行从百度网盘下载:https://pan.baidu.com/s/1KPuCM-9gPd0hQsr5Df_PnA 提取码: w8f2。

  • nginx 笔记

    nginx 笔记

    基础配置

    # daemon on;
    # worker_processes 1;
    error_log logs/travis.error.log error;
    pid logs/travis.nginx.pid;
    
    events {
        accept_mutex off;
    }
    
    http {
    
        server {
            listen 9000;
    
            include mime.types;
    
            location / {
                rewrite ^ /static/edge/index.html last;
            }
    
            location /admin-api/ {
                proxy_pass https://admin-dev.openresty.com.cn;
                proxy_set_header Host admin-dev.openresty.com.cn;
                proxy_ssl_name "admin-dev.openresty.com.cn";
                proxy_ssl_server_name on;
            }
    
            location /static/ {
                alias fe/dist/static/;
            }
        }
    }

    启动 nginx

    nginx -p $PWD -c conf/travis.conf

    其中,-p $PWD 指定当前目录为工作目录。-c 指定配置文件。

    reload

    找到配置中的 pid 文件,从里面找到 pid

    kill -s HUP ${pid}

    域名 A 返回 a 文件,域名 B 返回 b 文件

    如果同一个项目下,我们有两个 robots.txt 文件,希望根据域名输出不同的文件,可以用条件判断 + rewrite

    注意,nginx 不支持 else,只能纯 if

    server {
        location /robots.txt {
            if ($host = mywordle.org) {
                rewrite ^ /robots.mywordle.org.txt break;
            }
            if ($host = mywordgame.com) {
                rewrite ^ /robots.mywordgame.com.txt break;
            }
            try_files $uri =404;
        }
    }
  • 在 Fedora 34 上启动 VNC DISPLAY

    在 Fedora 34 上启动 VNC DISPLAY

    大家可以先阅读 使用 Node.js 驱动 FFmpeg 在 Linux + vncserver 下完成视频录制 了解产品目标和技术选型。

    前两天在系统更新里看到 Fedora 34 发布,作为更新党,我当然迫不及待就升级了。升级过程蛮顺利的,升级后,系统里的“在线账户”也能正常走 VPN 了,感觉还蛮好的。

    然后,前两天需要调试录视频的程序,发现新系统的 tigerVNC 有一个巨大的变化:不再支持用 vncserver 命令创建虚拟显示器,必须用 systemctl start service,目的是方便绑定系统启动,因为很多服务器的运维需要自动化。

    不过这可苦了我。我是系统运维菜鸡,基本只能照抄文章,搞了半天也没搞好。不过感谢开源,在 GitHub issue 里讨论的只言片语让我知道了其实 vncserver 是个脚本,它调用的其实是 Xvnc 这个命令。

    那就好办了,我开始按图索骥,寻找 vncserverXvnc 之间的关联。最终找到解决方法如下:

    1. 修改 /etc/X11/Xwrapper.config,加入 allowed_users=anybody。这样才能直接使用 Xvnc 创建虚拟显示器,不然会报告只有 console 用户才能创建的错误。
    2. 使用 vncpasswd 命令创建密码文件,创建后的密码文件位于 ~/.vnc/passwd
    3. 然后用 Xvnc :5 -geometry 1280x720 -PasswordFile ~/.vnc/passwd 创建显示器,跟之前的命令很类似,不过需要 -PasswordFile 选项指定密码
    4. 使用 VNC viewer 登录 VNC,输入密码。(我不知道这一步是否必须)
    5. 可以继续使用 DISPLAY=:5

    不过问题并没有完美解决,虽然我的 puppeteer JS 能跑,FFmpeg 也能录。但是 DISPLAY=:5 firefox https://cn.bing.com 只会在当前屏幕打开窗口,不知道为什么。留待以后解决吧。

    最后吐槽下,这种稳定版里换大版本的行为真的要不得,开源团队也不能滥用自己的地位。


    参考阅读

  • 移除 Puppeteer 里的保存密码提示窗

    移除 Puppeteer 里的保存密码提示窗

    大家知道,当我们使用 Chrome 完成登录的时候,Chrome 会询问我们是否要保存密码,如下图所示:

    图一 询问是否保存密码的弹窗

    但是在录制视频的时候,这个弹窗就不太必要了,甚至有些干扰,所以我们就想把它移除。如果搜索“puppteer prevent save password modal”,多半会被引导到这个页面,并得到结果:

    --enable-automation 添加到启动标记里。

    对应 Puppeteer 就是 puppeteer.launch(options) 里的 args,默认已经启用。但是使用这个选项会提示“Chrome is being controlled by automated test software.”,对录视频来说也不够理想。所以只能另辟蹊径,再找别的方法。

    图二 标记浏览器被自动测试软件控制

    后来我发现在 设置 > 密码(chrome://settings/passwords)里,可以关闭“提示保存密码”的选项,就不会再弹窗了。那么,我们只要修改浏览器的默认设置,即可。

    我的第一反应是用 puppeteer 打开这个页面,然后修改配置。尝试之后发现不行,Puppeteer 不能打开非 http/https/file 协议的页面。接下来尝试修改 userDataDir 目录里的内容,我发现,如果使用 headless 即无窗口模式启动 puppeteer,生成的 user data 目录里的内容就很少,尤其没有 Default/Preferences 这个 json 文件,而后者才是保存浏览器设置的位置。

    所以正确的解法就是:

    1. 先用 headless: false 模式启动一次 puppeteer,获得全是默认值的 userDataDir
    2. 修改 userDataDir/Default/Preferences 文件,在根对象上添加 "credentials_enable_service": false,不保存密码
    3. 启动新的 puppeteer 录制视频前,先复制 userDataDir 到目标位置
    4. puppeteer.launch({ignoreDefaultArgs: ['--enable-automation'], userDataDir: '/path/to/userDataDir'}) 启动窗口时禁用 --enable-automation 选项,避免出现图二的提示条,并且使用刚才复制的 userDataDir 保存缓存等信息

    目前看来,这套解决方案最合适,希望对大家有帮助。

  • Linux 命令行科学上网

    Linux 命令行科学上网

    前些天因为工作需要,装了 Fedora 33 开发 Showman 的视频录制功能。准备顺势双机工作一段时间,自然也就需要给新系统配置好科学上网。这里简单记录一下过程,方便日后回查。

    0. 更新系统

    第一部当然是更新系统,保证系统组件均为最新版,这样可以规避大多数问题。

    sudo dnf update

    1. 使用 pip 安装 shadowsocks 客户端

    Fedora 33 自带 python 3.9,所以直接使用 pip 安装 shadowsocks 客户端即可:

    sudo pip install shadowsocks

    这里可以用 sudo 也可以不用,差别就在于,sudo 之后会将执行脚本安装到 /usr/local/bin;而不带 sudo 则会安装到 ~/.local/bin。两者的执行环境不一样。我无意间使用了后者,所以后面也会按照后者来记录。

    2. 配置客户端

    接下来编辑配置文件,如果是图形界面,建议使用 IDE,纯命令行的话用 vim 也可以。配置文件一般放在 /etc/shadowsocks.json。内容大概如下,顾名思义,我就不一一解释了:

    {
      "server":"server-ip",
      "server_port":8000,
      "local_address": "127.0.0.1",
      "local_port":1080,
      "password":"your-password",
      "timeout":600,
      "method":"aes-256-cfb"
    }

    与前面 Ubuntu 20.04 科学上网 一样,本文不打算介绍服务器的制备——其实我也不建议大家自己制备服务器,除非你本来就有一些资源或者需求。不然的话,以我的经验,比较出名的迷你 VPS(类似 Vultr,DO,$5/月甚至 $2.5/月),IP 大部分都在规则库里,流量大一点,比如看个视频,不出半个小时 IP 就被封了,然后一个月后解封,基本没法用。

    如果你是 macOS 或者 iPhone,或者其它支持 AnyConnect 的系统,可以考虑直接买现成的,比如链接里这个

    3. 配置系统代理

    然后启动服务:

    sslocal -c /etc/shadowsocks.json -d start
    • -c 用来指定配置文件的地址
    • -d 表示启动服务

    这里可能会遇到几个问题(也是上次我放弃命令行转投 qt5 客户端的原因)。我们来逐个解决它们:

    3.1 openssl 错误

    执行后报错:

    $ sslocal -c /etc/shadowsocks.json -d start
    INFO: loading config from /etc/shadowsocks.json
     2021-05-05 15:17:00 INFO     loading libcrypto from /root/anaconda3/lib/libcrypto.so.1.1
     Traceback (most recent call last):
       File "/root/anaconda3/bin/sslocal", line 8, in <module>
         sys.exit(main())
       File "/root/anaconda3/lib/python3.9/site-packages/shadowsocks/local.py", line 39, in main
         config = shell.get_config(True)
       File "/root/anaconda3/lib/python3.9/site-packages/shadowsocks/shell.py", line 262, in get_config
         check_config(config, is_local)
       File "/root/anaconda3/lib/python3.9/site-packages/shadowsocks/shell.py", line 124, in check_config
         encrypt.try_cipher(config['password'], config['method'])
       File "/root/anaconda3/lib/python3.9/site-packages/shadowsocks/encrypt.py", line 44, in try_cipher
         Encryptor(key, method)
       File "/root/anaconda3/lib/python3.9/site-packages/shadowsocks/encrypt.py", line 82, in __init__
         self.cipher = self.get_cipher(key, method, 1,
       File "/root/anaconda3/lib/python3.9/site-packages/shadowsocks/encrypt.py", line 109, in get_cipher
         return m[2](method, key, iv, op)
       File "/root/anaconda3/lib/python3.9/site-packages/shadowsocks/crypto/openssl.py", line 76, in __init__
         load_openssl()
       File "/root/anaconda3/lib/python3.9/site-packages/shadowsocks/crypto/openssl.py", line 52, in load_openssl
         libcrypto.EVP_CIPHER_CTX_cleanup.argtypes = (c_void_p,)
       File "/root/anaconda3/lib/python3.9/ctypes/__init__.py", line 395, in __getattr__
         func = self.__getitem__(name)
       File "/root/anaconda3/lib/python3.9/ctypes/__init__.py", line 400, in __getitem__
         func = self._FuncPtr((name_or_ordinal, self))
     AttributeError: /root/anaconda3/lib/python3.9/lib-dynload/../../libcrypto.so.1.1: undefined symbol: EVP_CIPHER_CTX_cleanup

    其中文件、行号可能有所不同,不过错误内容大多一致。

    这是因为 OpenSSL 升级至 1.1.0 以上后,内部 API 有一些变化,废弃了 EVP_CIPHER_CTX_cleanup() 函数而引入了 EVE_CIPHER_CTX_reset(),shadowsocks 客户端处于无人维护的状态,没有适配这些变化。

    好在修复方案并不复杂,我们只需要修改文件 ~/.local/lib/python3.9/site-packages/shadowsocks/crypto/openssl.py,将里面的 cleanup 都替换成 reset 即可。

    3.2 permission denied /var/run/shadowsocks.pid

    前面写过,因为我安装的时候没使用 sudo,所以把客户端装在当前用户的目录里。于是面临一个矛盾:

    1. 直接使用当前用户启动客户端,会报告标题里的错误
    2. 使用 root 即 sudo 启动客户端,会报告库有错误(即 3.1),因为我修改的是当前用户的本地库

    这里有两个解决方案,一是手动创建 pid,然后修改权限:

    sudo touch /var/run/shadowsocks.pid
    sudo chmod 777 /var/run/shadowsocks.pid

    这个方法重启后会失效,还得再跑一遍。所以我比较推荐另一种做法:使用 sudo -u $user -i 的方式,sudo 的同时仍然使用当前用户的环境(这个方案实测失败了,还要再研究下):

    sudo -u meathill -i sslocal -c /etc/shadowsocks.json -d start

    4. 加入自动启动

    上一步测试成功之后就可以把这段代码加入 /etc/rc.local,以便实现开机自动启动。

    5. 其它步骤

    接下来,需要配置 pac 文件、文件服务和系统代理,可以完全参考 Ubuntu 20.04 科学上网 一文。

    其中下载 gfwlist.txt 时,如果访问不到 https://raw.githubusercontent.com/gfwlist/gfwlist/master/gfwlist.txt,可以试着修改 /etc/hosts,加入下一行:

    199.232.28.133 raw.githubusercontent.com

    完成剩余步骤后,配置成功。


    在 macOS/iOS 设备上使用科学上网有更简单的方法,扫码可得(其实只要你有 AnyConnect,并不限制系统平台):