锐角云三角主机,入手两年多,一直在家作为私人服务器,稳定跑着各种服务。作为矿难出品,当年以299RMB入手,很多人说贵。但跑了这么久,现在感觉很超值。性价比高,秒杀各种ARM开发板。

先说缺点。当时由于是299,所以没有外壳和M.2接口的128GB SSD。为了不破坏自带的Win10,我另外买了个32GB SSD,装上Debian 10。30 多 RMB,包邮,型号是M.2 2242。CPU性能问题,不能胜任大型计算任务。试过编译minidlna,能跑到2.2Ghz,但仍然有点慢。

装系统时,注意要支持uefi32启动的。所以Debian 10的安装盘,只能用同时支持32位和64位的ISO文件。下载链接:
http://mirrors.163.com/debian-cd/10.2.0/multi-arch/iso-cd/

另外还有个硬伤,不能上电自动开机。如果家里电路不稳定,可能需要买个12V的UPS补救。另外,也可以参照以下教程,添加上电自动开机的功能:

N3450 锐角云硬改来电自启,加装3.5寸硬盘成功 https://hostloc.com/thread-713391-1-1.html

再来说优点吧。作为家用服务器,首先是省电。然后是8GB大内存(z8350那类电视棒一般是2GB或4GB)!CPU是N3450,x86架构,64位(服务器软件方面几乎没有障碍,例如直接跑Minecraft Bedrock),跑起来风扇不会经常转(就是噪音低)。接口丰富,千兆网卡、3个USB 3.0、HDMI 2.0、WiFi、蓝牙、SSD(M.2 2242)、耳机接口等。自带64GB存储,默认装Win10,就算不做服务器,作为高清播放器、办公电脑,也是可以。

原官网详细介绍(已不能访问)
https://www.acuteangle.com/product.html

更多相关资料,可以搜索关键词“锐角云 三角主机”。

本文记录了从jQurey转到原生JavaScript开发的相关处理。

一 历史

二十一世纪初,IE 6还在统治浏览器的时代,出现了一批JavaScript框架。除了提高前端开发效率,还屏蔽了各个浏览器的JavaScript接口差异。那时有3个产品印象比较深刻:

  1. prototype,http://prototypejs.org/
    其特点是在原生JavaScript基础上做扩展,定义通用的方法或接口,屏蔽各个浏览器的差异。很轻量,个人比较喜欢。
  2. Ext JS,https://www.sencha.com/products/extjs/
    数据与界面分离,提供丰富的UI组建,便于页面开发。当时浏览器JavaScript性能不高,用起来不够流畅,不适合简单排版布局的页面。但是对于开发一些管理系统,确实很方便。
  3. jQuery,https://jquery.com/
    最大特别是查找HTML元素很方便(前提是熟悉其搜索语法),有点函数式编程的味道。在那个需要手工修改HTML界面的年代,确实很方便。

二 当前

看看当前的浏览器,已经是Webkit内核的天下,加上IE已亡,ECMAScript 6普及……各个浏览器的JavaScript兼容性大大提高。所以,我们可以直接采用浏览器原生JavaScript,替代jQuery这类用于遍历或搜索DOM的框架。当然,复杂的界面,主要是响应式前端框架(AngularJS、React、VUE)的世界。

三 实现方法

主要参考这个文章,从jQuery转到原生JavaScript。

  • 你也许不需要 jQuery (You (Might) Don't Need jQuery)

https://github.com/nefe/You-Dont-Need-jQuery/blob/master/README.zh-CN.md

另外,对于页面上的异步请求(ajax),该文章没有提出timeout的处理。以下整理一个示例:

// 请求错误的类,用于传递错误信息
let RespError = class {
    constructor(code, msg, respJson) {
        this.code = code;
        this.msg = msg;
        this.respJson = respJson;
    }
};

// POST提交Json数据。调用ajaxJson方法前加上async就是同步调用,直接调用就是异步调用
// 默认超时10秒
let ajaxJson = async (url, formParam={}, onSuccess=(respJson)=>{}, 
        onFailed=(respError)=>{}, timeoutSec=10) => {
    let controller = new AbortController();
    let timeoutId = setTimeout(() => {
            // 超时后停止请求
            controller.abort();
            // 抛出超时的错误
            onFailed(new RespError(-1, 'TIMEOUT', null));
        }, timeoutSec * 1000);
    try {
       // 发起请求
        let response = await fetch(url, {
            signal: controller.signal, // 用于接收中断请求信号
            method: 'POST',
            cache: 'no-cache',
            headers: {
                // 声明请求的参数是JSON
                'Content-Type': 'application/json; charset=UTF-8'
            },
            body: JSON.stringify(param)
        });
        // 注意,响应的数据只能获取一次,包括response.json()和response.text()
        let respJson = await response.json();
        if(!response.ok) {
            // 请求失败,抛出自定义的错误对象
            throw new RespError(response.status, response.statusText, 
                respJson);
        }
        onSuccess(respJson);
    } catch(err) {
        onFailed(err instanceof RespError ? err 
            : new RespError(-1, err.message, null));
    } finally {
        // 请求结束,停止执行定时函数。避免相应成功后,抛出超时的错误。
        clearTimeout(timeoutId);
    }
};

关于Fetch的使用,参考:

  1. Fetch API 教程

https://www.ruanyifeng.com/blog/2020/12/fetch-tutorial.html

  1. mdn web docs - 使用 Fetch

https://developer.mozilla.org/zh-CN/docs/Web/API/Fetch_API/Using_Fetch

用过Flutter,对数据的响应式,有进一步了解。于是,回过头来再看看VueJS,自然而然地理解了。

找到一个比较好的Vue3示例代码,展示了使用Vue3的主要代码,涉及Vue3的模板语言、组件等。

Vue3 起步简单示例
https://blog.csdn.net/thankseveryday/article/details/124741733

我在代码加上注释,更容易理解:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://unpkg.com/vue@next"></script>
<title>Learn Vue3</title>
</head>

<body>
<!-- 界面模板 -->
<div id="app">
    <!-- 两个大括号,获取data()方法返回对象的属性的值,给文本绑定值 -->
    <p>{{counter}}</p>
    <p>
        <!-- :title是v-bind:title的缩写,给HTML标签的属性绑定值 -->
        <span :title="message">{{message}}</span>
    </p>
    
    <!-- @click 是 v-on:click 的缩写,指定事件的执行方法 -->
    <p><button @click="reverseMessage">翻转文字</button></p>
    
    <!-- “v-”前缀的特殊属性,是Vue的指令 -->
    <!-- 使用 v-model 指令来实现双向数据绑定 -->
    <p><input type="text" v-model="message"></p>
    
    <!-- v-if ,当值为true时,才显示对应HTML -->
    <p v-if="seen">你能看到我吗?</p>
    
    <p><button @click="seenYN">显示/隐藏</button></p>
    <h4>我爱吃的水果:</h4>
    <ul>
        <!-- v-for,循环输出HTML -->
        <li v-for="fruit in fruits">{{fruit}}</li>
    </ul>
    <h4>周末计划:</h4>
    <ol>
        <!-- todo-item对应自定义组件TodoItem -->
        <todo-item v-for="todo in todos" :todo="todo" :key="todo"></todo-item>
    </ol>
</div>
<script>
// 定义自定义组件
const TodoItem = {
    props: ['todo'],
    template: '<li>{{todo}}</li>'
}

// 数据对象,有点像Flutter的State或者Provider
const dataObj = {
    // 声明用到的自定义组件
    components: {
        TodoItem
    },
    
    // 定义数据对象,有用的属性可以先设置null,用于占位
    data() {
        console.log("vue3 demo, data()");
        return {
            counter: 0,
            message: "hello vue",
            seen: true,
            fruits: ["apple", "orange", "bananas"],
            todos: ["钢琴课", "绘画课", "看电影"]
        }
    },
    
    // 在模板渲染成html前调用,即通常初始化某些属性值,然后再渲染成视图
    created() {
        console.log("vue3 demo, created()");
    },
    
    // 在模板渲染成html后调用,通常是初始化页面完成后,再对html的dom节点进行一些需要的操作。
    mounted() {
        console.log("vue3 demo, mounted()");
        setInterval(() => {
            this.counter++
        }, 1000);
    },
    
    // 操作数据的方法,一般用于页面元素的事件处理
    methods: {
        reverseMessage: function () {
            // 操作数据,让Vue更新界面
            this.message = this.message.split('').reverse().join('');
        },
        seenYN: function () {
            this.seen = !this.seen;
        }
    }
}

// 数据与模板绑定
Vue.createApp(dataObj).mount('#app')
</script>
</body>
</html>

更详细的介绍和相关文档,可以访问Vue3的官网:
https://v3.cn.vuejs.org/guide/introduction.html#vue-js-%E6%98%AF%E4%BB%80%E4%B9%88

注:本文不是教程。纯粹以玩和分享的角度做记录。

现今时代,微波炉已经足够廉价,可以成为普通家用电器。微波炉也是个加热工具,所以一直想试试用来烘培咖啡豆,但不知如何操作。直到刷到这个视频(注意,up主是个居住在中国的日本人,应该是用翻译软件翻译出来的中文,所以看起来有点别扭):

微波炉也能烘焙咖啡豆妈?使用微波炉的咖啡烘焙方法
https://www.bilibili.com/video/BV15Z4y1t77z

一 所需器具

  1. 微波炉,只要带调火力的功能的即可。
  2. 外壁比较厚(0.5cm左右吧)的玻璃器具,可以观察豆子的颜色变化,也可以锁住热量。我是用两个玻璃饭盒,一大一小。小的反过来,可以被大的包住。
  3. 隔热手套一双。
  4. 计时器。可以用手机自带的计时器(秒表)。最好是两个,一个是记录操作时间,一个记录总的烘培时间。没有的话,总的烘培时间可以看手机的时间。
  5. 金属盘子。刚出炉的咖啡豆比较热,最好用金属盘子装着。
  6. 风扇。下豆后给豆子降温。比较专业的,应该是用“去银皮冷却盘散热器”。
  7. 电子秤。

二 操作方式

视频的操作是,微波炉(分5档,低火、中低火、中火、中高火、高火)开高火,每隔30秒拿出来摇晃几下。130g生豆,约7分钟后一爆,约10分钟后二爆,约12分钟下豆。

我第一次试,50g生豆,用玻璃茶壶(外壁比较薄)。开高火,第一个30秒还没到就黑掉几颗,赶紧转中高火。可是全程中高火,烘了14分钟也没有一爆,但豆子没有糊。估计是没能锁住热量。

后面一次,110g生豆,用两个玻璃饭盒(外壁比较厚)。先开中高火,约5分钟后转黄,再开高火,约9分钟后(时间有点忘记了)一爆,一爆过后下豆。效果比较好。但是由于两个饭盒盖得比较紧,拖慢了下豆时间,导致有点过烘。

就是,没有一次成功的经历,但可以总结一些经验。正如该视频下的有个留言,“小火脱水,中火转黄,大火发展”,烘豆一般都是这样操作的过程。

三 需要注意

  1. 烘焙时,最好在室外,或者厨房里开排气、抽油烟机。毕竟不是每个人都喜欢烘焙时的气味。特别要注意下豆时,最好是在室外进行。因为整个烘焙过程的烟都被困在玻璃饭盒里,一打开就爆发出来,气味非常浓烈。
  2. 下豆最好在室外进行,第二个原因是要处理银皮。有专业机器的话,可以无视这点。
  3. 烘焙成品不均匀。应该是没有在烘培过程中进行搅拌而导致的。
  4. 烘焙过程不能测温。一般温度计不能放进微波炉,拿出来摇晃又不能打开(避免导致豆子失温)。
  5. 根据微波炉的加热原理,会导致豆子从内部发热出来,跟传统的烘豆(从外部给豆子加热)不同。不知道是不是这个原因,豆子太少(例如50g)就不能达到一爆。

四 总结

总的来说,微波炉烘豆,值得玩玩。毕竟器具都很家常,整个过程能够观察咖啡生豆的颜色和气味变化,烘焙过程也不会有银皮乱飞的烦恼。

但不建议作为日常烘豆。不能搅拌、不能测温,基本就不能控制并稳定出品的风味。

今年受疫情影响,几乎所有芯片都涨价了。但是合宙ESP32C3-CORE却奇迹地以9.9RMB包邮,其搭配的Air101-LCD屏幕扩展板(0.96寸)也是9.9RMB包邮。甚是吸引,于是入手了一套,主板+屏幕。

注:以下操作,以基于Debian的Linux发行版为例。

一 概述

合宙ESP32C3-CORE简单总结如下:

  1. 采用乐鑫科技的ESP32-C3芯片,搭载RISC-V 32位单核处理器,支持2.4 GHz Wi-Fi 和Bluetooth 5 (LE)。
  2. 板载4MB闪存。
  3. USB Type-C接口,集成CH343(带TTL串口转USB)。新版好像改为USB直连了。

相关资料

二 MicroPython

由于合宙的Lua OS采用Lua语言,虽然官方在努力,但本人不熟悉,就选择了更好玩的MicroPython。

相关资料

三 刷机

MicroPython官方关于ESP32-C3的固件及刷机教程:
https://micropython.org/download/esp32c3/

1. 安装USB串口驱动

Windows,需要安装CH343的驱动。我使用Lubuntu 20.04,自动识别。另外,新版的合宙ESP32C3-CORE应该也不用装。

2. 刷机工具

安装Python 3.8或3.7后,再装刷机工具esptool。使用sudo安装,是方便所有用户都可以用。使用pip3是指定安装Python3的版本。

sudo pip3 install esptool

3. 下载固件

在MicroPython官方网站 https://micropython.org/download/esp32c3/ 底部的Firmware -> Releases,下载最新版本的固件。

4. 清除原固件

--port为端口,要根据实际填写,我电脑上的是/dev/ttyACM0

sudo esptool.py --chip esp32-c3 --port /dev/ttyACM0 erase_flash

5. 刷入固件

--port为端口,/opt/download/esp32c3-20220618-v1.19.1.bin为MicroPython固件文件。另外,如果刷入不成功,可以多刷几次。

sudo esptool.py --chip esp32-c3 --port /dev/ttyACM0 --baud 460800 write_flash -z 0x0 /opt/download/esp32c3-20220618-v1.19.1.bin

四 开发

推荐使用Thonny作为开发IDE。可以先不上传代码而直接运行,也可以看到开发板上的文件。

相关资料

先安装python3-tk

sudo apt install python3-tk

再安装thonny

sudo pip3 install thonny

运行

thonny

插上开发板,在Thonny进入 工具 -> 设置 -> 解释器 -> 选择解释器为“MicroPython (ESP32)”,然后就可以开发了。

五 点亮屏幕

Air101-LCD屏幕的使用有几点需要注意的:

  1. 不能使用HSPI(硬件SPI),只能使用软SPI,即SoftSPI
  2. 该屏颜色不对,因此需要定义函数来生成正确的颜色。
  3. 横屏时,即tft.rotation(1),x轴不偏移,y轴偏移24像素。相反,竖屏时,即不写tft.rotation(1),x轴偏移24像素,y轴不偏移。
  4. 屏幕的RKey应该接到ESP32C3-CORE的GPIO13,但不知道为什么不能读取点击事件,于是该为接在GPIO19。

相关资料

写了个示例代码显示一些信息(如下),保存为main.py,连同ST7735驱动文件ST7735.py、英文字体文件terminalfont.py

from machine import Pin, SoftSPI, SPI
from ST7735 import TFT
import time
from terminalfont import terminalfont
import network
import ubinascii

# 由于TFT屏的颜色有问题,因此需要重写一个函数修复一下
def TFTColor(r,g,b) :
  return ((b & 0xF8) << 8) | ((g & 0xFC) << 3) | (r >> 3)

spi = SoftSPI(baudrate=1000000, polarity=1, phase=0, sck=Pin(2), mosi=Pin(3), miso=Pin(10))
tft=TFT(spi,6,10,7) #DC, Reset, CS
tft.initr()
tft.rgb(True)
tft.rotation(1) # 横屏显示

# 绘制背景色
tft.fill(TFTColor(0,0,0))

# 绘制方块
#tft.fillrect((0,24),(20,20),TFTColor(0,0,255))

# 显示文字
tft.text((0,24),'mac',tft.WHITE,terminalfont,2)

# 显示MAC
mac = ubinascii.hexlify(network.WLAN().config('mac')).decode()
tft.text((0,40),mac,tft.WHITE,terminalfont,2)

# 显示运行秒数
from machine import Timer
sec = 0
def showTime(t) :
    global sec
    sec += 1
    tft.fillrect((0,56),(160,20),TFTColor(255,255,255))
    tft.text((0,60),f'Run {sec} sec',tft.BLACK,terminalfont,2)

# 运行定时器
tim0 = Timer(0)
tim0.init(period=1000, mode=Timer.PERIODIC, callback=showTime)

# 把按键信息显示在屏幕的函数
def showDirect(t) :
    global tft
    tft.fillrect((0,76),(160,16),TFTColor(0,0,0))
    tft.text((0,78),str(t),tft.WHITE,terminalfont,2)

# 设置按键的接口
from machine import Pin
keyL = Pin(9, Pin.IN, Pin.PULL_UP)
keyU = Pin(8, Pin.IN, Pin.PULL_UP)
keyC = Pin(4, Pin.IN, Pin.PULL_UP)
keyD = Pin(5, Pin.IN, Pin.PULL_UP)
keyR = Pin(19, Pin.IN, Pin.PULL_DOWN)

keyL.irq(trigger=Pin.IRQ_FALLING, handler=showDirect)
keyU.irq(trigger=Pin.IRQ_FALLING, handler=showDirect)
keyC.irq(trigger=Pin.IRQ_FALLING, handler=showDirect)
keyD.irq(trigger=Pin.IRQ_FALLING, handler=showDirect)
keyR.irq(trigger=Pin.IRQ_RISING, handler=showDirect)

这是另一个程序,显示一个走动的方块:

from machine import Pin, SoftSPI, SPI
from ST7735 import TFT
import time

# 由于ftf屏的颜色有问题,因此需要重写一个函数修复一下
def TFTColor(r,g,b) :
  return ((b & 0xF8) << 8) | ((g & 0xFC) << 3) | (r >> 3)

spi = SoftSPI(baudrate=1000000, polarity=1, phase=0, sck=Pin(2), mosi=Pin(3), miso=Pin(10))
tft=TFT(spi,6,10,7) #DC, Reset, CS
tft.initr()
tft.rgb(True)
tft.rotation(1) #方向调整

# 绘制背景色
tft.fill(TFTColor(0,0,0))

w = 20
h = 20
max = 160
for i in range(0,max*4-1):
  x = i * 5 % max
  y = i * 5 // max * h + 24
  tft.fillrect((x,y),(w,h),TFTColor(255,255,255))
  ++i
  time.sleep(0.04)
  tft.fillrect((x,y),(w,h),TFTColor(0,0,0))

六 后续

显示优化的问题,仍未解决(如下)。后面应该会试试Arduino for ESP32-C3。

  1. 有个项目解决中文的显示的,但刷固件失败,放弃了。

支持中文显示的MicroPython固件 https://github.com/wangshujun-tj/mpy-Framebuf-boost

  1. 想使用LVGL显示更好的UI,但是编译失败,也放弃了。

Micropython + lvgl https://github.com/lvgl/lv_micropython

今晚计划做意大利面,于是CFO建议搭配个Pizza。以前做过很多次,一直没有实现满意的饼底。再次参考小高姐视频,做了个软底的,这次算是比较满意了。当然,还是没有视频中的烘焙石板。视频如下:
Youtube版:小高姐的 Magic Ingredients - 披萨做法 Pizza Margherita, Cheese Pizza and Steak Pizza
https://www.youtube.com/watch?v=ATEnM1YPQQE
Bilibili版:小高姐的魔法调料 - 详细的解说,带你做出真正经典的意大利披萨
https://www.bilibili.com/video/av35740109

1)饼底材料(刚好做一个家用烤箱盘子大小的):

  • 高筋面粉 125克
  • 温水 85克
  • 酵母 2克
  • 盐 1克
  • 油 8克
  • Pizza草 适量(没有可以不加)

2)配料(一般选用自己喜欢的即可):

  • 番茄酱
  • 马苏里拉芝士
  • 洋葱
  • 玉米
  • 培根、牛肉等

3)做法:

  • a)高筋面粉加入油和盐。
  • b)酵母加入温水融化后,倒进高筋面粉混合。醒面20分钟。
  • c)揉面,直到出现光面。面团表面抹油,发酵1个小时,面团大概成两倍大。
  • d)面团弄成烤盘大小的饼底,不要用擀面杖,用手甩或者拉扯面团。烤盘铺上硅胶纸(不用硅胶纸的,可以在烤盘刷油,防粘),放上整理好的饼底。
  • e)饼底表面撒上Pizza草,再刷层薄油、涂上番茄酱、铺上马苏里拉芝士,最后铺上其它材料。
  • f)烤箱最高温(我的是230°C)预热10分钟,然后放入Pizza烤大概10分钟。一般马苏里拉开始烤焦,就差不多可以出炉。

近来工作中用上了Flutter,并且使用了Provider作为状态管理,确实爽,但是也踩了一下坑。

一 概述

Provider是基于InheritedWidget组件,使用观察者模式 + 生产者消费者模式,实现状态共享,简直就是为了取代StatefulWidget而存在。相关资料:

Provider的Flutter插件网址:
https://pub.dev/packages/provider

Provider的官方中文说明:
https://github.com/rrousselGit/provider/blob/master/resources/translations/zh-CN/README.md

二 总结

  1. Provider可以定义在任意地方,其状态只提供给其子Widget访问。例如,定义在App之上可实现全局的状态共享的状态,定义在页面之上可实现页面内的状态共享。
  2. Provider的子Widget(即child参数)不能传入StatelessWidget对象,或者说不能直接直接传入Widget对象,否则后面的所有孙Widget不能通过context获取其状态。解决方案是使用builder参数,传入构建子Widget的函数,或者child参数设置带有builder函数的Widget,例如Builder对象。
  3. 数据变化,必然导致重绘。所以不要过于担心是否重绘,而重点关注重绘的点在哪里,如何减少重绘的Widget。重绘Widget,会向上找到最近的builder方法并执行。所以需要重绘的Widget,最好放在其builder方法内。需要变化的StatelessWidget对象,用Builder类的builder方法包裹,是个很好的做法。
  4. Provider是以类型区分数据的。如果是多个相同数据类型(例如int类型)的状态,则需要定义不同的类,且都含有该数据类型(例如int类型)的属性。
  5. 定义多个Provider,可以使用MultiProvider。
  6. 组合多个Provider对象,可以使用ProxyProvider。

三 Provider类型

一般使用ChangeNotifierProvider就可以,更多的Provider类型如下:

类型描述
Provider最基础的 provider 组成,接收一个任意值并暴露它。
ListenableProvider供可监听对象使用的特殊 provider。ListenableProvider 会监听对象,并在监听器被调用时更新依赖此对象的 widgets。
ChangeNotifierProvider为 ChangeNotifier 提供的 ListenableProvider 规范,会在需要时自动调用 ChangeNotifier.dispose。
ValueListenableProvider监听 ValueListenable,并且只暴露出 ValueListenable.value。
StreamProvider监听流,并暴露出当前的最新值。
FutureProvider接收一个 Future,并在其进入 complete 状态时更新依赖它的组件。

四 监听方式

获取Provider的状态,有以下三种方式:

  1. read,即只读。只获取状态,不进行监听。示例代码:

    // 使用Provider.of,需要加上参数“listen: false”
    T t = Provider.of(context,listen: false));

    // 使用context.read方法最简单
    T t = context.read();

  2. select,即只监听指定数据。指定数据有变化,才会执行重绘。

    // 使用Selector类,可以定义builder方法
    Selector<T, R>(
    selector: (_, t) {return t.r;},
    builder: (_, r, __) {return Text('${r}');}
    );

    // 使用context.select方法最简单。如果取出的数据需要重绘,则最好用Builder类包裹一下
    R r = context.select<T,R>(R cb(T value));

  3. watch,即监听状态的变化。状态有任何变化,都会执行重绘。

    // 使用Consumer类,可以定义builder方法
    Consumer(
    builder: (_, t, __) {return Text('${t.r}');}
    );

    // 使用Provider.of方法。如果取出的数据需要重绘,则最好用Builder类包裹一下
    T t = Provider.of(context);

    // 使用context.watch方法最简单。如果取出的数据需要重绘,则最好用Builder类包裹一下
    T t = context.watch();

为了消灭家中的低筋面粉库存,找到“奶香馒头”的视频。试着做了一下,不难,吃起来也可以。

松软光滑不塌陷的鲜奶馒头 学会了 这就去跟面粉对线!
https://www.bilibili.com/video/BV1gD4y1m7pL

材料:

  • 牛奶:175g(可换成水,约160g)
  • 中筋/低筋:300g
  • 糖:10g
  • 酵母:3g

做法:

  • 1, 容器(杯子或碗)倒入牛奶,加入酵母化开。
  • 2, 盆子倒入面粉和糖,边加入牛奶边搅拌,然后用手揉成团(太硬可加水,太软可加面粉),盖上盖子静置5分钟。
  • 3, 取出面团,揉到表面光滑,大概10分钟。这一步会影响馒头蒸出来的表面是否光滑。
  • 4, 把面团擀成大概20cm*40cm的面片,手上粘水并抹在面片上,下面长边按薄,从上面长边一直卷下来(尽量避免有空隙),再用手搓成直径4cm左右的长条。
  • 5, 面粉长条切成约4cm长的剂子,并放在蒸笼发酵至1.5倍大(24°C室温约55分钟)。不盖盖子发酵,可以让表皮稍微干燥硬实一点。室温低(特别是冬天)且干燥,最好表皮喷水并盖上盖子,否则表皮太干燥会导致蒸的时候裂开。
  • 6, 开水上锅蒸,上汽(即看到有蒸汽冒出蒸笼)后转为中火,蒸12分钟。蒸后等5分钟再揭开,避免塌陷。

总结:

  • 因为一盒天润牛奶是125g,所以把低筋按比例调整为215g。这个面粉和牛奶的比例刚好,面团不会太湿也不会太干。
  • 我是先把牛奶放微波炉叮半分钟,接近体温,再依次加入糖和酵母。这样更容易激活酵母。
  • 这个配方的面团,应该适合做豆沙包、奶黄包之类。

最近完成了一个小项目的数据库迁移,从微软SQL Server 2016迁移到MySQL 8。过程没什么复杂,只是需要注意一下数据类型和SQL语法的转换。

1 环境

原数据库是SQL Server 2016。迁移的目标环境,操作系统为Debian 11,安装了MySQL 8。

2 还原SQL Server数据库备份

拿到手的是SQL Server数据库备份,需要还原出来再迁移。幸好微软推出了SQL Server的Linux版,而且官方提供了可用于开发测试的Docker镜像,几个步骤就部署并还原好SQL Server数据库。

参考资料:

1)在Debian上安装Docker的官方教程:
Install Docker Engine on Debian
https://docs.docker.com/engine/install/debian/

2)运行SQL Server 2019 Docker镜像的官方教程:
Quickstart: Run SQL Server container images with Docker
https://docs.microsoft.com/en-us/sql/linux/quickstart-install-connect-docker?view=sql-server-ver15&pivots=cs1-bash

3)SQL Server 2019的微软官方Docker镜像:
dockerhub - Microsoft SQL Server
https://hub.docker.com/_/microsoft-mssql-server

3 MySQL的准备

由于SQL Server的数据库表名不区分大小写,MySQL为了兼容相关SQL语句,也需要设置表名不区分大小写。即设置MySQL的参数lower_case_table_names=1,MySQL在存储和查询时,都把表名转为小写后再执行处理。

这里最麻烦的是,如果MySQL原来设置了lower_case_table_names=0(一般Linux上安装MySQL的默认值),需要把data文件夹清空,更新设置后重新初始化MySQL的数据。如果直接更改该值,MySQL重启后会报错。

关键的操作步骤:

1)修改MySQL的配置文件(Debian的默认路径为:/etc/mysql/mysql.conf.d/mysql.cnf),在[mysqld]节点下,加入一行lower_case_table_names=1

2)重新初始化MySQL(已有数据库的话,先做好备份,初始化后再还原),先清空数据文件夹(Debian的默认路径:/var/lib/mysql),然后执行以下命令:

mysqld --user=root --initialize --lower-case-table-names=1

初始化成功后,root用户的密码会记录在/var/log/mysql/error.log

4 迁移数据库定义

即导出原数据库表的create语句。一般推荐使用MySQL Workbench的Migration功能,官方文档如下:

MySQL Workbench - Using the MySQL Workbench Migration Wizard
https://dev.mysql.com/doc/workbench/en/wb-migration-wizard.html

但是我所安装的MySQL Workbench不能连接到Docker部署的SQL Server,所以使用了已安装的HeidiSQL,导出原数据库表的create table语句,然后手工修正为MySQL的语法。一些修改操作如下:

  • 修正字符编码,特别是设置了COLLATE的,需求改为COLLATE utf8mb4_0900_ai_ci
  • 修正默认值设置,例如DEFAULT '(0)'改为DEFAULT '0'DEFAULT getDate()改为DEFAULT CURRENT_TIMESTAMP
  • 自增型字段会被忽略,需要加上AUTO_INCREMENT
  • 字段类型转换,例如NVARCHAR改为VARCHARBIT改为TININY(1)MONEY改为DECIMAL(19,4)
  • 需要补上索引设置。

5 迁移数据库的数据

即导出所有数据的insert语句,然后在目标数据库利用source命令进行导入。一般也是推荐使用MySQL Workbench操作,不用担心语法和数据类型的问题。

我使用了DBeaver导出所有表的insert语句,然后手工修正为MySQL语法。需要注意:

  • 一般一条insert语句包含10000行数据,已提高导入时的效率。
  • 所有表名以数据库名.dbo开头的,都改为以数据库名开头。
  • 列名以中括号“[]”括住的,要改为“\`”。

6 修改程序的SQL语句

主要是把SQL Server的语法,改为MySQL的语法。总结如下:

  • TOP改为LIMIT
  • getDate()改为CURRENT_TIMESTAMP
  • 去掉表名前的dbo.
  • WITH(NOLOCK)的处理。SQL Server加了WITH(NOLOCK)的语句,如果MySQL的InnoDB设置innodb_autoinc_lock_mode=0,需要特殊处理该语句,否则直接去掉WITH(NOLOCK)。关于InnoDB的设置说明如下:

MySQL innodb_autoinc_lock_mode 详解
https://www.cnblogs.com/JiangLe/p/6362770.html

当Service方法被内部调用时,Spring注解会失效。就是Spring的Service类,如果public方法加上注解,类内部的其它方法使用this调用该方法,会导致注解失效。

例如Spring的Service实现类如下:

@Service("userService")
class UserServiceImpl implements UserService{
    
    @Override
    @Cacheable(CacheNames="USER_CACHE", key="#userId")
    public User getUser(String userId){
        // do something
    }

    @Override
    public String getUserName(String userId) {
        User u = this.getUser(userId); // getUser方法的@Cacheable注解失效了
        return u == null ? "" : u.getName();
    }
}

原因是this引用的对象没有被Spring代理,调用该对象的public方法时,Spring不能处理相关注解。

解决方法很简单,就是使用Spring代理过的对象,代替this。然后只需解决如果获取该Service类被Spring代理过的对象。


1 循环依赖

就是自己注入自己。在Service类定义一个自身对象的属性,让Spring装配时把自己注入到自己。虽然Spring 5(具体版本待查证)声称解决了循环依赖的问题,但是Spring Boot 2.6.0开始默认设置不允许循环依赖。循环依赖是一个古老的问题,一样认为要避免。所以此方法不推荐

1)先设置

# Spring Boot 2.6.0之后,允许循环依赖
spring.main.allow-circular-references = true

2)上面的例子改为

@Service("userService")
class UserServiceImpl implements UserService{
    
    @Autowired
    private UserService self; // 自己注入自己
    
    @Override
    @Cacheable(CacheNames="USER_CACHE", key="#userId")
    public User getUser(String userId){
        // do something
    }

    @Override
    public String getUserName(String userId) {
        User u = self.getUser(userId); // 用self代替this,注解生效
        return u == null ? "" : u.getName();
    }
}

2 获取装配后的自己

避免Bean的循环依赖,主要思路是,在Bean装配完成后,再获取被Spring代理的自己。至于怎样获取,实现方法是多种多样的。

方法1,从Bean容器中获取自己。即:

UserService self = applicationContext.getBean("userService");

至于怎么获取Bean容器applicationContext,方法也是多样的。

方法2,开启AspectJ自动代理来获取自己。

详细参考:一个Spring AOP的坑!很多人都犯过!

要注意,AspectJ自动代理不只是解决本文档的问题,需考虑是否会带来未知的问题。

开启AspectJ自动代理的方法有多种,这里列出三种:

1)在启动类添加注解:

@EnableAspectJAutoProxy(proxyTargetClass=true, exposeProxy=true)

2)Spring增加配置:

<aop:aspectj-autoproxy proxy-target-class="true" expose-proxy="true" />

3)Spring Boot的配置文件增加配置:

# 开启AspectJ自动代理
spring.aop.auto=true
# 开启CGLIB代理
spring.aop.proxy-target-class=true

然后就可以在当前Service类的方法中,通过类似的代码调用自身的方法,且能保证该方法的注解正常执行:

User u = ((UserService) AopContext.currentProxy()).getUser(userId);

方法3,延迟执行自己注入自己。

很简单,就是使用@Lazy注解,达到Bean初始化不执行自己注入自己,避免循环依赖的错误。我记得解决Spring 2.x循环依赖的问题时,也是采用延迟注入的配置。此方法写的代码最少,目前倾向采用这种方法。于是,上面的代码改为:

@Service("userService")
class UserServiceImpl implements UserService{
    
    @Lazy
    @Autowired
    private UserService self; // 延迟自己注入自己
    
    @Override
    @Cacheable(CacheNames="USER_CACHE", key="#userId")
    public User getUser(String userId){
        // do something
    }

    @Override
    public String getUserName(String userId) {
        User u = self.getUser(userId); // 用self代替this,注解生效
        return u == null ? "" : u.getName();
    }
}