<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/_style/default.xsl" type="text/xsl"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<generator><![CDATA[Typlog (https://typlog.com/)]]></generator><pubDate>Wed, 06 May 2026 12:39:04 +0000</pubDate><atom:link href="https://pubsubhubbub.appspot.com/" rel="hub"/><atom:link href="https://blog.kevinzhow.com/feed.xml" rel="self" type="application/rss+xml"/>
<title><![CDATA[Kevin Blog]]></title><description><![CDATA[All about life moments and my products.]]></description><link>https://blog.kevinzhow.com/</link><copyright><![CDATA[Copyright 2023 Kevin Blog]]></copyright><item><title><![CDATA[产品随想 2 产品力与人才密度]]></title><guid>https://blog.kevinzhow.com/posts/chan-pin-sui-xiang-2-chan-pin-li-yu-ren-cai-mi-du/zh</guid><link>https://blog.kevinzhow.com/posts/chan-pin-sui-xiang-2-chan-pin-li-yu-ren-cai-mi-du/zh</link><pubDate>Sun, 31 Aug 2025 07:31:47 +0000</pubDate><content:encoded><![CDATA[<p>今年的东京似乎比以往更热，走在路上能真实得感受到什么是炙烤，阳光穿透皮肤直达肌肉，给你一种平时感受不到的通透感，甚至会想多晒一会，让自己熟悉这个陌生的身体。</p>
<p>最近脑海会闪现很多产品的对比，比如我曾经最喜欢的 Apple 在 AI 上意外的缓慢，最早做出 Copilot 的 VSCode 没有成为 AI Code 的领导者，甚至有点跟不上 Cursor 的脚步，Gemini 模型一次次霸榜却无法取代 ChatGPT 成为我日常的工具，我很欣赏 Grok 在交互设计上创新的劲头，却无法真的喜欢上 Grok 的模型效果。</p>
<p>为什么觉得很强的组织，产品却难产或者怎么也不如另一个？尤其是当这些全球最顶尖的团队在一起竞技的时候，这种矛盾凸显到了你不需要思考都会察觉。</p>
<p>产品到底是谁做出来的？</p>
<p>塔尖的决策者时常会被神化，有时候是神化老板，有时候神化产品经理，我也不止一次遇到有朋友会说 “我都想清楚了，只需要找个人来执行就可以了” 以前也许还要去讨论下要找什么样的人来执行，现在有了 Agent 可能大部分人也都有了体会，如果 Agent 的模型能力太差，会需要你一次次的描述自己要做的事情，最后疲惫不堪，开始降低标准妥协。</p>
<p>对吧，产品就是团队一起做出来的，产品是这群人状态和能力的映射。</p>
<p>我在自己做产品的过程中，不止一次的体会到这个观察。</p>
<p>产品哪里出了问题，就是那里的人才密度出了问题。</p>
<p>之所以想这么说，是因为「做什么」和「怎么做」这两件事只有愚蠢的决策者才会认为自己想的就是对的，你需要的是更多的想法和方法，然后筛选，而不是去执行自己的方案。</p>
<p>对于产品的想法，我可能在发布前变更过至少 3 次，一个方案的实现思路，我也可能因为和朋友的一次简短的对谈中完全切换到另一个角度。</p>
<p>碰上有产品感的设计师，最后可能是他告诉你应该怎么做。</p>
<p>碰上对技术有追求工程师，我可能唯一的贡献只是说清楚了需求，在此之上有诸多地方都超越了你的预期，但这都是他自己琢磨的。</p>
<p>蹩脚的产品，修不完的 Bug，拿不出手的交付，并没有什么很复杂的原因，这就是人才密度的现状， 而且要把自己算进去。</p>
<p>人才密度，是一种能量的吸引，双向的筛选，也许现状无力改变，但至少需要认清原因。</p>
]]></content:encoded></item><item><title><![CDATA[产品随想 1 做营销还是做产品]]></title><guid>https://blog.kevinzhow.com/posts/chan-pin-sui-xiang-1-zuo-ying-xiao-huan-shi-zuo-chan-pin/zh</guid><link>https://blog.kevinzhow.com/posts/chan-pin-sui-xiang-1-zuo-ying-xiao-huan-shi-zuo-chan-pin/zh</link><pubDate>Fri, 29 Aug 2025 13:03:43 +0000</pubDate><content:encoded><![CDATA[<p>昨天和朋友一起沿着涉谷，代官山，原宿走了一下午，到了晚饭时间连走了 3 家半熟牛肉的餐厅，最后才在原宿的 Galaxy 店旁边找到一家环境不错的店，前两家都开在 B1 层，又小又挤，像是摆满图标的手机桌面，很难慢慢喝点东西聊聊天。</p>
<p>和朋友聊起产品的时候，他感叹道 “我当年产品比同行好，但是还是一个劲的打磨产品，如果当时我花更多精力在营销上，收入能多好多倍。”</p>
<p>这似乎是一个时常发生的争论，产品重要还是营销重要。</p>
<p>其实我也经历过醉心打磨产品细节的时光，尤其是爱花很多时间去打磨动效和交互，现在虽然很少这么干了，但回想起来都还是很喜欢以前那个不懂事得，沉浸在「单纯的热爱」的自己。</p>
<p>话说回来，要我看的话，这个问题提出的角度就是错了，但在重新阐述之前，我们还是要搞清楚为什么会有这样的问题。</p>
<p>通常来说，提出这个问题的人指的是</p>
<p>“埋头产品细节好重要，还是各种无底线的增长手段赚钱更重要”</p>
<p>看不上做营销的人，其实是看不上那些上不了台面的手段，以为是在做营销，实际上做的是销售的事。</p>
<p>而轻视产品的人，是看不上那些无法带来增长的自嗨式打磨，以为是做产品，其实是在做手工艺品。</p>
<p>如果能够正确的理解营销和产品，这个问题的答案就会自然得呈现。</p>
<p>伪产品 —— 为饱和的需求制造产品，认为用户需要的是“要你命3000”，好的产品像是在米饭盖上了一层半熟牛肉，提供新的体验带来增长。</p>
<p>伪营销 —— 把卖掉这一单作为目标，利用信息差诓骗，利诱用户，好的营销和产品是一体的，挖掘需求，创造价值，然后尝试找到最好的角度和力道，把产品这个纸飞机推出去。</p>
<p>所以成为销售之前，应该先 Double Check 下伪产品和伪营销。</p>
]]></content:encoded></item><item><title><![CDATA[Play PyTorch Stable Diffusion and ONNX, Ollama on Intel Core Ultra 5 225H Ubuntu 25.04]]></title><guid>https://blog.kevinzhow.com/posts/pytorch-intel-core-ultra/zh</guid><link>https://blog.kevinzhow.com/posts/pytorch-intel-core-ultra/zh</link><pubDate>Sun, 20 Jul 2025 05:27:27 +0000</pubDate><content:encoded><![CDATA[<p>在几个月前，我购买了一个 8845HS 的主机，<a href="https://blog.kevinzhow.com/posts/rocm-780m/zh">尝试了一番 ROCm</a>，AMD 在 API 的 iGPU 支持上可以说相当慢了，直到目前也只是在<a href="https://github.com/amd/RyzenAI-SW/blob/061d0b31f983040e73bbe75ee9fa7b0d7ad6c090/example/iGPU/getting_started/predict.py#L14C15-L14C35">利用 DirectML 和 ONNX 的能力</a>实现 iGPU 上运行模型，非常费劲。</p>
<p>Intel 这边就优雅且慷慨的多，oneAPI 统一了 CPU GPU NPU 多端的运行，看起来非常的靠谱，并且提供了 PyTorch+XPU 的后端，已经并入官方仓库，意味着 PyTorch 项目只需要简单的修改下模型的 device 就可以推理了，真是一种方便你我他的好方案。</p>
<p>不过有了上次 8845HS 的经验后，我也没有贸然下单，总想着先找个机器测一测吧，几经辗转，发现 Intel 的 <a href="https://console.cloud.intel.com/home">Tiber Cloud</a> 已经可以用了，开了台机器简单测了几个场景，相较 ROCm 之前在 iGPU 的各种报错，Intel 的 XPU 后端顺利的过于幸福。</p>
<p>Tiber 虽然提供的是 Intel 的商用 GPU，但既然官方表示是一视同仁，我相信 iGPU 的支持也不会差，立刻购入了一个摸到支持门坎的 Ultra 5 225H 的迷你主机，瞧瞧是什么情况。</p>
<h2>PyTorch</h2>
<p>PyTorch+XPU 安装起来也非常简单，通过这个命令即可：</p>
<div class="block-code"><pre><code>pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/xpu</code></pre></div>
<div class="blockquote"><blockquote><p>这个官方编译的 XPU 版本集成了 <a href="https://github.com/intel/torch-xpu-ops">https://github.com/intel/torch-xpu-ops</a> 的算子支持</p>
</blockquote></div>
<p>先测一下 Kokoro 这个 TTS 模型</p>
<div class="block-code" data-language="python"><pre><code>from kokoro import KPipeline
from IPython.display import display, Audio
import soundfile as sf
pipeline = KPipeline(lang_code='a', device='xpu')

text = '''
[Kokoro](/kˈOkəɹO/) is an open-weight TTS model with 82 million parameters. Despite its lightweight architecture, it delivers comparable quality to larger models while being significantly faster and more cost-efficient. With Apache-licensed weights, [Kokoro](/kˈOkəɹO/) can be deployed anywhere from production environments to personal projects.
'''
generator = pipeline(text, voice='af_heart')
for i, (gs, ps, audio) in enumerate(generator):
    print(i, gs, ps)
    display(Audio(data=audio, rate=24000, autoplay=i==0))
    sf.write(f'{i}.wav', audio, 24000)</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8247008607_363877.png" alt="kokoro.png" /></figure></div><p>这个 20s 的音频首次生成大概是 4s，之后大概花费 2s 左右</p>
<h2>ONNX</h2>
<h3>ONNX 版本的 Kokoro</h3>
<div class="block-code" data-language="python"><pre><code>import soundfile as sf
from misaki import en, espeak

from kokoro_onnx import Kokoro

# Misaki G2P with espeak-ng fallback
fallback = espeak.EspeakFallback(british=False)
g2p = en.G2P(trf=False, british=False, fallback=fallback)

# Kokoro
kokoro = Kokoro(&quot;kokoro-v1.0.onnx&quot;, &quot;voices-v1.0.bin&quot;)

# Phonemize
text = '''
[Kokoro](/kˈOkəɹO/) is an open-weight TTS model with 82 million parameters. Despite its lightweight architecture, it delivers comparable quality to larger models while being significantly faster and more cost-efficient. With Apache-licensed weights, [Kokoro](/kˈOkəɹO/) can be deployed anywhere from production environments to personal projects.
'''
phonemes, _ = g2p(text)

# Create
samples, sample_rate = kokoro.create(phonemes, &quot;af_heart&quot;, is_phonemes=True)

# Save
sf.write(&quot;audio.wav&quot;, samples, sample_rate)
print(&quot;Created audio.wav&quot;)</code></pre></div>
<p>Intel 可以通过 <code>pip install onnxruntime-openvino</code> 来开启 GPU 的支持，稍微修改下 Kokoro 这个类</p>
<div class="block-code"><pre><code>providers = [('OpenVINOExecutionProvider', {'device_type': 'GPU'})]</code></pre></div>
<p>很遗憾，出错了</p>
<div class="block-code"><pre><code>RuntimeException: [ONNXRuntimeError] : 6 : RUNTIME_EXCEPTION : Exception during initialization: /onnxruntime/onnxruntime/core/providers/openvino/ov_interface.cc:98 onnxruntime::openvino_ep::OVExeNetwork onnxruntime::openvino_ep::OVCore::CompileModel(std::shared_ptr&lt;const ov::Model&gt;&amp;, std::string&amp;, ov::AnyMap&amp;, const std::string&amp;) [OpenVINO-EP]  Exception while Loading Network for graph: OpenVINOExecutionProvider_OpenVINO-EP-subgraph_1_0Exception from src/inference/src/cpp/core.cpp:109:
Exception from src/inference/src/dev/plugin.cpp:53:
Check 'inputRank == 2 || inputRank == 4 || inputRank == 5' failed at src/plugins/intel_gpu/src/plugin/ops/interpolate.cpp:37:
Mode 'linear_onnx' supports only 2D or 4D, 5D tensors</code></pre></div>
<h3>PaddleOCR-onnx</h3>
<p><a href="https://github.com/jahongir7174/PaddleOCR-onnx">PaddleOCR-onnx</a> 需要修改一下 PredictBase 这个类</p>
<div class="block-code" data-language="python"><pre><code>       if use_gpu:
            providers = [('OpenVINOExecutionProvider', {
                          'device_type': 'GPU'})]</code></pre></div>
<div class="blockquote"><blockquote><p>也可以使用  'device_type': 'NPU' 'device_type': 'HETERO:GPU,CPU' 这类方式指定计算方式</p>
</blockquote></div>
<p>然后运行项目自带的 test_ocr.py 即可，开启 use_gpu=True</p>
<div class="block-code"><pre><code>import cv2
import time
from onnxocr.onnx_paddleocr import ONNXPaddleOcr, sav2Img
import sys
import time

model = ONNXPaddleOcr(use_angle_cls=False, use_gpu=True)

img = cv2.imread('./onnxocr/test_images/00006737.jpg')
s = time.time()
result = model.ocr(img)
e = time.time()

for box in result[0]:
    print(box)
print(&quot;total time: {:.3f}&quot;.format(e - s))
sav2Img(img, result, name=str(time.time())+'.jpg')</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8247008575_024674.png" alt="image.png" /></figure></div><p>运行良好，花费了大概 1s</p>
<h2>Ollama</h2>
<p>Ollama 的 Intel 支持需要通过 <a href="https://github.com/intel/ipex-llm">https://github.com/intel/ipex-llm</a> 这个项目，使用 Intel 编译的 Ollama
在运行时可以看到</p>
<div class="block-code"><pre><code>get_memory_info: [warning] ext_intel_free_memory is not supported (export/set ZES_ENABLE_SYSMAN=1 to support), use total memory as free memory
get_memory_info: [warning] ext_intel_free_memory is not supported (export/set ZES_ENABLE_SYSMAN=1 to support), use total memory as free memory</code></pre></div>
<p>看来自己不需要解决 iGPU 有多少显存的问题</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8247008557_7057085.png" alt="image.png" /></figure></div><p>比 8845HS 的 8 tokens/s 慢一些，不知道未来 ipex-llm 是不是还能在这里加把劲。</p>
<p>这次玩下来，PyTorch 的支持挺不错，足以作为一个推理机器使用。</p>
<h2>Stable Diffusion</h2>
<p>SD 有两个选择，一个是 Diffusers，一个则是 stable-diffusion.cpp</p>
<h3>Diffusers</h3>
<p>Flux 占用的内存真的不少，32GB 内存跑 FP16 完全不够，官方也给出了的例子使用 GGUF 量化模型的方法，不过这个方案还是不够省内存</p>
<div class="block-code"><pre><code>import torch

from diffusers import FluxPipeline, FluxTransformer2DModel, GGUFQuantizationConfig

ckpt_path = (
    &quot;./models/flux1-dev-Q4_0.gguf&quot;
)
transformer = FluxTransformer2DModel.from_single_file(
    ckpt_path,
    quantization_config=GGUFQuantizationConfig(compute_dtype=torch.bfloat16),
    torch_dtype=torch.bfloat16,
)
pipe = FluxPipeline.from_pretrained(
    &quot;black-forest-labs/FLUX.1-dev&quot;,
    transformer=transformer,
    torch_dtype=torch.bfloat16
)
pipe.enable_model_cpu_offload()
prompt = &quot;A cat holding a sign that says hello world&quot;
image = pipe(prompt,
    generator=torch.manual_seed(0), 
    num_inference_steps=20,
    height=512, 
    width=512
 ).images[0]
image.save(&quot;flux-gguf.png&quot;)</code></pre></div>
<p>这部分我参考了<a href="https://touch-sp.hatenablog.com/entry/2024/12/18/082219">此处的实现</a> ，通过分步的方式节省了不少内存</p>
<div class="block-code" data-language="python"><pre><code>import torch
from diffusers import FluxPipeline, FluxTransformer2DModel, GGUFQuantizationConfig
import gc

def flush():
    gc.collect()
    torch.xpu.empty_cache()

def main():
    # downloaded from https://huggingface.co/city96/FLUX.1-dev-gguf
    gguf_file = &quot;./models/flux1-dev-Q4_K_S.gguf&quot;
    model_id = &quot;black-forest-labs/FLUX.1-dev&quot;

    pipeline = FluxPipeline.from_pretrained(
            model_id,
            transformer=None,
            vae=None,
            torch_dtype=torch.bfloat16
    ).to(&quot;xpu&quot;)

    prompt = &quot;a lovely cat holding a sign says 'hello world'&quot;

    with torch.no_grad():
        prompt_embeds, pooled_prompt_embeds, text_ids = pipeline.encode_prompt(
            prompt=prompt,
            prompt_2=None,
        )

    print(&quot;text_encoder:&quot;)
    print(f&quot;torch.xpu.max_memory_allocated: {torch.xpu.max_memory_allocated()/ 1024**3:.2f} GB&quot;)

    del pipeline
    flush()

    transformer = FluxTransformer2DModel.from_single_file(
        gguf_file,
        quantization_config=GGUFQuantizationConfig(compute_dtype=torch.bfloat16),
        torch_dtype=torch.bfloat16
    )
    pipeline = FluxPipeline.from_pretrained(
        model_id,
        transformer=transformer,
        text_encoder=None,
        text_encoder_2=None,
        tokenizer=None,
        tokenizer_2=None,
        torch_dtype=torch.bfloat16
    ).to(&quot;xpu&quot;)

    image = pipeline(
        prompt_embeds=prompt_embeds,
        pooled_prompt_embeds=pooled_prompt_embeds,
        generator=torch.Generator(&quot;xpu&quot;).manual_seed(0),
        height=512, 
        width=512,
        num_inference_steps=20
    ).images[0]

    save_file = gguf_file.replace(&quot;.gguf&quot;, &quot;.jpg&quot;)
    image.save(save_file)

    print(&quot;transformer:&quot;)
    print(f&quot;torch.xpu.max_memory_allocated: {torch.xpu.max_memory_allocated()/ 1024**3:.2f} GB&quot;)

if __name__ == &quot;__main__&quot;:
    main()</code></pre></div>
<p>Q4 结果如下</p>
<div class="block-code"><pre><code>Loading checkpoint shards: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 2/2 [00:00&lt;00:00, 94.74it/s]
Loading pipeline components...:  60%|████████████████████████████████████████████████████████▍                                     | 3/5 [00:00&lt;00:00, 28.63it/s]You set `add_prefix_space`. The tokenizer needs to be converted from the slow tokenizers
Loading pipeline components...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████| 5/5 [00:00&lt;00:00, 14.99it/s]
text_encoder:
torch.xpu.max_memory_allocated: 9.32 GB
Loading pipeline components...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████| 3/3 [00:00&lt;00:00, 48.13it/s]
100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 20/20 [02:52&lt;00:00,  8.62s/it]
transformer:
torch.xpu.max_memory_allocated: 9.32 GB</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8246035060_172783.jpg" alt="flux1-dev-Q4_K_S.jpg" /></figure></div><p>Q8 结果如下</p>
<div class="block-code"><pre><code>Loading checkpoint shards: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 2/2 [00:00&lt;00:00, 19.84it/s]
Loading pipeline components...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████| 5/5 [00:00&lt;00:00, 11.03it/s]
text_encoder:
torch.xpu.max_memory_allocated: 9.32 GB
Loading pipeline components...: 100%|██████████████████████████████████████████████████████████████████████████████████████████████| 3/3 [00:00&lt;00:00, 45.77it/s]
100%|████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 20/20 [01:41&lt;00:00,  5.06s/it]
transformer:
torch.xpu.max_memory_allocated: 12.61 GB</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8246033469_745569.jpg" alt="flux1-dev-Q8_0.jpg" /></figure></div><p>Q8 比 Q4 更快，神奇，似乎是因为硬件有原生 Q8 的加速支持</p>
<h2>stable-diffusion.cpp</h2>
<p>stable-diffusion.cpp 除了对后端支持很全面（ CUDA, Metal, Vulkan, OpenCL and SYCL）另一大优点应该就是占内存比较少了，结合其 Python 的绑定 <a href="https://github.com/william-murray1204/stable-diffusion-cpp-python?tab=readme-ov-file">stable-diffusion-cpp-python</a>可以轻松跑起来 Q8 的模型</p>
<div class="block-code" data-language="python"><pre><code>from stable_diffusion_cpp import StableDiffusion
import torch

def callback(step: int, steps: int, time: float):
    print(&quot;Completed step: {} of {}&quot;.format(step, steps))

gen = torch.Generator(device=&quot;xpu&quot;).manual_seed(0)

stable_diffusion = StableDiffusion(
    diffusion_model_path=&quot;./models/flux1-dev-Q8_0.gguf&quot;, # In place of model_path
    clip_l_path=&quot;./models/clip_l.safetensors&quot;,
    t5xxl_path=&quot;./models/t5xxl_fp16.safetensors&quot;,
    vae_path=&quot;./models/ae.safetensors&quot;,
    vae_decode_only=True, # Can be True if we dont use img_to_img
)
output = stable_diffusion.txt_to_img(
      prompt=&quot;a lovely cat holding a sign says 'hello world'&quot;,
      sample_steps=20,
      width=512, # Must be a multiple of 64
      height=512, # Must be a multiple of 64
      cfg_scale=1.0, # a cfg_scale of 1 is recommended for FLUX
      sample_method=&quot;euler&quot;, # euler is recommended for FLUX
      progress_callback=callback,
      seed=gen.initial_seed()
)

output[0].save(&quot;output.png&quot;) </code></pre></div>
<p>Q4 结果如下</p>
<div class="block-code"><pre><code>stable-diffusion.cpp:1525 - sampling completed, taking 113.58s
stable-diffusion.cpp:1533 - generating 1 latent images completed, taking 113.58s
stable-diffusion.cpp:1536 - decoding 1 latents
ggml_extend.hpp:1148 - vae compute buffer size: 1664.00 MB(VRAM)
stable-diffusion.cpp:1129 - computing vae [mode: DECODE] graph completed, taking 11.54s
stable-diffusion.cpp:1546 - latent 1 decoded, taking 11.54s
stable-diffusion.cpp:1550 - decode_first_stage completed, taking 11.54s
stable-diffusion.cpp:1684 - txt2img completed in 138.73s</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8246035309_805065.png" alt="output (1).png" /></figure></div><p>Q8 结果如下</p>
<div class="block-code"><pre><code>stable-diffusion.cpp:1525 - sampling completed, taking 117.70s
stable-diffusion.cpp:1533 - generating 1 latent images completed, taking 117.70s
stable-diffusion.cpp:1536 - decoding 1 latents
ggml_extend.hpp:1148 - vae compute buffer size: 1664.00 MB(VRAM)
stable-diffusion.cpp:1129 - computing vae [mode: DECODE] graph completed, taking 11.57s
stable-diffusion.cpp:1546 - latent 1 decoded, taking 11.57s
stable-diffusion.cpp:1550 - decode_first_stage completed, taking 11.57s
stable-diffusion.cpp:1684 - txt2img completed in 144.15s</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8246034758_745577.png" alt="output (2).png" /></figure></div><h2>Monitor</h2>
<p>可以使用 Intel 提供的性能查看工具 intel_gpu_top</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8247008540_042873.png" alt="image.png" /></figure></div><p>也可以使用 watch -n 1 xpu-smi stats -d 0</p>
]]></content:encoded></item><item><title><![CDATA[Play with ROCm, PyTorch, Ollama on Ubuntu 24.04 and 780m]]></title><guid>https://blog.kevinzhow.com/posts/rocm-780m/zh</guid><link>https://blog.kevinzhow.com/posts/rocm-780m/zh</link><pubDate>Mon, 24 Mar 2025 05:11:58 +0000</pubDate><content:encoded><![CDATA[<h2>2025.12.25 更新</h2>
<p>最近 <a href="https://github.com/ROCm/rocm-libraries/pull/1320">rocBLAS</a> 和 AMD 新的 TheRock 构建项目都支持了 780M(gfx1103) 的显卡，详情见这个<a href="https://github.com/ROCm/TheRock/blob/main/RELEASES.md">指南</a></p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8233335067_5693865.png" alt="image.png" /></figure></div><p>安装后我参考之前<a href="https://blog.kevinzhow.com/posts/pytorch-intel-core-ultra/zh">Intel XPU 体验</a>测试了几个项目。</p>
<h3>测试的版本</h3>
<p>ROCm 7.1.1</p>
<p>Python 侧（需要注意这个对应关系，如果直接按照官方的命令安装可能出现不匹配的情况）</p>
<div class="block-code"><pre><code>rocm==7.11.0a20260121
torch==2.11.0a0+rocm7.11.0a20260121
torchaudio==2.11.0a0+rocm7.11.0a20260121 
torchvision==0.25.0a0+rocm7.11.0a20260121

uv pip install torch==2.11.0a0+rocm7.11.0a20260121 rocm==7.11.0a20260121 torchaudio==2.11.0a0+rocm7.11.0a20260121 torchvision==0.25.0a0+rocm7.11.0a20260121  --index-url https://rocm.nightlies.amd.com/v2/gfx110X-all/ --pre</code></pre></div>
<h3>Pytorch</h3>
<p>✅正常通过 CUDA 检测</p>
<div class="block-code"><pre><code>import torch

print(torch.cuda.is_available())
print(torch.cuda.get_device_name(0))</code></pre></div>
<h3>Kokoro-TTS</h3>
<p>❌错误 RuntimeError: miopenStatusUnknownError</p>
<h3>Kokoro-ONNX</h3>
<p>✅正常运行，可以使用 CUDA Provider</p>
<h3>PaddleOCR</h3>
<p>✅直接测试了原版，正常运行并启用 CUDA 加速</p>
<h3>Ollama</h3>
<p>✅Ollama 方面暂未支持 gfx1103，需要像之前那样用 HSA_OVERRIDE_GFX_VERSION 绕一下</p>
<h3>Stable Diffusion</h3>
<p>✅用了个 int8 的量化版本，成功</p>
<div class="block-code"><pre><code>import torch
from diffusers import FluxPipeline

def main():
    pipe = FluxPipeline.from_pretrained(
        &quot;diffusers/FLUX.1-dev-torchao-int8&quot;,
        torch_dtype=torch.bfloat16,
        use_safetensors=False,
        device_map=&quot;balanced&quot;
    )
    prompt = &quot;a lovely cat holding a sign says 'hello world'&quot;

    out = pipe(
        prompt=prompt,
        height=512,
        width=512,
        num_inference_steps=9
    ).images[0]

    out.save(&quot;out.jpg&quot;)

if __name__ == &quot;__main__&quot;:
    main()</code></pre></div>
<div class="block-code"><pre><code>100% 9/9 [01:08&lt;00:00,  7.57s/it]</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8233336288_188881.jpg" alt="alt text" /></figure></div><h3>Z-Image</h3>
<p>✅ 成功</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8233335975_420019.png" alt="alt text" /></figure></div><div class="block-code"><pre><code>import torch
from diffusers import ZImagePipeline

# 1. Load the pipeline
# Use bfloat16 for optimal performance on supported GPUs
pipe = ZImagePipeline.from_pretrained(
    &quot;Tongyi-MAI/Z-Image-Turbo&quot;,
    torch_dtype=torch.bfloat16,
    low_cpu_mem_usage=False,
)
pipe.to(&quot;cuda&quot;)

prompt = &quot;Young Chinese woman in red Hanfu, intricate embroidery. Impeccable makeup, red floral forehead pattern. Elaborate high bun, golden phoenix headdress, red flowers, beads. Holds round folding fan with lady, trees, bird. Neon lightning-bolt lamp (⚡️), bright yellow glow, above extended left palm. Soft-lit outdoor night background, silhouetted tiered pagoda (西安大雁塔), blurred colorful distant lights.&quot;

# 2. Generate Image
image = pipe(
    prompt=prompt,
    height=512  ,
    width=512,
    num_inference_steps=9,  # This actually results in 8 DiT forwards
    guidance_scale=0.0,     # Guidance should be 0 for the Turbo models
    generator=torch.Generator(&quot;cuda&quot;).manual_seed(42),
).images[0]

image.save(&quot;example.png&quot;)</code></pre></div>
<div class="block-code"><pre><code>100% 9/9 [00:35&lt;00:00,  4.00s/it]</code></pre></div>
<div class="block-code"><pre><code>prompt = &quot;a lovely cat holding a sign says 'hello world'&quot;</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8233335766_616344.png" alt="alt text" /></figure></div><div class="block-code"><pre><code>100% 9/9 [00:33&lt;00:00,  3.70s/it]</code></pre></div>
<p>感觉再过些时间 780M 兼容性应该会变得更好，但从种种痕迹来看，iGPU 至少要从 Strix Halo (aka AI MAX) 开始，才算得上得到了 AMD 的重视，RuntimeError 应该是会大幅减少的</p>
<hr />
<p>最近买了个 8845HS 的小主机，但因为 780M 的显卡并没有被 ROCm 列为官方支持的卡，所以目前需要很多 trick 来运行</p>
<p>最主要的就是通过 <code>HSA_OVERRIDE_GFX_VERSION</code> 来假装成受支持的显卡。虽然我用的都是 HSA_OVERRIDE_GFX_VERSION=11.0.2 但实际上因 ROCm 版本的不同，到底哪个能在你的显卡上工作需要自己测试下。</p>
<p>你可以通过 AMD 官网的 <a href="%20https://rocm.docs.amd.com/projects/install-on-linux/en/latest/reference/system-requirements.html">Supported GPUs</a> 中的 Architecture 和 LLVM target 来查找，比如 gfx1101，那就是 HSA_OVERRIDE_GFX_VERSION=11.0.1</p>
<p>除此之外，<code>ls  /opt/rocm/lib/rocblas/library</code> 命令也会列出一些没显示在官网上的支持，比如 11.0.2</p>
<h2>ROCm</h2>
<p>ROCm 是 AMD 显卡玩机器学习的基础组件，现在安装起来很简单，<a href="https://rocm.docs.amd.com/projects/install-on-linux/en/latest/install/install-methods/amdgpu-installer/amdgpu-installer-ubuntu.html">amdgpu-install </a>这个包就可以很好的解决</p>
<div class="block-code"><pre><code>sudo apt install amdgpu-install
amdgpu-install --usecase=rocm</code></pre></div>
<h2>Ollama</h2>
<p>Ollama 的运行可以参考下面的 PR
<a href="https://github.com/ollama/ollama/pull/5426">Enable AMD iGPU 780M in Linux, Create amd-igpu-780m.md #5426</a></p>
<p>简而言之，直接通过这个命令就可以运行</p>
<div class="block-code"><pre><code>HSA_OVERRIDE_GFX_VERSION=11.0.2 ollama serve</code></pre></div>
<p>目前 Ollama 对 igpu 的显存支持有些问题，不能够将所有共享内存计算在内，解决方案可以参考这里 <a href="https://github.com/ollama/ollama/pull/6282">AMD integrated graphic on linux kernel 6.9.9+, GTT memory, loading freeze fix #6282</a></p>
<p>我的主要场景并非用 8845HS 来跑 LLM，所以就跑个简单的测试下吧，gemma3:12b 这个量化模型的速度大概是 8.44 tokens/s 可以说堪用</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8257213005_742332.png" alt="image.png" /></figure></div><h3>ollama ps</h3>
<p>使用 <code>ollama ps</code>命令可以查看模型是分配在哪个设备上运行的。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8257207184_502723.png" alt="image.png" /></figure></div><h2>PyTorch</h2>
<p>PyTorch 直接通过<a href="https://rocm.docs.amd.com/projects/install-on-linux/en/latest/install/3rd-party/pytorch-install.html">AMD 官网</a>提供的命令来安装即可，需要注意的是我使用 PyTorch 官网的命令安装稳定版并不能成功运行，AMD 官网给出的 nightly 版本可以。</p>
<div class="block-code"><pre><code>pip3 install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm6.2.4/</code></pre></div>
<p>同样，需要使用 <code>HSA_OVERRIDE_GFX_VERSION=11.0.2</code> 来运行，可以创建一个 .env 文件在 ipynb 里动态载入，如果是 vscode 的话，.env 文件会自动加载，不需要下述步骤</p>
<div class="block-code"><pre><code>pip install python-dotenv</code></pre></div>
<p>在 ipynb 顶部加入一个 code block 每次运行一下即可</p>
<div class="block-code"><pre><code>%load_ext dotenv
%dotenv</code></pre></div>
<p>其它内容无需修改</p>
<h2>radeontop 监控</h2>
<p>配置完成后，如果有时候不确定有没有跑在 GPU 上，可以用 radeontop 来监控</p>
<div class="block-code"><pre><code>sudo apt install radeontop</code></pre></div>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8257207289_281906.png" alt="image.png" /></figure></div><h2>关于 NPU</h2>
<p>8845HS 还带了个 16 TOPS 的 NPU，不过要等到 <a href="https://www.phoronix.com/news/Ryzen-AI-NPU6-Linux-6.14">Linux 6.14</a> 才会合并进去。</p>
<p>届时 ONNX Runtime 的 <a href="https://onnxruntime.ai/docs/execution-providers/Vitis-AI-ExecutionProvider.html">VitisAIExecutionProvider</a> 和 HuggingFace 的 <a href="https://huggingface.co/docs/optimum/en/amd/ryzenai/overview">RyzenAI</a> 应该都能开箱即用。</p>
<p>唯一的问题是兼容性如何。</p>
<p>暂时还没折腾，等到时候也会测试一下再写一篇折腾的博客</p>
<h2>其他参考</h2>
<p><a href="https://github.com/ROCm/ROCm/discussions/2631">Does ROCm 5.7 support Radeon 780M (gfx1103)? #2631</a></p>
<p><a href="https://github.com/ROCm/ROCm/issues/3398">Feature: ROCm Support for AMD Ryzen 9 7940HS with Radeon 780M Graphics #3398</a></p>
]]></content:encoded></item><item><title><![CDATA[写在新的旅程开始前]]></title><guid>https://blog.kevinzhow.com/posts/new-beginning/zh</guid><link>https://blog.kevinzhow.com/posts/new-beginning/zh</link><pubDate>Thu, 20 Mar 2025 08:25:56 +0000</pubDate><content:encoded><![CDATA[<p>最近经常想起自己为什么离开公司自己做产品，都已经是 7 年前的事情了</p>
<p>当时为了能够按照自己的意志去做产品</p>
<p>做一款足够独特，有趣的产品</p>
<p>这些年事情虽然做的慢了些</p>
<p>但现在对这个目标有了更好的想法，更成熟的思考</p>
<p>7 年间发生了很多事情，失去了挚友</p>
<p>身份，家庭，关系也都在变化</p>
<p>但今天我真的迈入新的副本了</p>
<p>副本开始前，是应该好好休息一下的！</p>
]]></content:encoded></item><item><title><![CDATA[想念自然与青春]]></title><guid>https://blog.kevinzhow.com/posts/2025-03-08/zh</guid><link>https://blog.kevinzhow.com/posts/2025-03-08/zh</link><pubDate>Sat, 08 Mar 2025 03:07:40 +0000</pubDate><content:encoded><![CDATA[<p>昨天看介绍说乔布斯在设计苹果的新总部时希望有在公园里办公的感觉，因为在亲近自然的时候最有灵感。</p>
<p>想起了去年住的东越谷，东越谷是真的挺美的，河道，公园，家里的窗外。</p>
<p>因为不能养猫所以搬家了，离开了这个环境后经常会想念，所以也在考虑是不是应该再搬家，找一个能养猫又亲近自然的地方。</p>
<p>但还是等春天来了之后，看看这里万物复苏后是什么感觉，或许也会很美。</p>
<p>最近集中看了一些电影，「怪物」「去唱卡拉OK吧」「爱的接力棒」这些</p>
<p>感觉也许是自己真的步入中年，远离青春了</p>
<p>「去唱卡拉OK吧」是那种后劲很足的青春感</p>
<p>而且不是那种恋爱的青春</p>
]]></content:encoded></item><item><title><![CDATA[懒猫微服体验——自由协作的神器]]></title><guid>https://blog.kevinzhow.com/posts/lazycat/zh</guid><link>https://blog.kevinzhow.com/posts/lazycat/zh</link><pubDate>Sat, 07 Dec 2024 10:40:02 +0000</pubDate><content:encoded><![CDATA[<p>11 月中旬的时候，Andy 私信问我愿不愿意体验一下懒猫微服这款产品，我看了下懒猫微服的官网，然后几个疑问就冒了出来</p>
<ul>
<li>功能好像是个 NAS，但一个新团队能力如何？真的比那些老牌 NAS 有优势？</li>
<li>宣传了不少 AI 技术，但是看 SOC 算力比起最新的那些动辄 100TOPS 的芯片，这个芯片似乎也不能力大管饱啊</li>
<li>支持网络穿透，但这个比起用 Tailscale 等第三方技术会真的更好用吗？</li>
<li>这个价格，我是不是应该考虑买个 Mac Mini 自己组装😂</li>
</ul>
<p>于是我对 Andy 说，关注我的人应该大都懂技术，对参数也比较敏感，可能找一些数码博主来体验会更好。</p>
<p>但 Andy 还是说，你先体验下，如果喜欢的话就点评点评。</p>
<p>好吧，既然条件这么宽松，我也很好奇原 Deepin Linux CTO 带来的产品，会有什么不同，在这些问题中，我最好奇的是懒猫的网络穿透技术，是不是真的能构建出有用的使用场景。</p>
<p>因此本次的体验将跳过所有和外观以及参数相关的内容，只在于使用场景的探索。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266439407_671306.jpg" alt="IMG_9774.jpg" /></figure></div><h2>寻找我的场景是什么</h2>
<p>懒猫到手后配置的流程很简单，打开 App 扫机器底部的二维码，几个下一步就可以配置好，和 HomeKit 的智能设备入网差不多。这个流畅简单的体验是让我觉得有些惊喜的，和我之前用的 NAS 相比是非常新手友好了。</p>
<p>与传统 NAS 不同的是，你暂时无法直接通过一个局域网的地址访问懒猫微服，想要正常使用懒猫，都需要启动他们的 App 进行组网，然后通过 App 管理和访问懒猫的功能。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266437885_490738.png" alt="image.png" /></figure></div><p>比较省心的是，不论你身处何地，都可以通过这个懒猫微服的内网穿透能力，无缝的访问家里懒猫微服的应用和内容。</p>
<p>原理上来说，懒猫微服会通过内网穿透组网的能力，将你登陆了懒猫 App 的设备和你家里的懒猫微服组合到一个加密的私有网络里，在这之后，懒猫 App 会接管你设备上所有访问 heiyu.space 域名后缀的流量，而每个你安装的懒猫上的应用，都会有对应的 heiyu.space 域名，比如懒猫清单对应的就是 <a href="https://todolist.myoland.heiyu.space">https://todolist.myoland.heiyu.space</a></p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266437027_563288.png" alt="image.png" /></figure></div><p>当你访问这个域名的时候，无需经过第三方中转即可直连家中的懒猫微服，这意味着你既不需要租服务器，处理那些烦人的备案合规问题，也不需要担心带宽和流量问题，更无须担心自己的数据会被窥窃。</p>
<p>如果这个域名只有自己能访问，那就相当无趣了，懒猫让我开始觉得兴奋的地方是，你可以邀请最多 20 名用户加入到你的微服上，这意味着，虽然他们没有微服设备，但可以访问，操控，管理你的懒猫微服和上面的应用。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266436758_910518.png" alt="image.png" /></figure></div><h2>Mattermost 协作</h2>
<p>在看到懒猫这种便利的组网能力后，我立刻想起了我之前和团队成员协作时一直在用的 Mattermost，一个 Slack 的开源替代品，出于数据安全的考虑，我并不想使用国内的协作产品聊天，但我又需要大家能稳定的连接到这个服务上，一个架设在自己家里的 Mattermost 就是很好的选择。</p>
<p>可惜在我使用的时候懒猫微服的应用商店还没有上架这个应用。</p>
<p>好吧，自己动手丰衣足食，这也是增进对懒猫了解的好机会，我着手移植了一个懒猫微服版本的 Mattermost，在这篇 Blog 发布的日期，我已经提交 Mattermost 到懒猫的应用商店，如果你想参考移植自己的项目，可以在这里找到项目代码 <a href="https://github.com/kevinzhow/mattermost-lzc">https://github.com/kevinzhow/mattermost-lzc</a></p>
<p>Mattermost 在安装后你可以访问 <a href="https://mattermost.myoland.heiyu.space">https://mattermost.myoland.heiyu.space</a> 来打开 Mattermost，也可以直接通过这个域名在 App 上登录。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266436290_615553.png" alt="image.png" /></figure></div><div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266436504_02454.png" alt="image.png" /></figure></div><p>得益于懒猫 App 在电脑端不错的兼容性，懒猫微服不会和你电脑上其他的 VPN 服务冲突，Mattermost 用起来也非常无缝，在一些网络配置后，远程协助和电话功能也都能正常运行，这一刻我有些爱上了懒猫微服为团队协作提供的便利性。</p>
<h2>Syncthing 进行文件同步</h2>
<p>团队协作第二个最常见的需求就是点对点文件同步，以前一直用 Resilio 进行同步，但是感觉不论是权限管理还是后来的改动，都让我用的有些不开心，这次我尝试了 Syncthing 这款开源产品。</p>
<p>虽然懒猫的应用商店自带了 Syncthing 但是有两个问题导致我还是自己进行了一次移植</p>
<ul>
<li>Syncthing 不应该是多实例的，需要通过单实例确保这个中心同步节点进行协调</li>
<li>Syncthing 的数据不应该存在自身的卷内，需要确保如果用户删除他也不会导致数据丢失，同时我将他的数据挂载到了用户文件夹内，这样你可以通过懒猫网盘访问查看上面的数据</li>
</ul>
<p>你可以在这里找到我的移植项目 <a href="https://github.com/kevinzhow/syncthing-lzc">https://github.com/kevinzhow/syncthing-lzc</a></p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266435777_0269575.png" alt="image.png" /></figure></div><h2>存储和备份</h2>
<p>因为一开始我带入了 NAS 的概念，所以看到硬盘 1 和 硬盘 2 的使用量一样时，我以为是组了 RAID，但客服告诉我这是 btrfs 均匀存储的特性，多年 Mac 用户此刻表示受到了开源震撼。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266434148_024623.png" alt="image.png" /></figure></div><h3>数据备份</h3>
<p>我另外一台 NAS 虽然用了 RAID 技术，但比起 RAID 让人摸不清头脑的规格，我个人更喜欢懒猫这种外接一个 USB 硬盘进行增量数据备份的方式，机器因此可以做小，更省电，自己也可以更自由的管理移动备份硬盘。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8266433170_93978.png" alt="image.png" /></figure></div><h2>综合体验</h2>
<p>其实在找到了团队协作这个场景之后，我认为懒猫微服已经给我提供了一个非常独特的使用场景，从一开始带着偏见的怀疑，变成了希望他们做大做强。</p>
<p>正如它名字里体现的一样，这是一个开箱即用的私有云服务主机，NAS 和 AI 都不是它打动我标签，懒猫微服隐藏了 NAS 产品过去的复杂细节，将微服这个概念交付到了用户手中。</p>
<h3>优点</h3>
<ul>
<li>系统运行稳定，底层技术不错</li>
<li>网络穿透服务稳定</li>
<li>技术支持响应迅速</li>
<li>客服非常友好专业</li>
</ul>
<h3>缺点</h3>
<ul>
<li>第一方应用尚需时间打磨</li>
<li>价格是个门槛，但不折腾这一点可以值回票价</li>
<li>宣传上让人会误以为是 NAS 或者 AI 主机，但其实他最差异化的私有云属性反而没有的到足够的宣传</li>
</ul>
<h3>谁适合购买这款产品？</h3>
<p>希望不折腾，拥有开箱即用的私有云服务主机的人。
另外懒猫很快会推出海外版本，相信届时如果结合 Cloudflare Tunnel，这应该也会是一个非常好的公网服务器。</p>
]]></content:encoded></item><item><title><![CDATA[没有光纤的日子怎么上网？自制 Home WI-FI！]]></title><guid>https://blog.kevinzhow.com/posts/android-home-wifi/zh</guid><link>https://blog.kevinzhow.com/posts/android-home-wifi/zh</link><description><![CDATA[记录一下没有网的漂泊日子]]></description><pubDate>Thu, 10 Oct 2024 10:47:53 +0000</pubDate><content:encoded><![CDATA[<p>这次搬家是彻底失算了，没想到日本 2022 年的新筑 Terrace （日语叫 メゾンネット，中文可以理解为联排）竟然没有光纤，这就导致在各家装宽带的网页上，公寓和一户建都不太能准确描述这个地方。</p>
<p>各种波折导致我没能顺利搬迁之前的宽带，也没能很快装上新的宽带。这也是为什么我会写这篇文章，主要是抒发下没有宽带的苦闷。</p>
<h2>为什么不用运营商的 Home WI-FI</h2>
<p>日本很多运营商都提供了一种叫做 Home Wi-Fi 的家庭热点，原理和手机热点一样，都是通过一张 SIM 卡连接到移动网络，然后再分享出来。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8271443004_83959.png" alt="Speed Wi-Fi HOME 5G L13" /></figure></div><p>但与手机不同的是，Home WI-FI 这种设备把技能点都点在了下载上。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8271442980_184626.png" alt="Speed Wi-Fi HOME 5G L13" /></figure></div><p>比如在我这里通过 5G 连接 Au 的线路，下载可以到 200M，但上传可能只有 10M - 20M，同样的卡切换成我的 Pixel 7a 之后，下载是 120M 左右，上传是 60 - 80M. 下图可以看到 Pixel 7a 的上传性能要好很多。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8271442954_349157.png" alt="Pixel 7a’s Exynos 5300" /></figure></div><p>有了这个数据的对比后，我决定把 Android 手机的网通过 RJ45 网线提供给路由器上网。</p>
<h2>为什么不用手机热点？</h2>
<p>在日本 Android 手机只能分享 2.4G 的 WI-FI，这导致 WI-FI 的速度和稳定性，带机量都相当有限。</p>
<h2>自制 Home WI-FI 的简单方式</h2>
<p>具备下面 3 个物品就可以方便的自制一个 Home WI-FI 了</p>
<ol>
<li>流量卡</li>
<li>支持以太网共享的 Android 手机</li>
<li>USB to RJ45 的转接设备</li>
<li>一个路由器（应该都有吧！）</li>
</ol>
<h3>流量卡</h3>
<p>在日本的话 Povo 和乐天这两个放题卡都是不错的选择，需要注意的是自己所在的区域是否有对应的 5G 信号支持，5G 通常可以提供一个 20ms 左右的低延迟体验，即使人多的时候速度不会降的离谱。</p>
<h3>Android 手机</h3>
<p>虽然 小米，三星，Google Pixel 的手机大都支持以太网网络共享，但这一点还是需要自己先确认下。</p>
<h3>USB to RJ45</h3>
<p>你可以选择一个带有 Type C 供电的转接头，比如我购买的支持 PD 电源输入的<a href="https://www.amazon.co.jp/gp/product/B0CXS87TR2/ref=ppx_yo_dt_b_asin_title_o04_s00?ie=UTF8&amp;psc=1">这款</a></p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8271442938_082185.png" alt="image 3.png" /></figure></div><p>除了专门买个转接头外，也可以用自己手头带有 RJ45 接口的扩展坞，但扩展坞和手机的兼容性可能不太好，比如我手里的 Anker 扩展坞可以和小米手机一起用，但 Pixel 使用则会一直掉线。</p>
<p>在购买前需要确认转接设备和 Android 手机的兼容性，一般如果商家写了支持 Android 设备那么通常没问题。</p>
<h3>路由器</h3>
<p>正常的家用 WI-FI 路由器即可 :)</p>
<h2>使用方式</h2>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8271442873_278105.jpg" alt="IMG_8949.jpg" /></figure></div><ol>
<li>Android 手机插好自己的流量卡</li>
<li>将 RJ45 转接头插到手机上</li>
<li>在 Android 手机的热点中打开「以太网网络共享」这个选项，如果转接头不兼容，那么这个会是灰色的无法开启</li>
<li>用一根网线连接 Android 手机转出来的 RJ45 口和路由器的 WAN 口</li>
<li>确保路由器的 WAN 口上网模式是「IP 动态分配」</li>
</ol>
<p>好了，可以享受网络了！我用了一个星期后感觉还是挺稳定的，不过还是祝你早日装上宽带！</p>
]]></content:encoded></item><item><title><![CDATA[Swift on Server Tour 6 关联 User 和 Post]]></title><guid>https://blog.kevinzhow.com/posts/swift-on-server-tour-6/zh</guid><link>https://blog.kevinzhow.com/posts/swift-on-server-tour-6/zh</link><pubDate>Mon, 30 Oct 2023 05:28:30 +0000</pubDate><content:encoded><![CDATA[<p>在上一章中我们创建了 <code>User</code> 的 <code>Model</code>，并构建了 <code>UserController</code>，但尚未构建起 <code>User</code> 和 <code>Post</code> 之间的关系。在这一章中，我们将在 Vapor 中完成一对多的关系构建。</p>
<p>本章代码可以在 <a href="https://github.com/kevinzhow/swift-on-server-tour/tree/main/6">Github</a> 中找到。</p>
<h2>修改 Post 的数据结构</h2>
<p>要修 <code>Post</code> 表结构，我们首先需要创建一个新的 Migration 文件。</p>
<p>创建 <code>Sources/App/Migrations/3_AddUserIDToPost.swift</code> 文件，添加如下代码：</p>
<div class="block-code" data-language="swift"><pre><code>import Fluent 

struct AddUserIDToPost: AsyncMigration { 
    func prepare(on database: Database) async throws { 
        try await database.schema(Post.schema)  
            .field(&quot;user_id&quot;, .uuid, .references(User.schema, &quot;id&quot;)) 
            .update()
    }

    func revert(on database: Database) async throws { 
       try await database.schema(Post.schema)
          .deleteField(&quot;user_id&quot;)
          .update()
    }
}</code></pre></div>
<p>这种方式将会给 Post 表添加一个可为空的 <code>user_id</code> 字段，虽然这种方式简单直接，但同时也意味着，我们容忍了 Post 可能不属于任何用户的情况。</p>
<p>在 <code>prepare</code> 方法中，我们使用 <code>references</code> 方法来创建了一个外键约束，这个外键将会引用 <code>User</code> 表中的 <code>id</code> 字段，确保了 <code>user_id</code> 在数据库层面的正确性，避免我们意外插入一个不存在的 <code>user_id</code>。</p>
<p>在 <code>revert</code> 方法中，我们使用 <code>deleteField</code> 方法来删除 <code>user_id</code> 字段，这个方法会同时删除它的外键约束。</p>
<p>随后，我们应当修改 <code>configure.swift</code> 将这条 Migration 添加到其中</p>
<div class="block-code" data-language="swift"><pre><code>app.migrations.add([CreatePost(), CreateUser(), AddUserIDToPost()])</code></pre></div>
<p>现在，运行 <code>vapor run migrate</code> 即可进行数据库的迁移。</p>
<h2>修改 Post Model</h2>
<p>完成了数据库 Schema 的修改后，我们需要进一步修改 Post Model</p>
<p>编辑 <code>Sources/App/Models/Post.swift</code> 文件如下：</p>
<div class="block-code" data-language="swift" data-hightlight="7,8"><pre><code>final class Post: Model, Content {
    static let schema = &quot;posts&quot;

    @ID(key: .id)
    var id: UUID?

    @OptionalParent(key: &quot;user_id&quot;)
    var user: User?

    @Field(key: &quot;content&quot;)
    var content: String
    
    @Timestamp(key: &quot;created_at&quot;, on: .create)
    var createdAt: Date?

    init() { }

    init(id: UUID? = nil, content: String) {
        self.id = id
        self.content = content
    }
}</code></pre></div>
<p>因为我们在给 Post 的 Schema 添加 <code>user_id</code> 字段时允许其为空，因此在 Post Model 中，我们使用 <code>@OptionalParent</code> 来表明 <code>user</code> 是不一定存在的。</p>
<p>与此同时，<code>@OptionalParent(key: &quot;user_id&quot;)</code> 表明了 <code>user_id</code> 和 User Model @ID 之间的对应关系，这与 Schema 中的 <code>.field(&quot;user_id&quot;, .uuid, .references(User.schema, &quot;id&quot;))</code> 形成对应关系。</p>
<h2>修改 User Model</h2>
<div class="block-code" data-language="swift" data-highlight="16,17"><pre><code>import Vapor
import Fluent

final class User: Model, Content {
    static let schema = &quot;users&quot;
    
    @ID(key: .id)
    var id: UUID?
    
    @Field(key: &quot;username&quot;)
    var username: String
    
    @Field(key: &quot;password_hash&quot;)
    var passwordHash: String

    @Children(for: \.$user)
    var posts: [Post]
    
    @Timestamp(key: &quot;created_at&quot;, on: .create)
    var createdAt: Date?
    
    init() {}
    
    init(id: UUID? = nil, username: String, passwordHash: String) {
        self.id = id
        self.username = username
        self.passwordHash = passwordHash
    }
}</code></pre></div>
<p>在 User Model 中，<code>@Children(for: \.$user)</code> 表明了 User 和 Post 之间的一对多关系，同时也说明了，这种关系是通过 Post Model 中的 <code>user</code> 属性来进行关联的。</p>
<p>现在，我们有两种方式可以创建 Post 了，一种是通过 Post Model 的 <code>user</code> 属性：</p>
<div class="block-code" data-language="swift"><pre><code>let user = User(username: &quot;hellouser&quot;, passwordHash: try await app.password.async.hash(&quot;123456&quot;))
try await user.save(on: app.db)
let post = Post(content: &quot;Hello, World!&quot;)
post.$user.id = try user.requiredID()</code></pre></div>
<p>另一种是通过 User Model 的 <code>posts</code> 属性</p>
<div class="block-code" data-language="swift"><pre><code>let user = User(username: &quot;hellouser&quot;, passwordHash: try await app.password.async.hash(&quot;123456&quot;))
try await user.save(on: app.db)
let post = Post(content: &quot;Hello, World!&quot;)
user.$posts.create(post, on: app.db)</code></pre></div>
<p>不管采用哪一种，我们都需要在保存 Post 前知道 <code>user_id</code>。</p>
<h2>使用 BasicAuthorization 进行用户验证</h2>
<p>在 PostController 中，我们需要修改 <code>create</code> 方法，使其能够接收 <code>user_id</code> 参数，并将其与 Post 关联起来。</p>
<p>那么如何将 <code>user_id</code> 传入到 <code>create</code> 方法中呢？</p>
<p>直接让客户端提供 <code>user_id</code> 参数肯定是不可以的，因为这样的话，客户端只需要伪造 <code>user_id</code> 就可以假装成别的用户发布，这显然是不安全的。</p>
<p>我们暂且通过最简单的 <code>BasicAuthorization</code> 来解决这个问题，BasicAuthorization 需要客户端提供 username 和 password 两个字段，并将其进行 Base64 编码后放在 HTTP Header 中：</p>
<p>计算格式如下</p>
<div class="block-code"><pre><code>base64(username:password)</code></pre></div>
<p>以上面的用户信息 <code>happyuser:123456</code> 为例，计算后的 Header 结果如下：</p>
<div class="block-code"><pre><code>Authorization: Basic aGFwcHl1c2VyOjEyMzQ1Ng==</code></pre></div>
<p>服务器端验证用户名和密码后，才会允许客户端创建对应用户的 Post。</p>
<p>BasicAuthorization 作为一种业界通用实践，Vapor 已经提供了 Model Authenticatable 来帮助我们完成这个功能</p>
<p>修改 <code>Sources/App/Models/User.swift</code> 在底部增加如下代码：</p>
<div class="block-code" data-language="swift"><pre><code>extension User: ModelAuthenticatable {
    static let usernameKey = \User.$username
    static let passwordHashKey = \User.$passwordHash

    func verify(password: String) throws -&gt; Bool {
        try Bcrypt.verify(password, created: self.passwordHash)
    }
}</code></pre></div>
<p><code>usernameKey</code> 和 <code>passwordHashKey</code> 分别表明了 User Model 中的哪两个属性用于存储用户名和密码。当客户端提供用户名和密码时，Vapor 会自动将其与 User Model 中的 <code>usernameKey</code> 和 <code>passwordHashKey</code> 进行对应，从而完成用户验证。</p>
<p>接下来我们需要确保 Post 在创建前，用户已经通过了验证，这可以通过在 PostController 的路由中，加入权限验证相关的 Middleware 来完成，Vapor 已经提供了 <code>User.authenticator()</code> 来帮助我们完成 BasicAuthorization 验证的功能。</p>
<p>修改 <code>Sources/App/Controllers/PostController.swift</code> 中 boot 代码如下</p>
<div class="block-code" data-language="swift" data-highlight="4"><pre><code>func boot(routes: RoutesBuilder) throws {
    routes.group(&quot;posts&quot;) { posts in
        posts.get(use: index)
        posts.grouped(User.authenticator()).post(use: create)
    }
}</code></pre></div>
<p>在 <code>posts</code> 路由组中，当客户端请求 <code>posts</code> 路由组中的 <code>post</code> 方法前，<code>User.authenticator()</code> 会进行 BasicAuthorization 验证，如果没有通过验证，Vapor 会返回 401 错误。</p>
<h2>修改 PostController</h2>
<p>修改 <code>Sources/App/Controllers/PostController.swift</code> 中的 <code>create</code> 方法如下：</p>
<div class="block-code" data-language="swift"><pre><code>func create(req: Request) async throws -&gt; Post {
    let user = try req.auth.require(User.self)

    let postData = try req.content.decode(Post.CreateDTO.self)
        
    let post = Post(content: postData.content)

    post.$user.id = try user.requireID()
    
    try await post.create(on: req.db)
    
    return post
}</code></pre></div>
<p>在 <code>create</code> 方法中，我们首先通过 <code>req.auth.require(User.self)</code> 获取到了已经通过验证的用户，然后通过 <code>req.content.decode(Post.CreateDTO.self)</code> 获取到了客户端传入的 Post 数据，最后通过 <code>post.$user.id = try user.requireID()</code> 将 Post 和 User 关联起来。</p>
<h2>完善 Post 的单元测试</h2>
<p>修改 <code>Tests/AppTests/PostTests.swift</code> 如下：</p>
<div class="block-code" data-language="swift"><pre><code>@testable import App
import XCTVapor

final class PostTests: XCTestCase {
    var app: Application!

    override func setUp() async throws {
        app = Application(.testing)
        try configure(app)

        try await app.autoRevert()
        try await app.autoMigrate()
    }

    
    override func tearDown() async throws {
        app.shutdown()
    }
    

    func testCreatePost() async throws {
        let user = User(username: &quot;hellouser&quot;, passwordHash: try await app.password.async.hash(&quot;123456&quot;))
        try await user.save(on: app.db)
        let postDTO = Post.CreateDTO(content: &quot;Post created from test&quot;)

        try app.test(.POST, &quot;posts&quot;, beforeRequest: { req in
            try req.content.encode(postDTO)
            req.headers.basicAuthorization = BasicAuthorization(username: user.username, password: &quot;123456&quot;)
        }, afterResponse: { res in
            XCTAssertEqual(res.status, .ok)
            
            let post = try res.content.decode(Post.self)
            
            XCTAssertEqual(postDTO.content, post.content)
            XCTAssertEqual(try user.requireID(), post.$user.id)
        })
    }
}</code></pre></div>
<p>在 <code>testCreatePost</code> 中，我们首先创建了一个用户，然后创建了一个 Post DTO，接着通过 <code>app.test</code> 方法，模拟了客户端的请求，最后验证了 Post 的 User 来判断是否创建成功。</p>
<h2>总结</h2>
<p>在这一章中，我们学习了如何在 Vapor 中构建一对多的关系，同时也学习了如何使用 BasicAuthorization 来进行用户验证。但这种方式虽然简单，但也有很多缺点，在下一章中，我们会学习使用 BearerAuthentication 来对用户进行验证，并进一步修复 User Model 的安全隐患。</p>
<p>本章代码可以在 <a href="https://github.com/kevinzhow/swift-on-server-tour/tree/main/6">Github</a> 中找到。</p>
]]></content:encoded><dc:creator><![CDATA[Kevin Zhow]]></dc:creator></item><item><title><![CDATA[Swift on Server Tour 5 创建 Users]]></title><guid>https://blog.kevinzhow.com/posts/swift-on-server-5/zh</guid><link>https://blog.kevinzhow.com/posts/swift-on-server-5/zh</link><pubDate>Sat, 21 Oct 2023 05:51:11 +0000</pubDate><content:encoded><![CDATA[<p>在本章中我们将在数据库中创建 <code>User</code> 表，用于存储用户信息，并理解如何将 <code>Post</code> 表和 <code>User</code> 关联起来，以便我们可以知道每篇微博是由哪个用户编写的。</p>
<p>本章代码可以在 <a href="https://github.com/kevinzhow/swift-on-server-tour/tree/main/5">Github</a> 中找到。</p>
<h2>设计 User 表的数据结构</h2>
<p>一个基础的 <code>User</code> 表应该包含以下字段</p>
<ul>
<li><code>id</code>：主键，用于唯一标识一个用户</li>
<li><code>username</code>：用户名，用于登录</li>
<li><code>password_hash</code>：密码，用于登录，存储的应该是加密后的密码</li>
<li><code>createdAt</code>：创建时间，用于记录用户创建时间</li>
</ul>
<p>如果我们插入了一个假数据 <code>happyuser</code>，那么我们的 <code>User</code> 表应该是这样的：</p>
<div class="block-table"><table><thead>
<tr>
  <th style="text-align:center">id</th>
  <th style="text-align:center">username</th>
  <th style="text-align:center">password_hash</th>
  <th style="text-align:center">createdAt</th>
</tr>
</thead>
<tbody>
<tr>
  <td style="text-align:center">0</td>
  <td style="text-align:center">happyuser</td>
  <td style="text-align:center">encrypted text</td>
  <td style="text-align:center">2023-1-1</td>
</tr>
</tbody>
</table></div><p>那么如何关联 <code>Post</code> 表呢？</p>
<h2>数据库的关系型设计</h2>
<p>在关系性数据库中，表关系被总结为三种：</p>
<ul>
<li>一对一（One-to-One）</li>
<li>一对多（One-to-Many）</li>
<li>多对多（Many-to-Many）</li>
</ul>
<p>我们的 <code>User</code> 表和 <code>Post</code> 表的关系是一对多，即一个用户可以有多篇微博，而一篇微博只能属于一个用户。</p>
<h2>一对多关系的设计</h2>
<p>在数据库中，我们可以通过在 <code>Post</code> 表中添加一个 <code>userId</code> 字段来表示这种关系，这个字段用于存储 <code>User</code> 表中的 <code>id</code>，即 <code>Post</code> 表中的每一行都会有一个 <code>userId</code> 字段，用于表示这篇微博是由哪个用户编写的。</p>
<p>修改之前的 <code>Post</code> 表，添加 <code>userId</code> 字段，表示如下：</p>
<div class="block-table"><table><thead>
<tr>
  <th style="text-align:center">id</th>
  <th style="text-align:center">userId</th>
  <th style="text-align:center">content</th>
  <th style="text-align:center">createdAt</th>
</tr>
</thead>
<tbody>
<tr>
  <td style="text-align:center">0</td>
  <td style="text-align:center">0</td>
  <td style="text-align:center">这是第一篇博文</td>
  <td style="text-align:center">2023-1-1</td>
</tr>
<tr>
  <td style="text-align:center">1</td>
  <td style="text-align:center">0</td>
  <td style="text-align:center">在 LONCAFE 写代码感觉不错呢！</td>
  <td style="text-align:center">2023/7/9 14:44</td>
</tr>
</tbody>
</table></div><p>当我们需要查询 happyuser 的所有微博时，只需要在 <code>Post</code> 表中查询 <code>userId = 0</code> 的所有行即可。</p>
<h2>创建 User Model</h2>
<p>在 <code>Sources/App/Models</code> 目录下创建 <code>User.swift</code> 文件，添加如下代码：</p>
<div class="block-code" data-language="swift"><pre><code>import Vapor
import Fluent

final class User: Model, Content {
    static let schema = &quot;users&quot;
    
    @ID(key: .id)
    var id: UUID?
    
    @Field(key: &quot;username&quot;)
    var username: String
    
    @Field(key: &quot;password_hash&quot;)
    var passwordHash: String
    
    @Timestamp(key: &quot;created_at&quot;, on: .create)
    var createdAt: Date?
    
    init() {}
    
    init(id: UUID? = nil, username: String, passwordHash: String) {
        self.id = id
        self.username = username
        self.passwordHash = passwordHash
    }
}

extension User {
    struct CreateDTO: Content {
        let username: String
        let password: String
    }
}</code></pre></div>
<p>接下来，我们需要添加 <code>User</code> 表的 Migration 文件，用于在数据库中创建 <code>User</code> 表。</p>
<p>在 <code>Sources/App/Migrations</code> 目录下创建 <code>2_CreateUser.swift</code> 文件，添加如下代码：</p>
<div class="block-code" data-language="swift"><pre><code>import Fluent 

struct CreateUser: AsyncMigration { 
    func prepare(on database: Database) async throws { 
        try await database.schema(User.schema)  
            .id()  
            .field(&quot;username&quot;, .string, .required)
            .field(&quot;password_hash&quot;, .string, .required)
            .field(&quot;created_at&quot;, .datetime)
            .create()
    }

    func revert(on database: Database) async throws { 
        try await database.schema(User.schema).delete() 
    }
}</code></pre></div>
<p>在 <code>configure.swift</code> 文件中修改 <code>app.migrations.add</code>，用于注册 <code>User</code> Migration：</p>
<div class="block-code" data-language="swift" data-hightlight="1"><pre><code>app.passwords.use(.bcrypt) // Bcrypt 是一种密码加密算法，可以确保多次加密后的密码都是不同的，但是可以通过原始密码验证
app.migrations.add([CreatePost(), CreateUser()])</code></pre></div>
<h2>创建 User Controller</h2>
<p>在 <code>Sources/App/Controllers</code> 目录下创建 <code>UserController.swift</code> 文件，添加如下代码：</p>
<div class="block-code" data-language="swift"><pre><code>import Fluent
import Vapor

struct UserController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        routes.group(&quot;users&quot;) { posts in
            posts.get(use: index)
            posts.post(use: create)
        }
    }

    func create(req: Request) async throws -&gt; User {
        let postData = try req.content.decode(User.CreateDTO.self)
            
        let user = User(username: postData.username, passwordHash: try await req.password.async.hash(postData.password))
        
        try await user.create(on: req.db)
        
        return user
    }

    func index(req: Request) async throws -&gt; [User] {
        let users = try await User.query(on: req.db).all()
        
        return users
    }
}</code></pre></div>
<p>在 <code>configure.swift</code> 文件中注册 <code>UserController</code>：</p>
<div class="block-code" data-language="swift"><pre><code>try app.register(collection: UserController())</code></pre></div>
<h2>创建 User 的 XCTest 测试用例</h2>
<p>在 <code>Tests/AppTests</code> 目录下创建 <code>UserTests.swift</code> 文件，添加如下代码：</p>
<div class="block-code" data-language="swift"><pre><code>@testable import App
import XCTVapor

final class UserTests: XCTestCase {
    var app: Application!

    override func setUp() async throws {
        app = Application(.testing)
        try configure(app)

        try await app.autoRevert()
        try await app.autoMigrate()
    }

    
    override func tearDown() async throws {
        app.shutdown()
    }
    
    func testCreateUser() async throws {
        let userData = User.CreateDTO(username: &quot;happyuser&quot;, password: &quot;123456&quot;)
        try app.test(.POST, &quot;users&quot;, beforeRequest: { req in
            try req.content.encode(userData)
        }, afterResponse: { res in
            XCTAssertEqual(res.status, .ok)
            
            let user = try res.content.decode(User.self)
            
            XCTAssertEqual(user.username, userData.username)
            XCTAssertTrue(try app.password.verify(&quot;123456&quot;, created: user.passwordHash))
        })
    }
    
    func testGetUsers() async throws {
        let user = User(username: &quot;hellouser&quot;, passwordHash: try await app.password.async.hash(&quot;123456&quot;))
        try await user.save(on: app.db)
        try app.test(.GET, &quot;users&quot;, afterResponse: { res in
            XCTAssertEqual(res.status, .ok)
            
            let users = try res.content.decode([User].self)
            
            XCTAssertEqual(users.count, 1)
            XCTAssertEqual(users[0].username, user.username)
        })
    }
}</code></pre></div>
<h2>运行测试用例</h2>
<p>在终端中运行 <code>swift test</code> 命令，顺利的话，可以看到测试用例全部通过。</p>
<h2>总结</h2>
<p>在本章中，我们学习了如何在数据库中创建 <code>User</code> 表，以及如何将 <code>Post</code> 表和 <code>User</code> 表关联起来，但我们尚未真正在数据库中关联这两个表，我们将在下一章中完成这个功能。</p>
<p>本章代码可以在 <a href="https://github.com/kevinzhow/swift-on-server-tour/tree/main/5">Github</a> 中找到。</p>
]]></content:encoded><dc:creator><![CDATA[Kevin Zhow]]></dc:creator></item><item><title><![CDATA[Swift on Server Tour 4 构建 Post Controller]]></title><guid>https://blog.kevinzhow.com/posts/swift-on-server-tour-4/zh</guid><link>https://blog.kevinzhow.com/posts/swift-on-server-tour-4/zh</link><pubDate>Sun, 06 Aug 2023 07:57:46 +0000</pubDate><content:encoded><![CDATA[<p>在本章中，我们将为 Post 创建一个 Controller，将 Route 和 Route Handler 都移动到这里，保持项目代码的整洁</p>
<p>.. toc::</p>
<h2>Controller 中的 Routing</h2>
<p>在上一章中，下面两个和 Post 相关的 Route 和 Handler 都写在了 <code>Sources/App/configure.swift</code> 中</p>
<ul>
<li>app.post(&quot;posts&quot;)</li>
<li>app.get(&quot;posts&quot;)</li>
</ul>
<p>现在我们创建 <code>Sources/App/Controllers/PostController.swift</code> 文件，并将代码移动到这里</p>
<div class="block-code" data-language="swift" data-highlight="6-9"><pre><code>import Fluent
import Vapor

struct PostController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        routes.group(&quot;posts&quot;) { posts in
            posts.get(use: index)
            posts.post(use: create)
        }
    }

    func create(req: Request) async throws -&gt; Post {
        let postData = try req.content.decode(Post.CreateDTO.self)
            
        let post = Post(content: postData.content)
        
        try await post.create(on: req.db)
        
        return post
    }

    func index(req: Request) async throws -&gt; [Post] {
        let posts = try await Post.query(on: req.db).all()
        
        return posts
    }
}</code></pre></div>
<p>在 boot 函数中，我们通过 <a href="https://docs.vapor.codes/basics/routing/#route-groups">RoutesBuilder</a> 定义了 route 和 handler 之间的关系。</p>
<h2>注册 Controller</h2>
<p>现在我们需要将 Controller 注册到 Application 中，让 Application 知道有哪些路由需要被处理</p>
<p>修改 <code>Sources/App/configure.swift</code></p>
<div class="block-code" data-language="swift" data-highlight="17"><pre><code>import Fluent
import FluentPostgresDriver
import Vapor

public func configure(_ app: Application) throws {
    app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
        hostname: Environment.get(&quot;DATABASE_HOST&quot;) ?? &quot;localhost&quot;,
        port: Environment.get(&quot;DATABASE_PORT&quot;).flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
        username: Environment.get(&quot;DATABASE_USERNAME&quot;) ?? &quot;vapor_username&quot;,
        password: Environment.get(&quot;DATABASE_PASSWORD&quot;) ?? &quot;vapor_password&quot;,
        database: Environment.get(&quot;DATABASE_NAME&quot;) ?? &quot;vapor_database&quot;,
        tls: .prefer(try .init(configuration: .clientDefault)))
    ), as: .psql)

    app.migrations.add([CreatePost()])

    try app.register(collection: PostController())
}</code></pre></div>
<p>完成添加之后，可以使用 <code>swift run App routes</code> 来打印 App 内生效的 routes</p>
<div class="block-code"><pre><code>+------+--------+
| GET  | /      |
+------+--------+
| GET  | /posts |
+------+--------+
| POST | /posts |
+------+--------+</code></pre></div>
<h2>测试 PostTests</h2>
<p>我们的项目经过了一遍较大的结构改动，但如果逻辑没有错误，我们之前写的 PostTests 应该仍然是可以通过的，换句话说，只要 PostTests 通过了， 我们就无需过于担心功能是否可以正常运行。</p>
<p>运行测试</p>
<div class="block-code"><pre><code>swift test</code></pre></div>
<p>测试结果通过</p>
<div class="block-code"><pre><code>Test Case '-[AppTests.PostTests testCreatePost]' passed (0.230 seconds).</code></pre></div>
<p>Unit Test 又一次验证了它的必要性。</p>
<h2>下章预告</h2>
<p>在下一章中，我们将会添加 User 并关联 User 和 Post</p>
]]></content:encoded><dc:creator><![CDATA[Kevin Zhow]]></dc:creator></item><item><title><![CDATA[Swift on Server Tour 3 构建 Post 的 API]]></title><guid>https://blog.kevinzhow.com/posts/swift-on-server-tour-3-build-api/zh</guid><link>https://blog.kevinzhow.com/posts/swift-on-server-tour-3-build-api/zh</link><pubDate>Sun, 23 Jul 2023 14:21:58 +0000</pubDate><content:encoded><![CDATA[<p>在本章中，我们将为 Post 配置 API，使得我们可以通过 HTTP 请求来完成 Post 的创建。同时，我们将引入 Environment 来为我们的服务器区分 Development 和 Testing 环境。</p>
<p>.. toc::</p>
<h2>API 的基础理念</h2>
<p>在我们编程的时候，经常会使用第三方提供的接口来完成一个特定任务，使得原本复杂的任务，只需要调用一个 API 就可以完成，这便是 API 最基础的理念，将特定的任务进行封装。</p>
<p>API 几乎无处不在，以用户使用 App 发微博为例，可以表示为下面的流程</p>
<p>User -&gt; UI -&gt; Client -&gt; HTTP -&gt; Server -&gt; Fluent -&gt; Database</p>
<p>用户操作 UI 编写内容，内容被记录在客户端 App，客户端通过 HTTP 请求发送到服务器，服务器接收到请求后，这些请求通过 Fluent 转化成 SQL 语句，最终在数据库中完成数据更新。</p>
<ul>
<li><p>UI 提供了「用户」和「客户端」之间的 API</p>
</li>
<li><p>HTTP 接口提供了「客户端」和「服务器」之间的 API</p>
</li>
<li><p>Fluent 提供了「服务器」和「数据库」之间的 API</p>
</li>
</ul>
<p>在本章中，我们将要设计的就是 Client -&gt; Server 之间的 HTTP API.</p>
<h2>创建 Post 的 HTTP API</h2>
<p>在第一章中，我们曾提供了一个非常基础的 <code>&quot;It works!&quot;</code> 的 API，了解了一个 HTTP 请求所需的基本参数。</p>
<p>现在，我们希望有一个可以创建 Post 的 API，需求如下</p>
<ul>
<li>使用 POST 请求</li>
<li>路径为 <code>/posts</code></li>
<li>请求内容为 JSON，内容包含一个 <code>content</code> 字段来表示 Post 的内容</li>
</ul>
<p>如果客户端按照这个标准发来一个 HTTP 请求，那么内容看起来就是这样的</p>
<div class="block-code"><pre><code>POST /posts HTTP/1.1
Host: 127.0.0.1:8080
Content-Type: application/json
Content-Length: 49

{
    &quot;content&quot;: &quot;First post from HTTP request&quot;
}</code></pre></div>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>需要客户端填写的是 <code>POST /posts</code> 和 JSON 部分的内容，其他如Host， Content-Type， Content-Length 均会在请求时自动生成。</p>
</section>
<p>首先，定义一个叫做 <code>Post.CreateDTO</code> 的 DTO struct 来表示客户端发来的 JSON 内容格式</p>
<div class="block-code" data-language="swift"><pre><code>extension Post {
    struct CreateDTO {
        let content: String
    }
}</code></pre></div>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>DTO，全称 Data Transfer Object，是一种常见的设计模式，用于在服务之间传输数据，可以灵活定义字段，便于封装和安全性验证，亦可减少流量的消耗。</p>
</section>
<p>接下来编辑 <code>Sources/App/configure.swift</code> 加入对 <code>POST /posts</code> 的处理</p>
<div class="block-code" data-language="swift" data-highlight="17-25"><pre><code>import Fluent
import FluentPostgresDriver
import Vapor

public func configure(_ app: Application) throws {
    app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
        hostname: &quot;localhost&quot;,
        port: 5432,
        username: &quot;vapor_username&quot;,
        password: &quot;vapor_password&quot;,
        database: &quot;vapor_database&quot;,
        tls: .prefer(try .init(configuration: .clientDefault)))
    ), as: .psql)

    app.migrations.add([CreatePost()])

    app.post(&quot;posts&quot;) { req async throws -&gt; Post in
        let postData = try req.content.decode(Post.CreateDTO.self)
            
        let post = Post(content: postData.content)
        
        try await post.create(on: req.db)
        
        return post
    }
}</code></pre></div>
<ul>
<li>req.content.decode(Post.CreateDTO.self) 表示将请求中 body 的内容解码为 Post.CreateDTO</li>
</ul>
<p>修改 <code>Sources/App/Models/Post.swift</code> 使 Post 支持被编码为 JSON</p>
<div class="block-code" data-language="swift" data-highlight="4,28-32"><pre><code>import Vapor
import Fluent

final class Post: Model, Content {
    // 数据库中的表名
    static let schema = &quot;posts&quot;

    // 唯一性标识符
    @ID(key: .id)
    var id: UUID?

    // 内容
    @Field(key: &quot;content&quot;)
    var content: String

    // 创建时间
    @Timestamp(key: &quot;created_at&quot;, on: .create)
    var createdAt: Date?

    init() { }

    init(id: UUID? = nil, content: String) {
        self.id = id
        self.content = content
    }
}

extension Post {
    struct CreateDTO: Content {
        let content: String
    }
}</code></pre></div>
<ul>
<li>给 Post 增加 Content 协议，使之具备 Codable 的特性，能够被编码成 JSON 返回给用户</li>
<li>增加了一个<code>Post.CreateDTO</code> 的结构体，同样遵循了 Content 协议，使之可以用来解码用户发来的 JSON</li>
</ul>
<h2>测试 API</h2>
<p>现在，我们可以修改 <code>AppTests/PostTests.swift</code> 来测试这个 API 是否能够跑通了</p>
<div class="block-code" data-language="swift" data-highlight="17,21-23"><pre><code>@testable import App
import XCTVapor

final class PostTests: XCTestCase {
    func testCreatePost() async throws {
        let app = Application(.testing)
        defer { app.shutdown() }

        try configure(app)
        
        try await app.autoRevert()
        try await app.autoMigrate()

        let postDTO = Post.CreateDTO(content: &quot;Post created from test&quot;)

        try app.test(.POST, &quot;posts&quot;, beforeRequest: { req in
            try req.content.encode(postDTO)
        }, afterResponse: { res in
            XCTAssertEqual(res.status, .ok)
            
            let post = try res.content.decode(Post.self)
            
            XCTAssertEqual(postDTO.content, post.content)
        })
    }
}</code></pre></div>
<ul>
<li>使用 app.test(.POST, &quot;posts&quot;) 模拟外部 HTTP 请求</li>
<li>beforeRequest 可以对请求数据进行修改，我们通过 <code>req.content.encode(postDTO)</code> 将 postDTO 编码到 request 的 body 中，在默认情况下，请求时数据会采用 JSON 编码</li>
<li>afterResponse 是请求后从服务器获得的响应，我们首先判断 res.status 是否是 <code>HTTPStatus.ok</code> 即 200</li>
<li>XCTAssertEqual(postDTO.content, post.content) 判断服务器上创建的数据是否和发送的一致</li>
</ul>
<p>运行这段测试，不出意外，将可以看到测试通过的信息</p>
<div class="block-code"><pre><code>Test Case '-[AppTests.PostTests testCreatePost]' passed (0.269 seconds).</code></pre></div>
<p>恭喜你！第一个 API 调通了！</p>
<h2>使用 cURL 命令进行测试</h2>
<p>除了使用 Unit Test 进行 API 测试以外，我们也可以使用 cURL 命令进行测试，以上面创建 Post 的请求为例，我们可以在终端中输入以下命令</p>
<div class="block-code" data-language="bash"><pre><code>curl -i --location 'http://127.0.0.1:8080/posts' \
--header 'Content-Type: application/json' \
--data '{
    &quot;content&quot;: &quot;First post from HTTP request&quot;
}'</code></pre></div>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>通过 -i 参数，我们要求 curl 打印出完整的 HTTP 响应信息</p>
</section>
<p>回车后，正常情况下会响应如下内容</p>
<div class="block-code" data-language="bash"><pre><code>HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 121
connection: keep-alive
date: Sun, 23 Jul 2023 13:22:20 GMT

{&quot;content&quot;:&quot;First post from HTTP request&quot;,&quot;createdAt&quot;:&quot;2023-07-23T13:22:20Z&quot;,&quot;id&quot;:&quot;1C9A6A1D-98B8-4871-8DE0-BDF011845ADA&quot;}%</code></pre></div>
<p>通过 cURL 我们可以很方便的观察 HTTP 信息的构成，通过这则信息，我们也可以更好的理解 Unit Test 中 <code>XCTAssertEqual(res.status, .ok)</code> 和 <code>200 OK</code> 的对应关系。</p>
<h2>使用 Postman 进行测试</h2>
<p><a href="https://www.postman.com/">Postman</a> 是一款非常流行的 API 测试软件，不仅可以方便的进行参数调试，也可以将编辑好的请求转换成其他软件的请求，比如下图右边转换为 cURL</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8309880757_300282.png" alt="image.png" /></figure></div><h2>列出所有 Post</h2>
<p>创建 Post 之后，最迫切的期望就是列出所有的 Post，现在我们可以编辑 <code>Sources/App/configure.swift</code> 来加入 <code>GET /posts</code> 的支持</p>
<div class="block-code" data-language="swift"><pre><code>import Fluent
import FluentPostgresDriver
import Vapor

public func configure(_ app: Application) throws {
    ...

    app.get(&quot;posts&quot;) { req async throws -&gt; [Post] in
        let posts = try await Post.query(on: req.db).all()
        
        return posts
    }
}</code></pre></div>
<ul>
<li>Post.query(on: req.db).all() 将获取数据库中所有的 Post.</li>
</ul>
<p>现在，如果你使用浏览器访问 <code>http://127.0.0.1:8080/posts</code> 将可以看到所有存储在数据库里的 posts</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8309880123_31565.png" alt="image.png" /></figure></div><p>使用 Postman 来查看会更方便一些</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8309880195_309937.png" alt="image.png" /></figure></div><h2>使用 Environment 区分服务器环境</h2>
<p>到目前为止，我们并没有区分服务器的开发环境和测试环境，这导致每次运行单元测试的时候，都会将我们之前在开发环境里创建的数据清除掉，这显然会给我们带来很多麻烦。</p>
<p>我们的数据库连接信息定义在 <code>Sources/App/configure.swift</code> 之中，这意味着如果我们可以让 App 启动时动态的配置数据库的连接信息，分别连接「开发环境数据库」和「测试环境数据库」，就可以解决我们的问题。</p>
<p>在 Vapor 中，通过 <a href="https://docs.vapor.codes/basics/environment/?h=en">Environment</a> 我们可以轻松的将他们分开。</p>
<p>回顾 <code>Sources/App/main.swift</code>，我们使用 <code>let app = Application()</code> 启动我们的 App，但实际上这段代码隐藏了一些细节，它包含了 Environment 的默认值 <code>Environment.development</code></p>
<p>因此更详尽的代码应该是 <code>let app = Application(.development)</code></p>
<div class="block-code" data-language="swift" data-highlight="5"><pre><code>import Vapor
import Fluent
import FluentPostgresDriver

let app = Application(.development)

app.http.server.configuration.port = 8080

defer { app.shutdown() }

app.get { req async in
    &quot;It works!&quot;
}

try configure(app)

try app.run()</code></pre></div>
<ul>
<li>Application() 在启动时，接受 Environment 变量，并读取与 Environment 对应的 <code>.env</code> 文件</li>
<li>在上面的 <code>Application(.development)</code> 代码中，Application 会读取 <code>.env.developemnt</code></li>
</ul>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>Vapor 的服务器启动时，默认是 development 环境，因此会从服务器启动目录的 .env.development 中读取环境变量，如果你使用的 Xcode，请记得修改 Scheme 中的 <a href="https://docs.vapor.codes/getting-started/xcode/#custom-working-directory">Working Directory</a> 为项目根目录</p>
</section>
<p>修改编辑项目根目录中的<code>.env.development</code> 配置开发环境变量</p>
<div class="block-code"><pre><code>DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USERNAME=vapor_username
DATABASE_PASSWORD=vapor_password
DATABASE_NAME=vapor_database</code></pre></div>
<p>修改  <code>Sources/App/configure.swift</code> 改变数据库参数的获取方式</p>
<div class="block-code" data-language="swift"><pre><code>app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
    hostname: Environment.get(&quot;DATABASE_HOST&quot;) ?? &quot;localhost&quot;,
    port: Environment.get(&quot;DATABASE_PORT&quot;).flatMap(Int.init(_:)) ?? SQLPostgresConfiguration.ianaPortNumber,
    username: Environment.get(&quot;DATABASE_USERNAME&quot;) ?? &quot;vapor_username&quot;,
    password: Environment.get(&quot;DATABASE_PASSWORD&quot;) ?? &quot;vapor_password&quot;,
    database: Environment.get(&quot;DATABASE_NAME&quot;) ?? &quot;vapor_database&quot;,
    tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)</code></pre></div>
<p>现在，我们只解决了 develpment 环境的问题，testing 环境需要一个新的数据库，以及一份对应的 <code>.env.testing</code></p>
<p>修改 <code>docker-compose.yml</code> 增加 db_test 服务</p>
<p>值得注意的是我们在 volumes 增加了 db_data_test，并将 db_test 的端口设置为了 5442</p>
<div class="block-code" data-language="yaml" data-highlight="5,19-29"><pre><code>version: '3.7'  # 定义 Docker Compose 文件的版本，此处使用的是版本 3.7

volumes:  # 定义卷部分
  db_data:  # docker 会使用这个键作为名字，自动创建 db_data 卷来存储数据
  db_data_test:

services:  # 定义服务部分

  db:  # db 服务配置
    image: 'postgres:15-alpine'  # 使用 PostgreSQL 15 Alpine 版本的镜像
    volumes:  # 定义挂载卷
      - 'db_data:/var/lib/postgresql/data/pgdata'  # 将 db_data 卷挂载到容器的 /var/lib/postgresql/data/pgdata 目录
    environment:  # 定义环境变量
      PGDATA: '/var/lib/postgresql/data/pgdata'  # 设置 PGDATA 环境变量为 /var/lib/postgresql/data/pgdata
      POSTGRES_USER: 'vapor_username'  # 设置 POSTGRES_USER 环境变量为 vapor_username
      POSTGRES_PASSWORD: 'vapor_password'  # 设置 POSTGRES_PASSWORD 环境变量为 vapor_password
      POSTGRES_DB: 'vapor_database'  # 设置 POSTGRES_DB 环境变量为 vapor_database
    ports:  # 定义端口映射，将主机的 5432 端口映射到容器的 5432 端口
      - '5432:5432'
  db_test: 
    image: 'postgres:15-alpine' 
    volumes: 
      - 'db_data_test:/var/lib/postgresql/data/pgdata' 
    environment:  # 定义环境变量
      PGDATA: '/var/lib/postgresql/data/pgdata'
      POSTGRES_USER: 'vapor_username'
      POSTGRES_PASSWORD: 'vapor_password'
      POSTGRES_DB: 'vapor_database'
    ports:
      - '5442:5432'</code></pre></div>
<p>创建 <code>.env.testing</code> 写入以下内容</p>
<div class="block-code"><pre><code>DATABASE_HOST=localhost
DATABASE_PORT=5442
DATABASE_USERNAME=vapor_username
DATABASE_PASSWORD=vapor_password
DATABASE_NAME=vapor_database</code></pre></div>
<p>启动 db_test</p>
<div class="block-code"><pre><code>docker-compose up db_test -d</code></pre></div>
<p>现在，服务器的 development 和 testing 环境的数据库就不会再相互干扰了。</p>
<p>本章节的代码可以在 <a href="https://github.com/kevinzhow/swift-on-server-tour/tree/main/3">https://github.com/kevinzhow/swift-on-server-tour/tree/main/3</a> 中找到</p>
<h2>下章预告</h2>
<p>目前我们 Post 相关的操作都堆在了 <code>configure.swift</code> 中，在下一章中，我们将学习如何将他们移动到 Controller 中，并继续完善我们 Post 的查看与删除。</p>
]]></content:encoded><dc:creator><![CDATA[Kevin Zhow]]></dc:creator></item><item><title><![CDATA[将不懂的日语一拍扫尽，介绍捧读全新的「OCR 工作台」功能]]></title><guid>https://blog.kevinzhow.com/posts/pengdu-ocr-desk/zh</guid><link>https://blog.kevinzhow.com/posts/pengdu-ocr-desk/zh</link><pubDate>Tue, 18 Jul 2023 13:20:06 +0000</pubDate><content:encoded><![CDATA[<div class="blockquote"><blockquote><p>捧读的 iOS 和 Android 版本均已推送此更新</p>
</blockquote></div>
<p>最近需要阅读很多日语资料，比如新闻，传单，书，漫画等等，复杂的场景也必然导致我对 OCR 有了一系列的痛点，因此对捧读的 OCR 功能也有了一些新的思考</p>
<p>成果如下，欢迎观看，点赞，转发给好朋友！</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/-bQybwIUX84" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>

<h2>读书，反复拍照</h2>
<p>最近去图书馆借了一些日文书，阅读的时候堪比文盲，真是得一页一页的用捧读扫描，然后看看汉字的发音都是什么，再查查一些自己不认识的字。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8310314283_107425.png" alt="IMG_9548.PNG" /></figure></div><p>常用的一个流程就是</p>
<ol>
<li>对着书的一页拍照</li>
<li>希望选择一个区域进行分析</li>
<li>加生词本</li>
<li>看完了拍下一页</li>
</ol>
<p>因此针对这个流程做了一些列的调整，同时左上角也加入了一个快捷选择新图片的入口，用户无需离开这个页面即可继续操作其他图片，这也使得 OCR 工作台 实至名归。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8310317926_099628.png" alt="IMG_9774.PNG" /></figure></div><h2>漫画、报纸，区域框选</h2>
<p>看漫画报纸的时候，由于内容比较分散，文本的通常关联性比较低，就需要比较精确的选取某一个区域的内容</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8310314257_467062.png" alt="IMG_9758.PNG" /></figure></div><p>而针对漫画这种场景，设置中也增加了一个漫画模式，可以将框选的文本进行合并，避免「出其不意」的分段问题。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8310314066_4734535.png" alt="IMG_9775.PNG" /></figure></div><h2>精读，逐行校对</h2>
<p>一些比较严谨的资料，也需要能够进行逐行校对，捧读在这个环节实现了一个自动定位的功能，即编辑文本框的时候，图片会自动定位到文本框所对应的位置，并用黄色高亮起来。</p>
<p>不过这个环节在辨识度方面还有持续优化的空间。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8310314003_986673.png" alt="IMG_9776.PNG" /></figure></div><h2>OCR 历史记录</h2>
<p>与上面这些功能相对应的，历史记录也进行了一次大升级，所有分析过的内容均可随时翻出来再次分析，并且会完美记录上次编辑的状态。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8310313842_726915.png" alt="IMG_9777.PNG" /></figure></div><h2>结语</h2>
<p>以上就是本次 OCR 工作台更新的主要功能啦，如果你有使用场景希望我针对性的优化，欢迎通过 App 内的意见反馈与我联系</p>
<div class="schema" data-type="SoftwareApplication"><img referrerpolicy="no-referrer" src="https://is1-ssl.mzstatic.com/image/thumb/Purple126/v4/5e/6a/7d/5e6a7dd5-55c7-e62f-b00c-35d65e47b897/AppIcon-0-2x-4-0-85-220.png/630x630w.png" alt="Oyomi - Japanese Reader" loading="lazy" width="120" /><div class="schema-main"><div class="schema-name">Oyomi - Japanese Reader</div><div class="schema-author">kaiwen zhou</div><div class="schema-action"><a href="https://apps.apple.com/us/app/oyomi-japanese-reader/id1474251984" rel="noopener noreferrer">View</a><span class="schema-rating">★★★★★ 5</span></div></div></div>]]></content:encoded><dc:creator><![CDATA[Kevin Zhow]]></dc:creator></item><item><title><![CDATA[Swift on Server Tour 2 连通你的数据库与服务器]]></title><guid>https://blog.kevinzhow.com/posts/swift-on-server-tour-02/zh</guid><link>https://blog.kevinzhow.com/posts/swift-on-server-tour-02/zh</link><pubDate>Sun, 09 Jul 2023 12:11:13 +0000</pubDate><content:encoded><![CDATA[<p>在本章，我们将设计 Micro Blog 中 Post（帖子）的数据模型，并使用 PostgreSQL 作为我们的数据库来存储内容，在最后，我们会编写一个单元测试，来测试 Post 的创建功能。</p>
<p>.. toc:: Table of Contents
:max-level: 3</p>
<h2>设计 Post 的数据模型</h2>
<p>在我们平时使用的微博系统里，帖子都是由用户发布的，因此作为数据模型的设计，通常的顺序也是先设计用户，然后再设计 Post 的数据模型。</p>
<p>但为了更好的理解如何构建数据之间的关系，我决定这次从 Post 的数据模型开始。</p>
<p>一个简单的 Post 含有以下两个属性</p>
<ul>
<li>content 内容</li>
<li>createdAt 创建时间</li>
</ul>
<p>如果用表格来表示我们的数据，那么看起来就是这样的</p>
<div class="block-table"><table><thead>
<tr>
  <th>content</th>
  <th>createdAt</th>
</tr>
</thead>
<tbody>
<tr>
  <td>这是第一篇博文</td>
  <td>2023/7/9 14:42</td>
</tr>
</tbody>
</table></div><p>当发布了更多的数据的时候，表格内容就会变成这样</p>
<div class="block-table"><table><thead>
<tr>
  <th>content</th>
  <th>createdAt</th>
</tr>
</thead>
<tbody>
<tr>
  <td>这是第一篇博文</td>
  <td>2023/7/9 14:42</td>
</tr>
<tr>
  <td>在 LONCAFE 写代码感觉不错呢！</td>
  <td>2023/7/9 14:44</td>
</tr>
</tbody>
</table></div><p>我们最好给每条记录再加上一个不重复的 ID，这样我们就可以通过 ID 来快速准确的表示某一条帖子。</p>
<div class="block-table"><table><thead>
<tr>
  <th>id</th>
  <th>content</th>
  <th>createdAt</th>
</tr>
</thead>
<tbody>
<tr>
  <td>0</td>
  <td>这是第一篇博文</td>
  <td>2023/7/9 14:42</td>
</tr>
<tr>
  <td>1</td>
  <td>在 LONCAFE 写代码感觉不错呢！</td>
  <td>2023/7/9 14:44</td>
</tr>
</tbody>
</table></div><section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>事实上数据在存储在数据库的时候，也正是一个个类似这样的表格</p>
</section>
<p>接下来，我们尝试用 Swift 中的 <code>类</code> 来表示这个数据结构</p>
<div class="block-code" data-language="swift"><pre><code>class Post {
    let id: Int
    let content: String
    let createdAt: Date
}</code></pre></div>
<p>这样，我们就得到了 Post 这个数据模型最原始的状态。</p>
<h2>让 Vapor 认识 Post</h2>
<p>现在 Vapor 还不知道如何在数据库里操作 Post 类型的数据，因为我们还有很多信息没有提供给 Vapor，比如：</p>
<ol>
<li>Vapor 并不知道 Post 这个数据模型与「存储在数据库内的表结构」的对应关系</li>
<li>Vapor 并不知道我们要用的数据库是什么，也不知道如何连接到那个数据库</li>
</ol>
<p>那么接下来就一步步的解决这些问题</p>
<h3>将 Post 写成 Vapor Model</h3>
<p>Vapor 使用自己的 Fluent 来完成与数据库的通信，这个功能也被通称为 ORM （Object-relational mapping）</p>
<p>我们修改 <code>Package.swift</code> 来加入 Fluent 的依赖，因为我们不再是一个简单的 HelloWorld 了，因此也顺便把 name 改成 MicroBlog 吧。</p>
<div class="block-code" data-language="swift" data-highlight="11,18"><pre><code>// swift-tools-version:5.8
import PackageDescription

let package = Package(
    name: &quot;MicroBlog&quot;,
    platforms: [
       .macOS(.v12)
    ],
    dependencies: [
        .package(url: &quot;https://github.com/vapor/vapor.git&quot;, from: &quot;4.77.0&quot;),
        .package(url: &quot;https://github.com/vapor/fluent.git&quot;, from: &quot;4.4.0&quot;),
    ],
    targets: [
        .executableTarget(
            name: &quot;App&quot;,
            dependencies: [
                .product(name: &quot;Vapor&quot;, package: &quot;vapor&quot;),
                .product(name: &quot;Fluent&quot;, package: &quot;fluent&quot;),
            ]
        ),
    ]
)</code></pre></div>
<p>接下来，我们创建 <code>Sources/App/Models/Post.swift</code></p>
<div class="block-code" data-language="swift"><pre><code>import Vapor
import Fluent

final class Post: Model {
    // 数据库中的表名
    static let schema = &quot;posts&quot;

    // 唯一性标识符
    @ID(key: .id)
    var id: UUID?

    // 内容
    @Field(key: &quot;content&quot;)
    var content: String

    // 创建时间
    @Timestamp(key: &quot;created_at&quot;, on: .create)
    var createdAt: Date?

    init() { }

    init(id: UUID? = nil, content: String) {
        self.id = id
        self.content = content
    }
}</code></pre></div>
<p>在 Vapor 中，我们通过一些 Property Wrapper 来辅助完成 Model 和数据库中表的关系映射</p>
<ul>
<li>schema - 是指在数据库存储这个类型的数据的「表的名称」即 posts</li>
<li><code>@ID</code> 是数据在数据库表中的「唯一性标识符」，在 Vapor 中默认推荐使用 UUID 随机字符串来作为 ID，并会默认使用字符串 <code>id</code> 在数据库的表中作为字段名，你可以使用 <code>@ID(custom: &quot;&quot;)</code> 来修改这个字段名。</li>
<li><code>@Field</code> 是表示要存储在数据库中的数据属性，通过 <code>@Field(key: &quot;content&quot;)</code> 我们显式的声明了 <code>var content: String</code> 对应的是数据库表中 <code>content</code> 这个字段。</li>
<li><code>@Timestamp</code> 是一个特殊的 <code>@Field</code> 类型，专门用来表示存储的是时间，同时带有一个 trigger 功能，在这里我们使用 <code>on: .create</code> 来表示，当 Post 被创建时，自动记录时间。</li>
</ul>
<p>至此 Vapor 就可以认识 Post 这个数据类型啦。</p>
<h2>使用 Docker 启动 PostgreSQL 数据库</h2>
<p>我们使用 PostgreSQL 作为我们的数据库服务，直接在电脑上安装 PostgreSQL 是一件比较复杂的事情，通过 Docker 我们可以简化这个过程。</p>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>Docker 是一种轻量级的容器化技术，将 App 运行所需要的运行时封装在一起变成一个沙盒环境，通过这项技术，我们可以在 Linux 系统上无缝的启动其他 App 而不需要在 Host 上安装各种依赖。你可以通过安装 <a href="https://www.docker.com/products/docker-desktop/">Docker Desktop</a> 来使用这项技术</p>
</section>
<h3>编写 docker-compose.yml</h3>
<p><code>docker-compos.yml</code> 是容器编排文件，我们在这个文件中描述自己所需要的容器 App 以及其运行的环境变量，网络设置等。</p>
<p>首先在项目根目录里创建 <code>docker-compose.yml</code> ，编写以下内容</p>
<div class="block-code" data-language="yaml"><pre><code>version: '3.7'  # 定义 Docker Compose 文件的版本，此处使用的是版本 3.7

volumes:  # 定义卷部分
  db_data:  # docker 会使用这个键作为名字，自动创建 db_data 卷来存储数据

services:  # 定义服务部分

  db:  # db 服务配置
    image: 'postgres:15-alpine'  # 使用 PostgreSQL 15 Alpine 版本的镜像
    volumes:  # 定义挂载卷
      - 'db_data:/var/lib/postgresql/data/pgdata'  # 将 db_data 卷挂载到容器的 /var/lib/postgresql/data/pgdata 目录
    environment:  # 定义环境变量
      PGDATA: '/var/lib/postgresql/data/pgdata'  # 设置 PGDATA 环境变量为 /var/lib/postgresql/data/pgdata
      POSTGRES_USER: 'vapor_username'  # 设置 POSTGRES_USER 环境变量为 vapor_username
      POSTGRES_PASSWORD: 'vapor_password'  # 设置 POSTGRES_PASSWORD 环境变量为 vapor_password
      POSTGRES_DB: 'vapor_database'  # 设置 POSTGRES_DB 环境变量为 vapor_database
    ports:  # 定义端口映射，将主机的 5432 端口映射到容器的 5432 端口
      - '5432:5432'</code></pre></div>
<p>现在我们在终端中进入 <code>docker-compose.yml</code> 所在的位置，使用 <code>docker-compose up db</code>  命令就可以启动数据库服务了，数据库将在本机的 5432 端口监听。</p>
<section class="admonition caution">
<p class="admonition-title">Caution</p>
<p>如果你看到了这样的错误 Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? 请检查是否启动了 Docker Desktop</p>
</section>
<h2>让 Vapor 连接到数据库</h2>
<p>接下来，我们的目标是让 Vapor 获取到我们的服务器信息，连接到我们的服务器。</p>
<p>我们首先修改 <code>Package.swift</code> 增加 Fluent 对 PostgreSQL 的支持</p>
<div class="block-code" data-language="swift" data-highlight="12,20"><pre><code>// swift-tools-version:5.8
import PackageDescription

let package = Package(
    name: &quot;MicroBlog&quot;,
    platforms: [
       .macOS(.v12)
    ],
    dependencies: [
        .package(url: &quot;https://github.com/vapor/vapor.git&quot;, from: &quot;4.77.0&quot;),
        .package(url: &quot;https://github.com/vapor/fluent.git&quot;, from: &quot;4.4.0&quot;),
        .package(url: &quot;https://github.com/vapor/fluent-postgres-driver.git&quot;, from: &quot;2.7.2&quot;),
    ],
    targets: [
        .executableTarget(
            name: &quot;App&quot;,
            dependencies: [
                .product(name: &quot;Vapor&quot;, package: &quot;vapor&quot;),
                .product(name: &quot;Fluent&quot;, package: &quot;fluent&quot;),
                .product(name: &quot;FluentPostgresDriver&quot;, package: &quot;fluent-postgres-driver&quot;),
            ]
        ),
    ]
)</code></pre></div>
<p>随后，在 <code>Sources/App/main.swift</code> 中增加连接数据库的信息</p>
<div class="block-code" data-language="swift"><pre><code>// ...
app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
    hostname: &quot;localhost&quot;,
    port: 5432,
    username: &quot;vapor_username&quot;,
    password: &quot;vapor_password&quot;,
    database: &quot;vapor_database&quot;,
    tls: .prefer(try .init(configuration: .clientDefault)))
), as: .psql)

try app.run()</code></pre></div>
<p>至此，Vapor 便知道如何连接到数据库了，但目前数据库还是一张白纸，并没有建立我们所需要的数据表，因此，在最后我们还需要写一个叫做 Migration 的东西，来更新数据库上的表结构。</p>
<h2>使用 Migration 来创建数据库表</h2>
<p>Migration 是 Fluent 中用来对数据库表结构进行迁移的功能，接下来我们来了解如何使用这个工具。</p>
<p>在 <code>Sources/App/Migrations/1_CreatePost.swift</code> 写入以下内容</p>
<div class="block-code" data-language="swift"><pre><code>import Fluent 

// 定义 CreatePost 结构体，实现 AsyncMigration 协议
struct CreatePost: AsyncMigration { 
    // 准备方法，在数据库上进行准备操作
    func prepare(on database: Database) async throws { 
        // 创建 Post 表的数据库模式对象
        try await database.schema(Post.schema)  
            .id()  // 添加 id 列
            .field(&quot;content&quot;, .string, .required)  // 添加 content 列，类型为字符串，不能为空
            .field(&quot;created_at&quot;, .datetime)  // 添加 created_at 列，类型为日期时间
            .create()  // 创建 Post 表
    }

    // 回滚方法，在数据库上进行回滚操作
    func revert(on database: Database) async throws { 
        try await database.schema(Post.schema).delete()  // 删除 Post 表的数据库模式对象
    }
}</code></pre></div>
<p>在运行时，以上的代码会被转换成 SQL 语句，以 prepare 中的代码为例，将会被转换成如下 SQL 代码</p>
<div class="block-code" data-language="sql"><pre><code>CREATE TABLE IF NOT EXISTS public.posts
(
    id uuid NOT NULL,
    content text COLLATE pg_catalog.&quot;default&quot; NOT NULL,
    created_at timestamp with time zone,
    CONSTRAINT posts_pkey PRIMARY KEY (id)
)</code></pre></div>
<p>因此 Migration 也只是 Fluent 这个 ORM 对 SQL 的封装，通过这种封装，可以大幅减少我们编写 SQL 时出错的情况，并能通过常用场景的 SQL 语句优化来提升性能的表现。</p>
<p>如果你需要用到数据库的高级功能的话，Fluent 也支持直接使用 SQL 语句进行 Migration.</p>
<h3>注册并执行 Migration</h3>
<p>我们需要在 <code>Sources/App/main.swit</code> 中将 <code>CreatePost</code> 注册到 App 中以便一会我们执行 Migration 的时候，App 知道内容是什么</p>
<div class="block-code" data-language="swift"><pre><code>//...
app.migrations.add([CreatePost()])

try app.run()</code></pre></div>
<p>现在，在项目根目录执行 <code>swift run App migrate</code> ，输入 y 就可以完成数据库中表结构的更新。</p>
<div class="block-code" data-language="bash" data-highlight="4"><pre><code>The following migration(s) will be prepared:
+ App.CreatePost on default
Would you like to continue?
y/n&gt; y</code></pre></div>
<h2>编写 Unit Test 测试创建 Post</h2>
<p>接下来，我们编写 Unit Test 来测试 Post 的创建功能，在 <code>Tests/AppTests/PostTests.swift</code> 中写入以下内容</p>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>编写 Unit Test 可以针对功能进行自动化测试，确保我们服务器的功能不出现异常，我们将在后续章节中继续深入讨论 Unit Test 的使用</p>
</section>
<div class="block-code" data-language="swift"><pre><code>@testable import App
import XCTVapor

final class PostTests: XCTestCase {
    func testCreatePost() async throws {
        let app = Application(.testing)
        defer { app.shutdown() }

        // autoRevert 将自动执行所有 Migration 中 revert 的内容
        try await app.autoRevert()
        // autoMigrate 将自动执行所有 Migration 中 prepare 的内容
        // 这两步将重建我们的数据库，为我们提供一个干净的测试环境
        try await app.autoMigrate()

        let post = Post(content: &quot;Hello, world!&quot;)
        
        try await post.save(on: app.db)

        let postID = try? post.requireID()
        // 如果 postID 不为 nil 则成功创建，测试通过
        XCTAssertNotNil(postID)
    }
}</code></pre></div>
<p>随后，修改我们的 <code>Package.swift</code> 文件添加关于 Test 相关的描述</p>
<div class="block-code" data-language="swift" data-highlight="23-26"><pre><code>// swift-tools-version:5.8
import PackageDescription

let package = Package(
    name: &quot;MicroBlog&quot;,
    platforms: [
       .macOS(.v12)
    ],
    dependencies: [
        .package(url: &quot;https://github.com/vapor/vapor.git&quot;, from: &quot;4.77.0&quot;),
        .package(url: &quot;https://github.com/vapor/fluent.git&quot;, from: &quot;4.4.0&quot;),
        .package(url: &quot;https://github.com/vapor/fluent-postgres-driver.git&quot;, from: &quot;2.7.2&quot;),
    ],
    targets: [
        .executableTarget(
            name: &quot;App&quot;,
            dependencies: [
                .product(name: &quot;Vapor&quot;, package: &quot;vapor&quot;),
                .product(name: &quot;Fluent&quot;, package: &quot;fluent&quot;),
                .product(name: &quot;FluentPostgresDriver&quot;, package: &quot;fluent-postgres-driver&quot;),
            ]
        ),
        .testTarget(name: &quot;AppTests&quot;, dependencies: [
            .target(name: &quot;App&quot;),
            .product(name: &quot;XCTVapor&quot;, package: &quot;vapor&quot;),
        ])
    ]
)</code></pre></div>
<p>现在，我们可以通过在 <code>Package.swift</code> 所在的路径执行 <code>swift test</code> 来运行单元测试</p>
<p>现在我们会获得一个测试没有通过的提示</p>
<div class="block-code" data-language="bash"><pre><code>FluentKit/Databases.swift:162: Fatal error: No default database configured.
error: Exited with signal code 5</code></pre></div>
<p>这是因为写在 main.swift 中关于数据库连接的内容并不会在测试中执行，我们需要重构这部分代码，使得两边都可以使用</p>
<h2>使用 configure.swift 重构 App 初始化</h2>
<p>在 <code>Sources/App/configure.swift</code> 中写入以下代码</p>
<div class="block-code" data-language="swift"><pre><code>import Fluent
import FluentPostgresDriver
import Vapor

public func configure(_ app: Application) async throws {
    app.databases.use(.postgres(configuration: SQLPostgresConfiguration(
        hostname: &quot;localhost&quot;,
        port: 5432,
        username: &quot;vapor_username&quot;,
        password: &quot;vapor_password&quot;,
        database: &quot;vapor_database&quot;,
        tls: .prefer(try .init(configuration: .clientDefault)))
    ), as: .psql)

    app.migrations.add([CreatePost()])
}</code></pre></div>
<p>修改 <code>main.swift</code> 使用 configure</p>
<div class="block-code" data-language="swift" data-highlight="15"><pre><code>import Vapor
import Fluent
import FluentPostgresDriver

let app = Application()

app.http.server.configuration.port = 8080

defer { app.shutdown() }

app.get { req async in
    &quot;It works!&quot;
}

try await configure(app)

try app.run()</code></pre></div>
<p>修改 <code>PostTests.swift</code> 使用 configure</p>
<div class="block-code" data-language="swift" data-highlight="9"><pre><code>@testable import App
import XCTVapor

final class PostTests: XCTestCase {
    func testCreatePost() async throws {
        let app = Application(.testing)
        defer { app.shutdown() }

        try await configure(app)
        
        // autoRevert 将自动执行所有 Migration 中 revert 的内容
        try await app.autoRevert()
        // autoMigrate 将自动执行所有 Migration 中 prepare 的内容
        // 这两步将重建我们的数据库，为我们提供一个干净的测试环境
        try await app.autoMigrate()

        let post = Post(content: &quot;Hello, world!&quot;)
        
        try await post.save(on: app.db)

        let postID = try? post.requireID()
        // 如果 postID 不为 nil 则成功创建，测试通过
        XCTAssertNotNil(postID)
    }
}</code></pre></div>
<p>现在运行 <code>swift test</code> 我们将会看到测试通过的信息</p>
<p><code>Test Case '-[AppTests.PostTests testCreatePost]' passed (0.263 seconds).</code></p>
<p>恭喜你，Vapor 和数据库连通起来了！</p>
<h2>本章代码</h2>
<p>你可以在 <a href="https://github.com/kevinzhow/swift-on-server-tour/tree/main/2">https://github.com/kevinzhow/swift-on-server-tour/tree/main/2</a> 找到本章的相关代码。</p>
<h2>拓展：使用 pgAdmin 查看数据库的内容</h2>
<p>如果你希望查看数据库里创建了什么内容，使用 pgAdmin 可以连接到 PostgreSQL</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8311094953_056861.png" alt="Untitled.png" /></figure></div><h2>下章预告</h2>
<p>在下一个章节，我们将编写 API 来实现 Post 的 CURD（Create Update Read Delete）并进一步学习测试的使用</p>
]]></content:encoded><dc:creator><![CDATA[Kevin Zhow]]></dc:creator></item><item><title><![CDATA[Swift on Server Tour 1  你的第一个 Server App 以及它背后的故事]]></title><guid>https://blog.kevinzhow.com/posts/swift-on-server-tour-1/zh</guid><link>https://blog.kevinzhow.com/posts/swift-on-server-tour-1/zh</link><pubDate>Sun, 25 Jun 2023 15:13:41 +0000</pubDate><content:encoded><![CDATA[<p>在这一章中，我们将了解如何配置 Swift on Server 的开发环境，创建第一个 Server 上的 Hello, World. 在此之上，我们还要简单聊一聊访问 Server App 背后的一些技术细节.</p>
<p>.. toc:: Table of Contents
:max-level: 3</p>
<h2>配置 Swift on Server 的开发环境</h2>
<p>得益于 Swift 开源的特性，Swift 也可以在 Linux 上进行开发，同样，在 <a href="https://www.swift.org/sswg/">Swift Server Workgroup</a> 的努力下，VSCode 也可以成为一个称职的 Swift 编辑器，因此，为了让不同类型的用户都可以很好的阅读，本系列文章将使用 VSCode 作为编辑器。</p>
<h3>安装 Swift</h3>
<p>SSWG 发布了 <a href="https://github.com/swift-server/swiftly"><strong>swiftly</strong></a> 可以帮助大家在 Linux 上轻松的安装 Swift</p>
<div class="block-code"><pre><code>curl -L https://swift-server.github.io/swiftly/swiftly-install.sh | bash
swiftly install latest</code></pre></div>
<p>除此之外， Apple 的 <a href="https://www.swift.org/download/"><strong>Download Swift</strong></a> 也提供了不同平台的安装指南。</p>
<h3>配置 VSCode</h3>
<p>安装 <a href="https://marketplace.visualstudio.com/items?itemName=sswg.swift-lang">Swift Extension for Visual Studio Code</a> 插件即可</p>
<h2>创建 Hello World</h2>
<p>我创建了一个叫做 1 的文件夹，来存放本章的代码，你也可以像我一样，使用下面的命令创建</p>
<div class="block-code" data-language="bash"><pre><code>mkdir 1</code></pre></div>
<p>作为一个 Swift 工程，我们第一件事就是创建一个 <code>Package.swift</code> 文件，来描述工程</p>
<div class="block-code" data-language="swift"><pre><code>// swift-tools-version:5.8
import PackageDescription

let package = Package(
    name: &quot;HelloWorld&quot;,
    platforms: [
       .macOS(.v12)
    ],
    dependencies: [
        .package(url: &quot;https://github.com/vapor/vapor.git&quot;, from: &quot;4.77.0&quot;),
    ],
    targets: [
        .executableTarget(
            name: &quot;App&quot;,
            dependencies: [
                .product(name: &quot;Vapor&quot;, package: &quot;vapor&quot;)
            ]
        ),
    ]
)</code></pre></div>
<p>随后，创建 <code>1/Sources/App/main.swift</code> 文件</p>
<div class="block-code" data-language="swift"><pre><code>import Vapor

let app = Application()

app.http.server.configuration.port = 8080

defer { app.shutdown() }

app.get { req async in
    &quot;It works!&quot;
}

try app.run()</code></pre></div>
<p>在文件夹 <code>1</code> 的根目录中使用 <code>swift run</code> 运行代码，或者在 VSCode 的 Run &amp; Debug 中点击执行</p>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>通常来说，当你保存 <code>Package.swift</code> 文件时，Swift 的 VSCode 插件会自动帮你在项目根目录生成 <code>.vscode/launch.json</code> 文件，从而使得  Run &amp; Debug 功能可以正常运行。</p>
</section>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8312294359_158958.png" alt="Untitled.png" /></figure></div><p>经过编译后，你会看到 Server 运行起来的提示</p>
<div class="block-code" data-language="bash"><pre><code>[Vapor] Server starting on http://127.0.0.1:8080</code></pre></div>
<p>在浏览器中访问 <a href="http://127.0.0.1:8080">http://127.0.0.1:8080</a>, 此时你就会看到老朋友 It works 了</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8312294348_8681.png" alt="Untitled 1.png" /></figure></div><p>恭喜你，第一个 Server App 已经写好了！</p>
<h2>浏览器是如何访问到 Server App 的？</h2>
<p>要理解这个问题，我们可以从浏览器访问的地址 <a href="http://127.0.0.1:8080/hello">http://127.0.0.1:8080</a> 入手，这个地址可以拆解成 3 部分</p>
<ul>
<li>http - 希望使用的协议名</li>
<li>127.0.0.1 - 希望访问的 IP 地址</li>
<li>8080 - 希望访问的 IP 地址所对应的机器的端口</li>
</ul>
<p>当你按下回车的时候，浏览器会根据 HTTP 协议，制作出下如下内容</p>
<div class="block-code"><pre><code>GET / HTTP/1.1
Host: 127.0.0.1:8080</code></pre></div>
<p>随后这个内容会被送往 IP 地址 127.0.0.1  所代表的机器，找到 8080 这个端口，递给在这里等着的 Server App.</p>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>127.0.0.1 是一个特殊的保留地址，用以表示访问本机，使用这个地址时，它实际上是指向发出请求的机器</p>
</section>
<p>现在我们可以回顾一下 Server App 的代码做了什么</p>
<div class="block-code" data-language="swift"><pre><code>// 在本机的 8080 端口监听
app.http.server.configuration.port = 8080

// 如果有请求抵达，使用 GET 访问的路径 / 那么返回文字 It works!
app.get { req async in
    &quot;It works!&quot;
}</code></pre></div>
<p>而事实上，我们的 Server App 传回的是像这样的内容</p>
<div class="block-code"><pre><code>HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 9

It works!</code></pre></div>
<p>浏览器拿到这份数据后，首先通过 200 这个 HTTP 状态码得知请求成功，而 content-type 部分则告诉浏览器，传回的数据是使用 utf-8 字符编码的  text/plain（纯文本）内容长度是 9.</p>
<p>最终根据这些信息，浏览器决定在页面上为我们显示 It works! 这段文字</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8312294348_8681.png" alt="Untitled 1.png" /></figure></div><p>这有来有回的数据交换，便是一次最基础的 Server App 应用，在其背后便是 TCP/IP 网络协议在支撑，TCP/IP 协议非常精巧复杂，上面的 HTTP 只是  TCP/IP  四层结构中最顶端的应用层。</p>
<p>构建简单的 Server App 并不需要很多 TCP/IP 的知识，但随着业务增长，面临的问题逐渐复杂，了解 TCP/IP 能帮助我们解决很多问题，不过，这不在本系列文章的讨论范围了，请读者伴随着日后的业务需求，自行探索吧。</p>
<p>本章代码可以在 Github 上找到 <a href="https://github.com/kevinzhow/swift-on-server-tour">https://github.com/kevinzhow/swift-on-server-tour</a></p>
<h2>下章预告</h2>
<p>在下一章中，我们开始使用 Vapor 连接数据库，完成对数据库的增删改查，并探索一下这些操作的背后是如何完成的。</p>
]]></content:encoded><dc:creator><![CDATA[Kevin Zhow]]></dc:creator></item><item><title><![CDATA[Swift on Server Tour 0: 为什么这可能是你的好选择]]></title><guid>https://blog.kevinzhow.com/posts/why-swift-on-server/zh</guid><link>https://blog.kevinzhow.com/posts/why-swift-on-server/zh</link><pubDate>Sun, 11 Jun 2023 13:25:28 +0000</pubDate><content:encoded><![CDATA[<h2>背景</h2>
<p>在使用 Swift on Server 之前，我最熟悉的 Web Framework 是 Ruby on Rails，除此之外，我也使用过 Python，NodeJS，Elixir 和 Go 来编写后端，但给捧读编写后端服务的时候，我权衡再三，最终选择了 Swift on Server.</p>
<p>本章将分享当时我为何做了这个决定，以及这个决定在随后的三年为何被验证是正确的选择，如果你和我的情况类似，那么相信会有所收获。</p>
<section class="admonition hint">
<p class="admonition-title">Hint</p>
<p>捧读——一款基于机器学习技术的日语语法分析 App</p>
</section>
<p>.. toc:: Table of Contents
:max-level: 3</p>
<h2>从单机开始</h2>
<p>捧读在 2019 年的研发之初，是作为一款单机产品设计的，初衷是希望即使我不在了，这款产品也依旧可以很好的运作，让用户实现真的一次购买，永久使用。</p>
<p>设计为单机产品主要起因于我两个想法</p>
<ol>
<li>教育资源需要足够容易被获取，这样才公平</li>
<li>我需要把产品的运行成本降到足够的低，由此这款教育产品就可以被卖的足够便宜，让更多人可以用上</li>
</ol>
<p>但随着时间的推移，捧读的机器学习模型更新给我带来了麻烦。</p>
<p>在单机的情况下，模型更新完全依赖于用户是否更新了 App，因此，我无法保证每个用户都能及时使用上最新的模型，获得最好的体验，除此之外，用户会逐渐积累很多学习数据，如果不提供云同步功能，用户难以跨设备使用，更是容易产生丢失数据的问题。</p>
<p>因此，为了解决「用户体验」和「单机」之间的冲突，在 2021 年的时候，我决定给捧读设计一个后端。</p>
<h2>需求驱动的选择</h2>
<p>在调研技术方案前，我们先来看下捧读服务端有哪些需求</p>
<ul>
<li>捧读的算法逻辑已经用 Swift 写完了，能不重写可以极大的加快我的研发效率</li>
<li>捧读使用了很多 C 库来支撑算法的实现，因此和 C 库的集成方式要足够简单</li>
<li>服务端的性能一定要足够好，让用户几乎不需要等待</li>
<li>服务端运行时的资源占用要足够的小，这样我在配置多节点实现高可用的时候，就不需要花大价钱买高配的服务器</li>
</ul>
<h3>Ruby on Rails 的优缺点</h3>
<p>以 Ruby on Rails 来说，这是一个我从 2013 年就开始使用的 Web 框架，也一直是它的拥趸，那么，先来看一下它的优劣</p>
<p>优点：</p>
<ul>
<li>可以非常高效的完成业务需求</li>
<li>生态完善，常规业务功能都有现成的开源实现</li>
<li>很容易找到使用这个技术的小伙伴</li>
<li>很容易找到学习资料</li>
</ul>
<p>缺点：</p>
<ul>
<li>性能一般（可以通过把耗费算力的用其他编译型语言写成模块优化）</li>
<li>内存占用比较高</li>
<li>和 C 库的互调比较麻烦</li>
<li>并发能力弱（可以通过多线程、多实例、多节点的方式优化）</li>
</ul>
<h3>Swift on Server 的优缺点</h3>
<p>反观 Swift on Server，它的优缺点有哪些呢？</p>
<p>优点：</p>
<ul>
<li>可以复用 iOS App 和 Swift 生态的代码</li>
<li>编译型语言非常稳定</li>
<li>和 C 库的集成非常容易自然，今年 Swift 5.9 又完善了和 C++ 的集成</li>
<li>性能极好的同时，内存的占用非常低，这意味省大量的钱</li>
<li>整个生态都使用了 Non-Blocking I/O 的设计理念，高并发的支持非常好</li>
</ul>
<p>缺点：</p>
<ul>
<li>作为服务端是一个非主流，大部分服务都没有对应的 SDK</li>
<li>很难找到学习 Swift on Server 的小伙伴</li>
<li>学习资料很少</li>
</ul>
<p>除此之外，Swift 对我来说还有三个加分项</p>
<ul>
<li>Swift 这门语言写起来愉悦性很强</li>
<li>Swift 的社区很活跃</li>
<li>Apple 对其投入足够大，每年都有很多惊喜</li>
</ul>
<p>因此，Swift on Server 对我来说，只要扬长避短，克服这些缺点，就是一个完美的选择。</p>
<h2>克服 Swift on Server 的先天劣势</h2>
<p>就像脚本语言借用其他语言来补全自己的短板一样，Swift 也同样可以借助其他语言的生态来补全自己的短板，在这个 Cloud Native 的时代，借助微服务和 Serverless 的帮助，我们可以很轻易的解决这些问题。</p>
<h3>解决缺少第三方服务 SDK 的问题</h3>
<p>以 Azure 为例，Azure TTS 并没有提供 Swift 的服务端 SDK，但可以很容易的找到其他语言的，比如 Python 和 NodeJS.</p>
<p>我的解决方案是用 JS 把和 Azure TTS 打交道的功能，部署到 AWS 的 Lambda 上，当 Server App 需要和 TTS 打交道的时候，就用 HTTP 请求这个 Lambda Function.</p>
<p>这个方案的优点如下</p>
<ul>
<li>Lambda Function 可以写的足够简单，比如在 TTS 这件事上，我只有 60 行代码</li>
<li>Lambda Function 的运行环境和我们的 Server App 是完全分离的，因此不占用额外资源，运行时相互之间也不会产生影响</li>
<li>AWS Lambda 的免费额度足够多，你几乎不需要付出金钱</li>
</ul>
<h3>解决技术包容性的问题</h3>
<p>我们很容易面临一个这样的问题，我喜欢 Swift，他可能喜欢 Rust，其他人则可能喜欢 Go 或其他语言。</p>
<p>当我选择了 Swift on Server 的时候，是否和其他人的世界就无缘了呢？</p>
<p>当然不是！通过一个精巧且合理设计的微服务架构，你可以很轻松的在不同的业务模块里，使用完全不同的语言和技术，相较于使用一门语言实现一个大单体应用，这样带来的直接好处有两点</p>
<ul>
<li>每个语言都有自己最擅长的领域，借助微服务可以轻松整合不同技术一起工作</li>
<li>每个人都可以选择自己最喜欢的技术来完成他的目标，这样会很开心</li>
</ul>
<h2>使用 Swift on Server 的三年</h2>
<p>从 2021 年服务器上线至今，已经高效，稳定的运行了 3 年，在这期间，业务从仅有的用户系统和语法分析，拓展到了支付系统，账单系统，以及后来的生词本和学习系统，在未来，我也会继续使用 Swift on Server 完成其他的产品需求。</p>
<p>之所以开启这个系列，是希望将我使用 Swift on Server 的经验系统的分享出来，让更多的人可以从中受益。</p>
<p>诚然，Swift on Server 不会是适合每个人的方案，因此我也列了一个画像，如果你符合这些条件，那么请持续关注这个系列的文章。</p>
<h3>谁适合使用 Swift on Server</h3>
<p>符合下列任何一条皆可</p>
<ul>
<li>已经在使用 Swift 语言</li>
<li>有自己的 iOS App 希望开发后端</li>
<li>对 Swift 语言充满热情</li>
<li>关注性能和高并发</li>
</ul>
<h2>系列预告</h2>
<p>本系列文章的会以一个 Micro Blog Server 为示例，和你一起畅游 Swift on Server 的世界，这个系列主要面向对服务器开发的初学者，因此除了功能的实现外，会写很多概念相关的内容。</p>
<p>总体来说，话题会涉及到以下方面</p>
<ol>
<li>概念：什么是 Server App</li>
<li>概念：HTTP 请求的那些事</li>
<li>Framework：选择你的框架 - Vapor</li>
<li>Database：设计你的数据模型</li>
<li>API：设计你的 API</li>
<li>Auth：用户权限验证</li>
<li>Test：测试你的 API</li>
<li>CI：部署你的服务器</li>
<li>微服务：和其他语言一起工作</li>
<li>微服务：多人同时在线的聊天室</li>
</ol>
<p>我会利用闲暇的时间，更新这个系列，希望能够完成每 1 - 2 周一更，欢迎持续关注！</p>
]]></content:encoded></item><item><title><![CDATA[纪念左耳朵耗子]]></title><guid>https://blog.kevinzhow.com/posts/in-memory-of-haoel/zh</guid><link>https://blog.kevinzhow.com/posts/in-memory-of-haoel/zh</link><pubDate>Mon, 15 May 2023 21:00:01 +0000</pubDate><content:encoded><![CDATA[<p>刚刚从半睡半醒中醒来，梦境的最后一刻是陈皓成为了 AI，而我刚想和“他”说说话。</p>
<p>我相信那一天终究会到来，但此刻看着 Discord 里甚至还没显示为离线的他，我也知道再也得不到回应。</p>
<h2>在北京相识</h2>
<p>我一直称他为皓哥，因此现在也还是皓哥吧，但这个名称其实让我纠结过，毕竟他那么显老，甚至让我一度也想过叫皓叔。但，为了不让陈皓同志觉得和我有年龄代沟，最终还是用哥这个辈分了，感觉他挺喜欢的 ：）</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8315827389_142016.png" alt="image.png" /></figure></div><p>2015 年底的时候我因为公司被收购来到了北京，当时负责筹划名叫「字里行间」的写作产品，2016 年 3 月的时候，正值组建团队之际，公司有阿里背景的 HR 大姐说，有个技术大神从阿里出来了，约了吃个饭，问我能不能一起去。</p>
<p>我拿过来一看——陈皓，接着大姐说 “在 Amazon 干过，还有个技术博客非常有名，叫 Coolshell” 。</p>
<p>我当即就去网上 &quot;人肉&quot; 了一番，找到了他的博客，微博，Twitter，从那时候开始，我从皓哥的个人介绍里就总能看到这句 “芝兰生于深谷，不以无人而不芳” 。其实这个和我潜意识的技术人是有点不搭的，也太“老土”了吧，哈哈，但说真的，我是很喜欢这句话的。</p>
<p>皓哥是真的把座右铭当成信条来执行，使得这句话也就不再像是一句用来装饰自己的空话。</p>
<p>这张 Twitter 截图应该是见面前一天晚截下来的，为第二天的见面做准备。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8315826933_307715.png" alt="IMG_0553.PNG" /></figure></div><p>第二天中午，我在北京朝阳大悦城的望湘园见到了他，皓哥这个人很真实，他如果一开始觉得对公司没兴趣，就会略显疲态，言语间带着攻击性，上来先劈头盖脸的批判了一番我们金主的业务内容，直到上菜了，才让我得以在他嚼饭的空隙做一些自我介绍。</p>
<p>“这个是我之前的产品，Yep”</p>
<p>“Yep 是你做的？”</p>
<p>“是啊”</p>
<p>“这个我知道啊，太酷了，第一次见在国内这么玩开源的”</p>
<p>Yep 是我之前做的一款面向程序员和设计师的社交产品，当时走了客户端全开源的模式，因为这款产品，本来将要不欢而散的饭局，产生了 180 度的反转，皓哥说可以聊聊，然后特别的说 “我主要是对 Kevin 感兴趣”。</p>
<p>我没有想到他会这样想。</p>
<p>在尝试拉他入伙的时候，他反复追问当时的老板是 “相信技术，还是相信管理” ，而老板的太极回答没能让他满意。</p>
<p>再后来，他便成为了当时公司的技术顾问，而我们的友谊也是从那时开始的。</p>
<h2>亦师亦友</h2>
<p>我当时住在朝阳区的青年汇佳园，而皓哥在朝阳的远洋天地，地缘上的因素使得我们就像是邻居一样，经常能碰个面，吃个饭。</p>
<p>聊天时，皓哥的输出是非常持久的，通常开启一个话题后，我基本只剩下负责吃的份，而他都是一个小时起步，跟我讲很多他的经历，价值观，他喜欢对比 Amazon 和 阿里 的企业文化，一个是工程师文化的代表，一个是销售驱动的代表，一个相信技术，一个相信管理，以此为引子，就是那晚的下饭菜。</p>
<p>有一次我跟他吐槽说，公司昨天有人没关窗户，结果现在开始安排值日关窗了。</p>
<p>他说：“你看，这个就是相信管理，如果要是相信技术的公司，就会安装一个自动闭门器，相信什么是刻在公司基因里的。”</p>
<p>有一次我问他，Amazon 那么好，你为啥还愿意去阿里。</p>
<p>他说：“本来是没什么兴趣的，但他们的 HR 的诚意打动了我，我想去看看到底是什么样的企业文化能有这样的 HR.”</p>
<p>有一次，我们聊起病毒传播，我本来只是随口提了下说 “要不搞一个”，却没想到皓哥立刻说“开搞！”</p>
<p>于是大周末的，两个人跑到公司，一起做了个<a href="https://github.com/kevinzhow/chainstory">故事接龙</a>的小产品。</p>
<p>皓哥就一直是这样较真的一个人，如果感兴趣，就一定会去试试，他从来不会只停留在想的阶段，我也从没有见过他纠结的样子，总是会坚定的随着自己的价值观，行动，验证。</p>
<p>北京的那两年，我们一起面试，一起撸串，一起遛弯，去猫咖见朋友，虽说是忘年之交，却真的一见如故。</p>
<p>他的那些观点，品格，都以言行合一的方式，逐渐影响着我价值观的塑造——做自己相信的事情，再把相信的事情做对。</p>
<p>2017 年底的时候，我决定离开当时的公司，谈完离职之后，我给皓哥发了信息，一起在管氏翅吧撸串。</p>
<p>我说，我现在什么都没了，今天你要请客。</p>
<p>他说，没问题！</p>
<p>那天，我一直在跟他倾诉，说着那些在公司里无法实现的抱负，说着自己后悔没有坚持下来的那些事情，以及最后我哭着说 “我只是想做自己想做的产品”</p>
<p>现在回想那些瞬间，我才意识到自己失去一个挚友所代表的是什么。</p>
<h2>青岛</h2>
<p>2018 年的时候我决定离开北京，在青岛以独立开发者的形式，去弥补那些自己过去的遗憾。</p>
<p>临走前，皓哥非要约我一起爬山，于是我带着老婆，他带着女儿，四个人一起去爬了怀柔的红螺寺，那天我被折磨的很惨，一路累的哭爹喊娘，第二天腿酸的厕所都上不了。</p>
<p>他说：“我就是故意搞你的，我要做点什么事情，让你记住我”。</p>
<p>说这句话的时候，他带着一种老男人的风情。</p>
<p>离别前，他说：“我以前有朋友从北京回到昆明后，失去了理想变得世俗了，但我不担心你，因为你知道自己想要什么。”</p>
<p>我觉得他还有点担心的，但所幸，我并没有改变。</p>
<p>因为皓哥在青岛也有亲人的原因，在随后 2019 - 2021 的那段时期，皓哥每年暑期都会带着家人来青岛和我聚一聚，带着一点点视察的性质。</p>
<p>他说，他很喜欢海的辽阔，在海边会觉得一切事情都会被包容。</p>
<p>而我却再也没去过一次北京，也没能赴约昆明。</p>
<p>记忆中，那天青岛下着小雨，他带着女儿坐在我家客厅的地垫上，电视播放着一个在日本旅行走路的 Youtube 频道，4K.</p>
<p>画面行走在京都的古道，亦是雨中，淅淅沥沥。</p>
<p>他说，哎呀，真的太放松了，我们一定要一起去一次日本。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8315821588_82805.jpg" alt="IMG_1404.jpg" /></figure></div><div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8315821661_169962.png" alt="IMG_0027.PNG" /></figure></div><div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8315821439_02382.jpg" alt="IMG_0780 (1).jpg" /></figure></div><h2>一起做点什么事情</h2>
<p>皓哥一直说 “总觉得我们会在一起搞点事情”，但很多年来都没真的走在一起做点什么。</p>
<p>所幸，这件事没有成为遗憾。</p>
<p>2022 年的时候我开始给自己的产品加入服务器，公有云的服务太贵了，而皓哥的 MegaEase 正好可以解决这个问题，我成为了潜在用户。</p>
<p>那天他说想去海外推广这款产品，但是产品设计他不满意，他说：  “这是我自己设计的，也是我能力范围内能做的到的极限了”。</p>
<p>我说： “要不我给你搞搞吧，反正我也想用这个系统，这样我用的可以更爽一些”。</p>
<p>于是我得以有机会，在那之后的 3 个月，利用每天一些闲暇的时间，给 MegaEase 做一些品牌和产品的设计。</p>
<p>记得有一次，我理顺了产品上用户创建 App 的路径问题，皓哥开心的打电话跟我说 “我觉得你很厉害，这个问题我郁闷了很久，被你这么优雅的就给整合在一起了，你这样的人，千万不要去大公司，不然才能就被浪费了”。</p>
<p>被他认同让我很是开心，但话锋一转，皓哥让我多和团队的人说说话，以后长期要在一起，融入融入。</p>
<p>看那架势，是想生米煮成熟饭。</p>
<p>这让我着实背后一凉，本来只是体验生活的，怎么就要负责了呢……</p>
<p>当然，这是一句玩笑话。</p>
<p>其实那段时期，我正因为乌烟瘴气的外界信息而感到心烦意乱，看到那么多拥有影响力的人并没有担负起应有的责任，肆意消费着普通人的注意力，让我颇为忿懑。</p>
<p>我没做好心理准备去接纳更多的人。</p>
<p>因此，我开始刻意降温，有一搭没一搭的，慢慢的也就结束了合作，我重新回到了自己原有的轨迹上。</p>
<p>现在回想起来，更多的是悔意。</p>
<p>很多地方我也许可以做的更好，但再也没有如果。</p>
<h2>没能捕捉到的信号</h2>
<p>2023 年 1 月 12 日，我给皓哥分享了一个美食探店视频，隔了两个小时，他回复我 “在医院呆了一天，还没来得及看。”</p>
<p>我心里咯噔一下，马上打了个电话过去。</p>
<p>在那一天，我得知他确诊了一些慢性病，他说 “在医院检查，医生要根据结果看怎么办”</p>
<p>因为很常见，而且考虑到北京的医疗水平，我便没有多想。</p>
<p>过了几天，我接到了皓哥的电话，这次又和往常一样，声音温暖而坚定，他开启了电话粥模式。</p>
<p>他说：“没什么大事，但后半辈子要吃药了” 然后跟我解释起了病因，原理，之后又颇为得意的讲起了自己如何改变作息，调整了饮食，让我学着点。</p>
<p>我连连说好。</p>
<p>也并没有多想。</p>
<p>2023 年的 4 月 27 号，是他最后一次给我打电话</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/8315819909_542534.png" alt="image.png" /></figure></div><p>那天他跟我聊了会 MegaEase，以及接下来想做的 AI 产品，说 “这可能是我最后一次能赶上的技术风口，再往后可能我也不懂了” 。</p>
<p>希望我能以合伙人的形式，跟他一起搞这款产品。</p>
<p>其实在此之前，皓哥都将我视作未来的合伙人。但我因为自己的产品还没有完成计划，不想再一次留下遗憾，也深知在兼顾的情况下，我无法符合一个合伙人所代表的期望，因此我总是有些抗拒的。</p>
<p>这一天我依旧有些抗拒，但和皓哥一起做点事总是我心底也期望的，在听他讲了 1 个小时后，我把心里关于不想留下产品遗憾的事情又提了一次，说 “我觉得这方向没有问题的，等到时候可以搞搞看，反正我也会用到”。</p>
<p>留下了一张空头支票。</p>
<h2>最后一次对话</h2>
<p>2023 年的 5 月 2 号的凌晨 3 点左右，我在睡觉前发现 Discord 好像上线了语音消息的功能，于是就给皓哥发了句语音 “测试一下 Discord 的语音功能”，第二天，他 7 点多回了句语音 “你晚上不睡觉啊”。</p>
<p>然后我也没有回复，这种没头没尾的对话，我们已经习以为常。</p>
<p>也从来未曾想到，如此稀松平常的对话，会是最后一次。</p>
<p>5 月 15 号凌晨，朋友给我个信息说，皓哥走了。</p>
<p>我一时错愕，问 “走了是啥意思，润了吗”</p>
<p>我没有能走入那个时间线。</p>
<p>直到现在，我都觉得非常不真实。</p>
<p>因为我可以轻易的想起他说话的音调，想像出他对一件事会有怎样的态度，会如何回应我的信息。</p>
<p>那些一起和朋友们玩 FIFA 的瞬间，和家人们在海边散步影像，以及我俩在烧烤店撸串时的对话，都能那么轻易的被回忆起来。</p>
<p>怎么，这些就都不再有后续了呢？</p>
<p>这个有时候爱称自己的是老家伙，知道我也听 AC/DC 后就兴奋的给我分享歌单的人</p>
<p>怎么能就没了呢？</p>
<h2>没有遗忘，就没有离去</h2>
<p>我一直以为，自己是一个可以看淡生死的人，甚至从初中开始，我就一直会以这种方式思考自己的人生：</p>
<p>“假如今天我已经 80 岁了，即将死去，我回想自己的一生，是否会觉得自己是个傻逼”</p>
<p>毫无疑问，皓哥的一生是值得他骄傲的。</p>
<p>每当想起他的时候，我总是能得到勇气把自己相信的事情坚持下去，让做和不做的事情都一样骄傲。</p>
<p>我希望自己能传承一点他的骄傲，继续创造，分享，更勇敢的面对未来，他已经留下了答卷，而我仍需要继续作答我的人生。</p>
<p>我想，只要我没有忘记他留给我的那些精神，他也就未曾离我而去。</p>
<p>愿他在另一个世界，依旧玩的开心。</p>
<p>以此纪念我的挚友，陈皓。</p>
]]></content:encoded></item><item><title><![CDATA[How to learn Japanese by reading Novels and News with the help of Oyomi.]]></title><guid>https://blog.kevinzhow.com/posts/how-to-learn-japanese-by-reading-novels-and-news-with-the-help-of-oyomi/en</guid><link>https://blog.kevinzhow.com/posts/how-to-learn-japanese-by-reading-novels-and-news-with-the-help-of-oyomi/en</link><pubDate>Mon, 20 Feb 2023 06:35:58 +0000</pubDate><content:encoded><![CDATA[<p>If you are interested in learning Japanese but feel overwhelmed by the complexity and variety of the language, you might want to check out Oyomi, a smart app that can help you learn Japanese from any content you like.</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_b06ecb3c050a3524bd7371343c606c75.png" alt="" /></figure></div><p>Oyomi has several helpful features that can make your learning process easier and more enjoyable. Here are some of them:</p>
<ol>
<li>Oyomi can extract content from web pages or EPUB books.</li>
<li>Oyomi can do semantic analysis of the content and tell you what grammars are used as well as the meaning of the content.</li>
<li>You can collect words and sentences so Oyomi will generate a review plan for you automatically.</li>
</ol>
<p>You don’t have to limit yourself to boring textbooks or limited resources when learning Japanese.</p>
<p>With Oyomi, you can choose any web page or EPUB book that interests you and import it into the app.</p>
<h2>Learn from Web Pages</h2>
<p>Oyomi is a powerful tool that can help you learn from any web page. It can analyze the text and structure of a web page and turn it into semantic blocks that you can easily understand and manipulate. Here are the steps to use Oyomi to learn from web pages:</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_91ef64740d93dd1eb23675d2840bb0f8.png" alt="" /></figure></div><ol>
<li>Copy the web url of the page you want to learn from and paste it into the analyze field in Oyomi.</li>
<li>Click the “Analyze” button on the top right corner of the screen.</li>
<li>Wait for the web page to load in Oyomi. You will see a preview of the page.</li>
<li>Click the “Analyze” button again to process the text into semantic blocks.</li>
</ol>
<h2>Lookup &amp; Translation &amp; Read Aloud</h2>
<p>From here now you can easily lookup the meaning of the word or translate and read aloud the sentences.</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_7f388dcc08279292356ba8f02ebd7f2a.png" alt="" /></figure></div><p>You can tap on any word or phrase to see its meaning, pronunciation, part of speech, and example sentences.</p>
<h3>Verb conjugation</h3>
<p>You might have encountered some difficulties with verb conjugation. Unlike English, where verbs only change their forms based on tense and number, Japanese verbs have many different forms that express various nuances of mood, politeness, honorifics and more.</p>
<p>For example, the verb 食べる (taberu), which means “to eat”, can be conjugated into dozens of forms such as 食べます (tabemasu), 食べない (tabenai), 食べさせる (tabesaseru), 食べられる (taberareru) and so on. Each form has a different meaning and usage that you need to master in order to communicate effectively in Japanese.</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_bf5204c64ab743e7909978b3df6fffd5.png" alt="" /></figure></div><p>How can you master all these verb forms and use them correctly in your sentences? Well, one way is to memorize the rules and patterns of verb conjugation. But that can be tedious and time-consuming. Another way is to use Oyomi, a handy tool that helps you learn verb conjugation in an easy and fun way.</p>
<p>You can also see the process of verb conjugation step by step. For example, if you want to know how to conjugate 食べる into potential form (which means “can eat”), you can simply click on the verb and Oyomi will show you the result:</p>
<p>食べる -&gt; drop る -&gt; add られる -&gt; 食べられる</p>
<h2>Learning</h2>
<p>When you click the “Study” button, you’ll see three sections: Structure, Vocabulary and Grammar.</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_1b71ff82807d9119a99c92a84cf483f3.png" alt="" /></figure></div><h3>Structure</h3>
<p>The Structure section shows the semantic tree of the sentence, indicating the grammatical relationship between the phrases.</p>
<h3>Vocabulary</h3>
<p>The Vocabulary section shows all of the words that you plan to learn from the sentence. You can add words to this section by clicking the star before the word when you are doing a lookup. This way, you can create your own personalized word list based on your interests and needs.</p>
<h3>Grammar</h3>
<p>The Grammar section shows all of the grammatical rules related to the sentence. It tells you how and why certain structures are used in different contexts. You can also find examples and explanations for each rule to help you understand them better.</p>
<h2>Learn from EPUB Book</h2>
<p>Oyomi has a built-in bookshelf that supports EPUB format books. You can import your EPUB books by clicking the add button at the top right corner of the app. Then you can browse and select the books that you want to read.</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_294847a1dd99bcae98a03df340464f6b.png" alt="" /></figure></div><p>When you open a book, you will see an “Analyze” button after each paragraph.</p>
<p>This button allows you to analyze the content of the paragraph and get useful information such as vocabulary definitions, grammar explanations, furigana readings, and more.</p>
<p>You can also listen to the audio of the paragraph and practice your pronunciation.</p>
<h2>Review plan</h2>
<p>Another challenge of learning Japanese is remembering what you have learned and applying it in real situations.</p>
<p>Oyomi can help you with that by allowing you to collect words and phrases that you want to remember or practice later.</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_10e4e9663878498506e2368788b59e4f.png" alt="" /></figure></div><p>You can add them to your personal wordbook with just one tap of the star icon before the word.</p>
<p>Oyomi will then generate a review plan for you based on your learning progress and goals. You can review your words and phrases anytime using various modes such as choice, fill-in-the-blank, matching, etc.</p>
<h3>Use the Widget to Learn Vocabulary on Your iOS Home Screen</h3>
<p>If you are looking for a convenient and effective way to improve your vocabulary, you might want to try the widget feature on your iOS device.</p>
<p>The widget is designed to help you use fragments of time to memorize words. Whether you have a few minutes before a meeting, during a commute, or while waiting in line, you can use the widget to learn new words or refresh your memory.</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_37b315be179e0ee1c88affe1bd808f8e.png" alt="" /></figure></div><p>To add the widget to your home screen, follow these simple steps:</p>
<ul>
<li>Long press on an empty area of your home screen until the apps start jiggling.</li>
<li>Tap on the plus sign (+) at the top left corner of the screen.</li>
<li>Search for Oyomi</li>
<li>Tap on the widget app and choose the size and style of the widget you want.</li>
<li>Tap on Add Widget and place it anywhere on your home screen.</li>
<li>Tap on Done at the top right corner of the screen.</li>
</ul>
<p>You can now enjoy learning vocabulary with the widget anytime and anywhere. Happy learning!</p>
<h2>Get the App</h2>
<p>Try it out yourself download it now. Oyomi now supports iOS Android and macOS.</p>
<p><a href="https://apps.apple.com/us/app/oyomi-japanese-reader/id1474251984">App Store.</a></p>
<p><a href="https://play.google.com/store/apps/details?id=com.kevinzhow.pengdu&amp;hl=en&amp;gl=US">Google Play Store</a></p>
]]></content:encoded></item><item><title><![CDATA[Write WebAssembly in Swift and use it in Swift App]]></title><guid>https://blog.kevinzhow.com/posts/swift-webassembly/en</guid><link>https://blog.kevinzhow.com/posts/swift-webassembly/en</link><pubDate>Sat, 11 Jun 2022 10:07:47 +0000</pubDate><content:encoded><![CDATA[<h2>Background</h2>
<p>I've been developing a new app for a while, one of the coolest ideas is to let the user write their own script to extend the app's ability.</p>
<p>But what kind of scripting language should I support? Why not support them all? So the decision is to adapt WebAssembly which grants the user's flavor.</p>
<p>What's more, We will also use Swift to write wasm thanks to the great <a href="https://swiftwasm.org/">SwiftWasm</a> project.</p>
<h2>Communication</h2>
<p>WebAssembly was designed to be a 32-bit sandbox VM, it's safe and isolated from our 64-bit Swift host app.</p>
<p>The only way to communicate with each other is to copy memory from the host app into the VM and read the processed memory from VM back later.</p>
<p>This's a big challenge, but we will overcome it step by step.</p>
<h2>Reference</h2>
<p>If you are not quite familiar with Swift pointer &amp; memory layout, check out these talks.</p>
<p><a href="https://www.youtube.com/watch?v=ERYNyrfXjlg">Exploring Swift Memory Layout</a>
<a href="https://swiftunboxed.com/internals/size-stride-alignment/">Size, Stride, Alignment</a>
<a href="https://www.raywenderlich.com/7181017-unsafe-swift-using-pointers-and-interacting-with-c#toc-anchor-001">Unsafe Swift: Using Pointers and Interacting With C</a></p>
<h2>Setup SwiftWasm</h2>
<h3>1. Install swiftenv</h3>
<p>We will use <a href="https://swiftenv.fuller.li/en/latest/installation.html">swiftenv</a> to manage the toolchain, So please install it first.</p>
<h3>2. Install swiftwasm</h3>
<p>Since swiftwasm has not been merged into the repo, we gonna install it on our own.</p>
<p>Here is the <a href="https://github.com/swiftwasm/swift/releases/tag/swift-wasm-5.6.0-RELEASE">Github release page</a> of the toolchain.</p>
<h3>3. Set swift env in project</h3>
<p>Use swiftenv to check the version of your swiftwasm</p>
<div class="block-code"><pre><code>swiftenv versions</code></pre></div>
<p>Mine is <code>wasm-5.6.0</code> at the time, so at the root of your project folder, run <code>swiftenv local wasm-5.6.0</code> to set the project level swift env.</p>
<h2>Basic WebAssembly App</h2>
<p><strong>Finished project can be found here <a href="https://github.com/kevinzhow/write-wasm-in-swift-demo">https://github.com/kevinzhow/write-wasm-in-swift-demo</a></strong></p>
<p>As we know before, the Host app can only communicate with WebAssembly VM through memory copy, with the help of protobuf, we can transport data between 32-bit wasm VM and 64-bit host app easily.</p>
<p>But we also need to implement a few functions to handle these.</p>
<ol>
<li>allocate memory with size and return memory pointer</li>
<li>deallocate memory at the pointer</li>
<li>function to do the real work with memory address and size.</li>
</ol>
<p>Quick look</p>
<div class="block-code" data-language="swift"><pre><code>import Foundation

@_cdecl(&quot;allocate&quot;)
func allocate(size: Int) -&gt; UnsafeMutableRawPointer {
  return UnsafeMutableRawPointer.allocate(byteCount: size, alignment: MemoryLayout&lt;UInt8&gt;.alignment)
}
@_cdecl(&quot;deallocate&quot;)
func deallocate(pointer: UnsafeMutableRawPointer) {
  pointer.deallocate()
}

@_cdecl(&quot;change_article_proto&quot;)
func changeBookProto(protoData: UnsafeMutableRawPointer,  size: Int,  newAuthor: UnsafeRawPointer, authorSize: Int, newSize: UnsafeMutablePointer&lt;Int&gt;) -&gt; UnsafeRawPointer {
    // Decode proto binary data
    let data = Data(bytes: protoData, count: size)
    var book = try! BookInfo(serializedData: data)

    // Change author
    book.author =  String(data: Data(bytes: newAuthor, count: authorSize), encoding: .utf8)!

    let newData = try! book.serializedData()
    newSize.pointee = newData.count

    // get the data pointer of the new book proto data
    let pointer = newData.withUnsafeBytes{ (bufferRawBufferPointer) -&gt; UnsafeRawPointer in

        let bufferPointer: UnsafePointer&lt;UInt8&gt; = bufferRawBufferPointer.baseAddress!.assumingMemoryBound(to: UInt8.self)
        return UnsafeRawPointer(bufferPointer)
    }

    return pointer
}</code></pre></div>
<p>Now we can build our wasm with command</p>
<div class="block-code"><pre><code>swift build --triple wasm32-unknown-wasi  -c release -Xlinker --allow-undefined</code></pre></div>
<p>We pass <code>--allow-undefined</code> to make sure all <code>@_cdecl</code> functions will be exported.</p>
<p>Then copy it out</p>
<div class="block-code"><pre><code>cp .build/release/swiftwasm.wasm ./swiftwasm.wasm</code></pre></div>
<h2>Swift Host App</h2>
<p><strong>Finished Project can be found here <a href="https://github.com/kevinzhow/swiftwasm-host-app-demo">https://github.com/kevinzhow/swiftwasm-host-app-demo</a></strong></p>
<p>First, we implement a Wasm Module to handle the memory exchange and method call.</p>
<div class="block-code" data-language="swift"><pre><code>import Foundation
import WasmInterpreter
import SwiftProtobuf

public struct WasmModule {
    private let _vm: WasmInterpreter

    init() throws {
        _vm = try WasmInterpreter(module: Bundle.module.url(forResource: &quot;swiftwasm&quot;, withExtension: &quot;wasm&quot;)!)
    }

    /// Allocate memory on heap
    /// It returns byteoffset
    func allocate(size: Int) throws -&gt; Int {
        return Int(try _vm.call(&quot;allocate&quot;, Int32(size)) as Int32)
    }
    
    func deallocate(byteOffset: Int) throws {
        try _vm.call(&quot;deallocate&quot;, Int32(byteOffset))
    }
    
    /// Allocate size on heap
    /// It returns byteoffset
    func allocateSize() throws -&gt; Int {
        
        let length = MemoryLayout&lt;Int32&gt;.size
        
        let newSizePointer = try! allocate(size: length)
        
        return newSizePointer
    }
    
    /// Write string to heap
    /// It returns byteoffset
    func writeString(string: String) throws -&gt; (Int, Int) {
        
        let length = Data(string.utf8).count
        
        let pointer = try! allocate(size: length)
        
        try _vm.writeToHeap(string: string, byteOffset: pointer)
        
        return (pointer, length)
    }
    
    /// Write Data to heap
    /// It returns byteoffset
    func writeData(data: Data) throws -&gt; Int {
        
        let length = data.count
        
        let pointer = try! allocate(size: length)
        
        try _vm.writeToHeap(data: data, byteOffset: pointer)

        return pointer
    }

    /// Send Protobuf binary into
    func changeBook(_ book: BookInfo, author: String) throws -&gt; BookInfo {
         let data = try! book.serializedData()
        
        let (newAuthorPtr, newAuthorSize) = try! writeString(string: author)
        
        let newSizePointer = try! allocateSize()
        
        let dataPointer = try writeData(data: data)
        
        let newArticlePointer = Int(try _vm.call(&quot;change_article_proto&quot;,
                                                 Int32(dataPointer),
                                                 Int32(data.count),
                                                 Int32(newAuthorPtr),
                                                 Int32(newAuthorSize),
                                                 Int32(newSizePointer)) as Int32)
        
        let newSizeValue = Int(try _vm.valueFromHeap(byteOffset: newSizePointer) as Int32)
        
        let newData = try _vm.dataFromHeap(byteOffset: newArticlePointer, length: newSizeValue)
        
        let newBook = try! BookInfo(serializedData: newData)
        
        try! deallocate(byteOffset: newAuthorPtr)
        try! deallocate(byteOffset: newSizePointer)
        try! deallocate(byteOffset: dataPointer)
        try! deallocate(byteOffset: newArticlePointer)
        
        return newBook
    }
}</code></pre></div>
<p>Finally, we can use it.</p>
<p>main.swift</p>
<div class="block-code" data-language="swift"><pre><code>import WasmInterpreter
print(&quot;Hello, world!&quot;)

let module = try! WasmModule()
var book = BookInfo()
book.id = 1
book.author = &quot;Apple&quot;
book.title = &quot;Swift Programming&quot;
let newBook = try! module.changeBook(book, author: &quot;Apple Stuff&quot;)
print(newBook.author)</code></pre></div>
]]></content:encoded></item><item><title><![CDATA[BenQ WiT ScreenBar Halo 体验报告]]></title><guid>https://blog.kevinzhow.com/posts/benq-wit-screenbar-halo-ti-yan-bao-gao/en</guid><link>https://blog.kevinzhow.com/posts/benq-wit-screenbar-halo-ti-yan-bao-gao/en</link><pubDate>Fri, 18 Feb 2022 15:13:17 +0000</pubDate><content:encoded><![CDATA[<h2><strong>前言</strong></h2>
<p>去年我曾写过一篇 BenQ WiT ScreenBar Plus 的体验报告，当时我曾想以为这也许就是屏幕挂灯的天花板了，毕竟之后其他厂商陆陆续续出的一些屏幕挂灯，也都没能在各项光照指标上超越它，以至于 BenQ 找到我体验新款 Halo 的时候，我都觉得可能没啥必要。</p>
<p>可谁又想得到呢，到手之后点亮的三分钟里，这款产品真的就完全把我给折服了， Halo 在 ScreenBar Plus 的基础上做出了多项升级：</p>
<ul>
<li>支持后补光</li>
<li>无线控制器</li>
<li>曲率控光专利技术，减少 89.8% 的屏幕反光</li>
<li>支持 35 度调节（之前是 15 度）</li>
<li>曲面屏适配器</li>
<li>集成在灯条内的环境光传感器</li>
</ul>
<p>可以说 Halo 的产品力完全超越了前作，产品的实用性和体验被提升到了一个新的纬度，但你其实真的需要关心的，是当减少了 89.8% 屏幕反光的时候，体验是如何被重新定义的。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_3a4cbf735fdb7169e3e5e9e06a7e27d2.jpg" alt="" /></figure></div><div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_310d1ca177155294236c3bff304510c2.jpg" alt="" /></figure></div><h2><strong>我的使用场景</strong></h2>
<p>因为工作需要，我经常要在电脑桌前敲代码，做设计，查阅资料，阅读纸质内容，在使用屏幕挂灯之前，我通常是开屋里的射灯，但阅读体验就比较差了，往往是在自己脑袋的阴影里看书</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_edcecff40ba188c039f45640f2afc76e.jpg" alt="" /></figure></div><p>屏幕挂灯还有很好的专注，聚焦效果，所以现在不论白天还是夜晚使用电脑，我都喜欢开着挂灯工作。</p>
<h2><strong>开箱</strong></h2>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_e4932341c4d0ebaf1aa12bd12364fff3.jpg" alt="" /></figure></div><p>ScreenBar Halo 继续保持了简洁精致的设计风格，Bar + 重力支架 + 无线控制器</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_f998309130eb9603ed124f5ca3b07a66.jpg" alt="" /></figure></div><p>新一代的重力支架增加了后向补光，集成了电源线，上一代更加整洁，使用 6.5W 的 USB 电源输入，并且经过测试，新的重力支架的防滑性能，稳定性都更好</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_8475c386daa284607e4c5f2636f63641.jpg" alt="" /></figure></div><p>新的无线控制器解决了线缆的困扰，使用三节七号电池供电，设计上抛弃了机械按键，使用寿命更长，设计也更有未来感</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_3be27bf1d99b633d6fcb42de71c6781a.jpg" alt="" /></figure></div><div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_5092adf600709513b2f4831a7d3e002d.jpg" alt="" /></figure></div><p>控制器需要手悬停 1 秒进行激活，同样支持色温调节，自动补光，新增了一个喜爱模式</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_eda83d9c8f41d885aa94a829ef0dd686.jpg" alt="" /></figure></div><p>最后是一个曲面屏适配器</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_ffd3738de7802d3e9cfb1fec9a64bf92.jpg" alt="" /></figure></div><h2><strong>光路是最核心的体验</strong></h2>
<p>去年在体验过 BenQ ScreenBar Plus 后，看到市场上也出了一些百元级别的产品，好奇的我又先后买了倍思和小米的产品来对比，倍思因为过差所以已经扔了，就不拿来对比了，一起看看小米和 BenQ 上一代产品 ScreenBar Plus 的对比效果。</p>
<h2><strong>小米光路</strong></h2>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_a32c098453f1cdc7e50951ea92bdf24b.jpg" alt="" /></figure></div><h2><strong>ScreenBar Plus 光路</strong></h2>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_e2c2a7ca64c92bdf40574e8bbe60b439.jpg" alt="" /></figure></div><p>ScreenBar Plus 的光路明显分界线要优于小米，但如果你仔细看，会发现还是有一束光线照向了屏幕，这束光在 iMac 上正好射到光线传感器，导致我的 iMac 经常越用越亮。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_697daf422f7870648620a1f6d1b39a70.jpg" alt="" /></figure></div><p>所以 ScreenBar Plus 虽然亮度和显色非常优秀，但光路里的这个 Bug 导致我的实际体验并没有比小米好出一个级别，这也是当时让我感到比较遗憾的。</p>
<p>因此 ScreenBar Halo 到手的时候，我第一个验证的就是这个问题，结果，神了！</p>
<h2><strong>ScreenBar Halo 光路</strong></h2>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_1d5d81d9c1142256c00f61567c2c69ce.jpg" alt="" /></figure></div><p>从新的光路图中可以看出来，Halo 实现了一个几乎完美垂直向下的光路，这得益于新的「曲率控光」专利技术，Halo 减少了 89.8% 的屏幕反光</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_c61d3d1f3ea0b0d21c68aface14b154d.gif" alt="" /></figure></div><p>如果说以前的 ScreenBar Plus 的体验有点像在坐翘翘板，那 Halo 就真的是一款体验上毫无负担，出人意料的水桶产品，其实当产品好到这个程度的时候，已经奠定了品类里堪比“苹果”的地位了。</p>
<h2><strong>总结</strong></h2>
<p>经过一个星期的使用，总结一下新款的 Halo 优点如下</p>
<ul>
<li>极其优秀顶尖的光路，无妥协的体验提升</li>
<li>支持曲面屏</li>
<li>支持无线控制</li>
</ul>
<p>不足之处就只有一个了</p>
<ul>
<li>无线控制器可能处于省电的考虑，手指悬停激活有些延迟，无法实现之前机械按键的一触即亮的感觉</li>
</ul>
<p>总体来说，这是一款顶级屏幕挂灯产品，如果你有使用独立显示器的使用习惯，那么新款的 ScreenBar Halo 可以称当上是一个极致的搭配，如果你喜欢没有妥协的体验，那么 Halo 将是一个称职的选择。</p>
]]></content:encoded></item><item><title><![CDATA[记录 2021 年考驾照的体验]]></title><guid>https://blog.kevinzhow.com/posts/ji-lu-2021-nian-kao-jia-zhao-de-ti-yan-2/en</guid><link>https://blog.kevinzhow.com/posts/ji-lu-2021-nian-kao-jia-zhao-de-ti-yan-2/en</link><pubDate>Thu, 30 Dec 2021 07:14:22 +0000</pubDate><content:encoded><![CDATA[<h2>城市</h2>
<p>青岛市黄岛区</p>
<h2>驾校选择</h2>
<p>一开始并不知道怎么判断一个驾校的好坏，网上的很多学车被坑的故事更是看得心惊胆战，但本着<strong>「货比三家，一分钱一分货」</strong>的原则，先随便转转好了。</p>
<p>第一站是去了金沙滩附近的驾校，感觉大受震撼，驾校是小平房 + 散装场地的配置，无限趋近于棚户区。</p>
<p>受大惊吓后，第二站就直奔比较远，但区内口碑最好的驾校，到了后发现环境确实比上一家好很多。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_810796112058e57dee3df6b57546798b.jpg" alt="" /></figure></div><div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_3aaf300a526efb045b4e5bb003fef860.jpg" alt="" /></figure></div><h2>驾校价格</h2>
<p>这家驾校之所以口碑好，主要是因为「一费到底」「教练不骂人」以及「每车 3 人」也就是交完钱后可以正经学车，不需要了解任何潜规则。</p>
<p>价格如下</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_822d377c95a2906b107ea6d4d498a7a2.jpg" alt="" /></figure></div><p>其实比起来其他小驾校 2800 左右（不含考试费）的价格，这个价格算是贵多了</p>
<ul>
<li>3K 的档位是不包考试费的，有班车可以乘坐</li>
<li>4K 的价格是包含了所有考试费，补考的话无需再交费</li>
<li>6K 的商务班则额外提供了「专车接送」以及「每车 1 - 2 」人的服务</li>
<li>9K 是「一对一」</li>
</ul>
<p>考虑到我家到驾校需要走高速，大概 25 分钟的车程，有专车接送会比较轻松，我就报了 6K 的商务班。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_fca300a99226f644caf7f8498a69a2d8.jpg" alt="" /></figure></div><h2>科目一</h2>
<p>报名后在驾校需要先做简单的体检，前台小姐姐叮嘱我说现在青岛的驾考需要打学时，让我每天来驾校打满学时，最快 3 天后可以考科目一，于是从这天开始，我这两个月最重要的事情就变成了「去驾校」</p>
<p>科目一主要是理论知识，大概有 1400 道题（选择题和判断题），全部刷完模拟考试能 96 分以上就可以报名考试了，下图就是我每天刷题的地方，竹林的影子会映射到室内，影影绰绰，还是很惬意的。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_ff1e4921d6466309086f6ec141d6ebbc.jpg" alt="" /></figure></div><p>三天后我刷完了题，打满了学时，通过了模拟考试，小姐姐就给我报名正式的考试了。</p>
<p>考试当天早上 6：30 起床，教练来接我到了驾校，随后乘坐班车历时 1 个多小时到了考场，10 分钟答完 100 道题，完成了科目一考试。</p>
<h2>科目二</h2>
<p>科目一考完后，教练在微信了和我约了学车时间，基本上每周连续三天，下午一点到四点，然后五点会有教练送我回家。</p>
<p>第一次开车时拍下了这具有纪念意义的时刻。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_e498e07333587766b19d5cfe5c8d06ed.jpg" alt="" /></figure></div><p>窗外的风景也很好</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_a68b7b52795a1fdeb27105adf70e12d2.jpg" alt="" /></figure></div><p>科目二由「倒车入库」「侧方位停车」「S 弯」「直角弯」四部分构成。学时打满后报名了科目二考试，当然也是顺利通过。</p>
<h2>科目三</h2>
<p>科目三是路考，也就是从 A 出门兜一圈然后再回到 A，大概有 3 公里左右。</p>
<p>科目三内容就繁琐的多，包括启停车辆，观察路况，文明行车，按照道路标志，信号灯行车，夜间行车，超车，变道，直线行驶，路边停车。</p>
<p>教练带着我在驾校附近的公路和考场路线上练满学时后，就报名考试了。</p>
<p>考试地点和科目二考场是一个地方，那天又是 6:30 出发到考场等着，自动挡因为学的人少，目前只有 1 辆考试车，整整等到了 12 点才排到我，过程有惊无险，顺利通过了考试。</p>
<h2>科目四</h2>
<p>科目四也被成为车德，主要是一些文明驾驶的内容。刷完 1300 多道题后，会考你 50 道题（选择题，判断题，多选题）</p>
<p>科目三考完后，就可以连考科目四，于是我在完成科目三的第二天，又是 6:30 折腾到了考场，好在也是顺利完成了考试。</p>
<h2>驾照</h2>
<p>科目四完成后的下午 4 点钟，12123 上就会出现驾照信息，点击「申请电子驾照」后 30 分钟左右就可以拿到。</p>
<h2>感想</h2>
<p>整体来说，这次驾考体验还是蛮好的，选择的很成功。</p>
<p>一方面驾校很守规则，不论是学车还是考试，都没有任何潜规则要求，其次是驾校的教练也都很 Nice，有的可以一起聊聊美食，有的会在送我回家的路上带我一起听崂山 921 答题，这两个月过的很单纯。</p>
]]></content:encoded></item><item><title><![CDATA[捧读的 EPUB 日语轻小说阅读器来了]]></title><guid>https://blog.kevinzhow.com/posts/peng-du-de-epub-yue-du-qi-lai-liao/en</guid><link>https://blog.kevinzhow.com/posts/peng-du-de-epub-yue-du-qi-lai-liao/en</link><pubDate>Wed, 25 Aug 2021 17:08:04 +0000</pubDate><content:encoded><![CDATA[<p>在刚刚发布的 iOS 1.0.33 中，你可以导入  EPUB 小说阅读了，目前日语轻小说阅读体验良好。</p>
<p>这个功能规划于 2020 年 4 月，没想到都一年多了！颇有一种实现了自己的愿望的感觉。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_22f3c6b2de9ff94ee29cfc84dbb6cb73.jpg" alt="-------20210826-005302" /></figure></div><div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_9383f7719afb453b09cbff9d4d58d968.jpg" alt="-------20210826-005328" /></figure></div><p>得益于此功能，我也能够大量阅读自己喜欢的内容了，所以接下来会有更多的动力完善语法分析功能以及使用体验。</p>
<p>最后，macOS 和 Android 版本的 EPUB 阅读功能也在做了，应该不会等太久的。</p>
<p>App Store <a href="https://apps.apple.com/cn/app/%E6%8D%A7%E8%AF%BB-%E6%97%A5%E8%AF%AD%E8%AF%AD%E6%B3%95%E5%AD%A6%E4%B9%A0%E4%B8%8E%E5%88%86%E6%9E%90/id1474251984">下载地址</a></p>
]]></content:encoded></item><item><title><![CDATA[使用 Go Mobile 开发跨平台 Library]]></title><guid>https://blog.kevinzhow.com/posts/gomobile_library/en</guid><link>https://blog.kevinzhow.com/posts/gomobile_library/en</link><pubDate>Tue, 22 Jun 2021 07:55:22 +0000</pubDate><content:encoded><![CDATA[<h3>前情提要：</h3>
<p><a href="https://blog.kevinzhow.com/2021/06/21/kotlin_natvie_ios_android/">使用 Kotlin Native 开发跨平台 Library</a></p>
<h2>为什么使用 Go Mobile</h2>
<p>相对于 Kotlin Native 而言，Go 有更完善的生态支持，更小的二进制体积。</p>
<p>虽然 Go Mobile 维护者有跑路的嫌疑，但通过<a href="https://github.com/golang/mobile/pull/65">第三方的 Fork </a>我们已经可以支持 Apple Silicon 和 Catalyst.</p>
<p>下面还是用和<a href="https://blog.kevinzhow.com/2021/06/21/kotlin_natvie_ios_android/">使用 Kotlin Native 开发跨平台 Library</a>里一样的 NASA API 来做一个 SDK，看看使用体验如何。</p>
<h2>创建 Go Module</h2>
<p>你可以到 <a href="https://github.com/kevinzhow/gomobile-lib-demo">gomobile-lib-demo</a> 下载已经完成的项目</p>
<p>首先定义 GOPATH，我选了用户 zhoukaiwen 目录的 golang 文件夹。</p>
<div class="block-code" data-language="bash"><pre><code>export GOPATH=/Users/zhoukaiwen/golang</code></pre></div>
<p>找个 GOPATH 之外的地方，创建 module 文件夹</p>
<div class="block-code" data-language="bash"><pre><code>mkdir go_lib_demo
cd go_lib_demo
go mod init Hello</code></pre></div>
<p>编辑 go.mod 增加一个好用点的 HTTP 库 resty 依赖，以及解决了 Catalyst 和 Apple Silicon 的第三方 gomobile <code>github.com/ydnar/gomobile</code></p>
<div class="block-code"><pre><code>module Hello

go 1.16

require (
github.com/go-resty/resty/v2 v2.6.0
golang.org/x/mobile v0.0.0-20210614202936-7c8f154d1008 // indirect
)

replace golang.org/x/mobile v0.0.0-20210614202936-7c8f154d1008 =&gt; github.com/ydnar/gomobile v0.0.0-20210301201239-fb6ffafc9ef9</code></pre></div>
<p>获取依赖</p>
<div class="block-code"><pre><code>go get github.com/go-resty/resty/v2
go get golang.org/x/mobile/cmd/gomobile
go get golang.org/x/mobile/bind</code></pre></div>
<p>初始化 gomobile</p>
<div class="block-code"><pre><code>gomobile init</code></pre></div>
<h2>创建 API</h2>
<p>使用 NASA 的 API 获得 <a href="https://apod.nasa.gov/apod/astropix.html">Astronomy Picture of the Day</a> 的 JSON 数据.</p>
<div class="block-code"><pre><code>https://api.nasa.gov/planetary/apod?api_key={API_KEY}</code></pre></div>
<p><code>src/hello/Nasa.go</code></p>
<div class="block-code" data-language="go"><pre><code>package hello

import (
&quot;encoding/json&quot;
&quot;fmt&quot;

&quot;github.com/go-resty/resty/v2&quot;
)

type APOD struct {
Date           string `json:&quot;date&quot;`
Explanation    string `json:&quot;explanation&quot;`
HDurl          string `json:&quot;hdurl&quot;`
MediaType      string `json:&quot;media_type&quot;`
ServiceVersion string `json:&quot;service_version&quot;`
Title          string `json:&quot;title&quot;`
Url            string `json:&quot;url&quot;`
}

type NasaPath string

const (
nasaBaseURL NasaPath = &quot;https://api.nasa.gov&quot;
)

var (
apodPath NasaPath = &quot;/planetary/apod&quot;
)

func (m NasaPath) fullPath() string {
return fmt.Sprint(nasaBaseURL + m)
}

type NasaClient struct {
ApiKey string
}

func (nasaClient *NasaClient) GetAPOD() (*APOD, error) {
url := apodPath.fullPath()

client := resty.New()

resp, err := client.R().
SetQueryParams(map[string]string{
&quot;api_key&quot;: nasaClient.ApiKey,
}).
Get(url)

if err != nil {
return nil, err
}

var apod APOD
if err := json.Unmarshal([]byte(resp.Body()), &amp;apod); err != nil {
return nil, err
}

return &amp;apod, nil
}</code></pre></div>
<h2>编译出 xcframework</h2>
<div class="block-code"><pre><code>gomobile bind -target ios ./src/hello </code></pre></div>
<h2>创建 Swift Package</h2>
<p>方法和<a href="https://blog.kevinzhow.com/2021/06/21/kotlin_natvie_ios_android/">使用 Kotlin Native 开发跨平台 Library</a>一样，你可以到 <a href="https://github.com/kevinzhow/go_lib_swift_package_demo">go_lib_swift_package_demo</a> 查看已经完工的项目。</p>
<div class="block-code" data-language="swift"><pre><code>import PackageDescription

let package = Package(
    name: &quot;go_lib_swift_package_demo&quot;,
    products: [
        .library(
            name: &quot;go_lib_swift_package_demo&quot;,
            targets: [&quot;go_lib_swift_package_demo&quot;, &quot;HappyNasa&quot;]),
    ],
    targets: [
        .target(
            name: &quot;go_lib_swift_package_demo&quot;,
            dependencies: []),
        .binaryTarget(
                    name: &quot;HappyNasa&quot;,
                    path: &quot;Sources/hello.xcframework&quot;),
        .testTarget(
            name: &quot;go_lib_swift_package_demoTests&quot;,
            dependencies: [&quot;go_lib_swift_package_demo&quot;]),
    ]
)</code></pre></div>
<h2>在 iOS 中使用</h2>
<p>你可以到 <a href="https://github.com/kevinzhow/go_lib_ios_demo">go_lib_ios_demo</a> 查看已经完工的项目。</p>
<div class="block-code" data-language="swift"><pre><code>import UIKit
import Hello

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let client = HelloNasaClient()
        client.apiKey = &quot;{API_KEY}&quot;

        do {
            let apod = try client.getAPOD()
            print(apod.title)
            print(apod.explanation)
        } catch let error {
            print(error.localizedDescription)
        }
    }
}</code></pre></div>
<h2>后记</h2>
<p>相对于 Kotlin Native，Go 编译出的 SDK 要小很多，但比较遗憾的是 go func 在 Go Mobile 中并不能使用。如果你在函数里使用了 go func, 在编译后这个函数会被自动删除。</p>
]]></content:encoded></item><item><title><![CDATA[使用 Kotlin Native 开发跨平台 Library]]></title><guid>https://blog.kevinzhow.com/posts/kotlin_natvie_ios_android/en</guid><link>https://blog.kevinzhow.com/posts/kotlin_natvie_ios_android/en</link><pubDate>Mon, 21 Jun 2021 17:29:36 +0000</pubDate><content:encoded><![CDATA[<h3>拓展阅读</h3>
<p><a href="https://blog.kevinzhow.com/2021/06/22/gomobile_library/">使用 Go Mobile 开发跨平台 Library</a></p>
<h2>为什么想使用 Kotlin Native？</h2>
<p>我的两款日语学习产品<a href="https://apps.apple.com/cn/app/50%E9%9F%B3%E8%B5%B7%E6%BA%90-%E6%97%A5%E8%AF%AD%E4%BA%94%E5%8D%81%E9%9F%B3%E9%9B%B6%E5%9F%BA%E7%A1%80%E5%85%A5%E9%97%A8/id1439222882">「50音起源」</a><a href="https://apps.apple.com/cn/app/%E6%8D%A7%E8%AF%BB-%E6%97%A5%E8%AF%AD%E8%AF%AD%E6%B3%95%E5%AD%A6%E4%B9%A0%E4%B8%8E%E5%88%86%E6%9E%90/id1474251984">「捧读」</a>都是多平台产品，在开发「50音起源」的时候，我选择了平台 Native 技术，虽然有一定的维护成本，但初期觉得工作量还好，不过后来慢慢懒了起来，就逐渐有了放羊的心。</p>
<p>因此当「捧读」在开发的时候，我尝试用 Flutter 解决这个问题，但是上线后又很不幸的发现， Flutter 对 Apple 的技术栈支持并不积极，比如 Catalyst 不能使用，还有一些小性能问题，所以最后「捧读」的 iOS 版本又用 UIKit 重写了。</p>
<p>最近给 Android 版本的「50音起源」做更新的时候，尝试用 Jectpack Compose 写了一个「设置」界面，感觉还不错，声明式，状态化，实时预览，开发效率很高，它即不基于旧的 Android UI Toolkit，没有历史遗留问题，很好的解决了 Android 上以往痛苦无比的 UI 开发过程，也不像 SwiftUI 那样绑定到了系统中，开发者可以自行更新 App 使用的 Jetpack Compose 版本。</p>
<p>所以这段时间，Jectpack Compose 极速拉升了我对 Android 开发的好感。关于它的诸多理念，可以观看官方 19 年的 <a href="https://www.youtube.com/watch?v=VsStyq4Lzxo">Session</a></p>
<p><strong>我在做跨平台开发时，通常有下面几个痛点</strong></p>
<ol>
<li>期望无缝结合平台原本已有的特性，比如特有 UI 控件，SDK 的 API。</li>
<li>期望性能不打折扣，没有用户体验的妥协</li>
<li>期望不受限于跨平台开发技术的限制，能第一时间跟进最新的设备，系统</li>
</ol>
<p>如果把这三点考虑进去，最好的方式就是使用原生 UI Toolkit，在数据层上做跨平台。但以往 Android UI 开发工作别繁琐，导致我后来宁愿 Flutter 也不要用 Android 自己的 UI Toolkit.</p>
<p>但现在有了 Jetpack Compose 这种好用的 Toolkit，我可以下个决心只做数据层跨平台了。</p>
<p><strong>那选什么语言呢？</strong></p>
<p><a href="https://github.com/golang/mobile">Go Mobile</a> 好像可以，Rust 也有这方面的<a href="https://mozilla.github.io/firefox-browser-architecture/experiments/2017-09-06-rust-on-ios.html">愿景</a>，但我还是最喜欢写得舒服的 Swift。</p>
<p>可 Swift 显而易见是不适合跑 Android 上的，那 Kotlin 呢？</p>
<p>Kotlin 不仅和 Swift 语法相近，还从官方立场就做了跨平台的完善支持，最近 Swift Package Manager 支持了 <a href="https://developer.apple.com/documentation/swift_packages/distributing_binary_frameworks_as_swift_packages">XCFramework Bundle</a> 这意味着，只要 Kotlin Native 能编译出不同 Apple 架构的 Framework，就可以轻松的打包成一个 Swift Package 进行跨平台使用以及分发了。</p>
<p>嗅到了「有戏」的味道后，决定试试搞了有些年头的 Kotlin Native。</p>
<h2>Kotlin Native 的跨平台原理</h2>
<p>Kotlin Native 的<a href="https://kotlinlang.org/docs/mpp-supported-platforms.html">跨平台</a>可以说是巨全无比了</p>
<ul>
<li>JVM</li>
<li>JS</li>
<li>Android / Android NDK</li>
<li>Apple</li>
<li>Linux</li>
<li>Windows</li>
<li>WebAssembly</li>
</ul>
<p><a href="https://kotlinlang.org/docs/native-overview.html">简而言之</a>，Kotlin 虽然可以跑在 JVM 上，也能调用 Java 代码，但 Kotlin 并不是 Java，借助于 LLVM，<em>Pure</em> Kotlin Code 可以编译成平台代码，实现无 VM 跨平台。</p>
<p>它编译出的可以是 executable，也可以是 library，当然还可以是 Apple framework.</p>
<h2>写一个 API SDK: HappyNasa</h2>
<p>我完全不担心 Kotlin Native 在 Android 上的使用问题，因此主要想得出的结论是和 Swift 一起用怎么样。</p>
<p>一个典型的使用场景就是用 Kotlin Native 写一个 API SDK，由 SDK 负责请求 API 并解析 JSON，并返回一个反序列化后的对象给 Swift.</p>
<p>首先我们需要一个 API.</p>
<p>在网上找了会，我发现 NASA 有一个 Astronomy Picture of the Day 的 API 很有意思，你可以访问这个 <a href="https://apod.nasa.gov/apod/astropix.html">Astronomy Picture of the Day</a> 页面查看.</p>
<p>NASA 提供的 API URL 是这样的</p>
<div class="block-code"><pre><code>https://api.nasa.gov/planetary/apod?api_key={API_KEY}</code></pre></div>
<p>你可以到 <a href="https://api.nasa.gov/">https://api.nasa.gov/</a> 申请自己的 API_KEY.</p>
<p>接下来就是创建一个 Kotlin Native 项目，我使用的是 <a href="https://www.jetbrains.com/idea/">IntelliJ IDEA</a>。</p>
<p>File -&gt; New -&gt; Project</p>
<p>像下图这样选择 Mobile Library</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_893f885fb707cd77e06268953d930131.png" alt="Screen-Shot-2021-06-22-at-00.32.11" /></figure></div><p>默认会创建三个 <a href="https://kotlinlang.org/docs/mpp-discover-project.html#targets">target</a></p>
<ul>
<li>common</li>
<li>android</li>
<li>ios</li>
</ul>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_eba0a28b6f0bc8b8246f892a97efcf9b.png" alt="flat-structure" /></figure></div><p>继续简而言之，common 里的代码是通用代码，理论上 SDK 最核心的逻辑就应该放在这里面，而 android 和 ios 可以使用 <a href="https://kotlinlang.org/docs/mpp-share-on-platforms.html#use-target-shortcuts">shortcuts</a> 的特性，继承 common，并可以<a href="https://kotlinlang.org/docs/mpp-connect-to-apis.html">使用自属平台接口</a>.</p>
<p>用法从 Greeting 类的 Platfrom 变量的传递上就可以看出来。</p>
<p><code>commonMain/kotlin/me.zhoukaiwen.library/Greeting.kt</code></p>
<div class="block-code" data-language="kotlin"><pre><code>class Greeting {
    fun greeting(): String {
        return &quot;Hello, ${Platform().platform}!&quot;
    }
}</code></pre></div>
<p><code>iosMain/kotlin/me.zhoukaiwen.library/Platform.kt</code></p>
<div class="block-code" data-language="kotlin"><pre><code>import platform.UIKit.UIDevice

actual class Platform actual constructor() {
    actual val platform: String = UIDevice.currentDevice.systemName() + &quot; &quot; + UIDevice.currentDevice.systemVersion
}</code></pre></div>
<p>如果你现在直接按下 Build，完成后，就可以在项目目录的 <code>build/bin/ios</code> 文件夹里找到编译好的 Framework，开箱即用.</p>
<h2>进行网络请求</h2>
<p>现在你可以到我的 repo <a href="https://github.com/kevinzhow/kotlin-native-library-demo">kotlin-native-library-demo</a> 上去下载已经完工的项目</p>
<p>首先是需要配置 <code>build.gradle.kts</code> 把<a href="https://ktor.io/docs/json.html#kotlinx_dependency">序列化</a>和<a href="https://ktor.io/docs/http-client-multiplatform.html#add-dependencies">网络请求库 Ktor </a>的依赖给加上，重点步骤是下面这几个</p>
<div class="block-code" data-language="kotlin"><pre><code>// 配置 serialization plugin
// https://ktor.io/docs/json.html#kotlinx_dependency
// https://github.com/Kotlin/kotlinx.serialization#setup
// https://kotlinlang.org/docs/mpp-discover-project.html#multiplatform-plugin
plugins {
    kotlin(&quot;plugin.serialization&quot;) version &quot;1.5.10&quot;
}

kotlin {
  // 配置 target
  // https://kotlinlang.org/docs/mpp-discover-project.html#targets
  val macos = macosX64(&quot;macos&quot;)
  val ios = iosX64(&quot;ios&quot;)
  val iosArm64 = iosArm64(&quot;iosArm64&quot;)
  
  // https://kotlinlang.org/docs/mpp-discover-project.html#source-sets
  sourceSets {
    val commonMain by getting {
                dependencies {
                // 添加 ktor core 到 common
                // https://ktor.io/docs/http-client-multiplatform.html#add-dependencies
                // https://kotlinlang.org/docs/mpp-add-dependencies.html
                    implementation(&quot;io.ktor:ktor-client-core:$ktorVersion&quot;)
                    implementation(&quot;io.ktor:ktor-client-serialization:$ktorVersion&quot;)
                }
            }
            
    val iosMain by getting {
        dependsOn(commonMain)
        dependencies {
            // 添加 ktor for ios
            implementation(&quot;io.ktor:ktor-client-ios:$ktorVersion&quot;)
        }
    }

    val macosMain by getting {
        dependsOn(commonMain)
        dependencies {
            // 添加 ktor for macOS
            implementation(&quot;io.ktor:ktor-client-curl:$ktorVersion&quot;)
        }
    }

    // 配置每个 target 编译出来的 Framework 名称
    // https://kotlinlang.org/docs/mpp-build-native-binaries.html#declare-binaries
    configure(listOf(ios, iosArm64, macos)) {
        // listOf(RELEASE) 是指 Build 时只编译 Release 版本
        binaries.framework(listOf(RELEASE)) {
            baseName = &quot;HappyNasa&quot;
        }
    }
  }
}</code></pre></div>
<p>关于这个文件的结构说明，可以参考<a href="https://kotlinlang.org/docs/mpp-discover-project.html">官方文档 Discover Project</a> 以及 <a href="https://kotlinlang.org/docs/mpp-build-native-binaries.html#declare-binaries">Build final native binaries</a></p>
<p>接下来就可以编写请求逻辑了</p>
<p><code>commonMain/kotlin/me.zhoukaiwen.library/Nasa.kt</code></p>
<div class="block-code" data-language="kotlin"><pre><code>@Serializable
@SerialName(&quot;APOD&quot;)
data class APOD(val date: String,
                val explanation: String,
                val hdurl: String,
                val media_type: String,
                val service_version: String,
                val title: String,
                val url: String)

class NASA(private val apiKey: String) {

    val NASAEntryPoint.fullPath: String
        get() {
            return nasaBaseURL + this.path
        }

    private val client = HttpClient() {
        install(JsonFeature) {
            serializer = KotlinxSerializer()
        }
    }

    private val nasaBaseURL: String = &quot;https://api.nasa.gov&quot;

    enum class NASAEntryPoint(val path: String) {
        APOD( &quot;/planetary/apod&quot;)

    }

    suspend fun getAPOD(): APOD? {
        val response: HttpResponse =  client.get(NASAEntryPoint.APOD.fullPath) {
            parameter(&quot;api_key&quot;, apiKey)
        }

        return try {
            val apod: APOD = response.receive()
            apod
        } catch (e: NoTransformationFoundException) {
            null
        }
    }
}</code></pre></div>
<p>现在按下 Build，我们就可以在 <code>build/bin</code> 下找到 <code>ios</code> <code>iosArm64</code> <code>macos</code> 这三个平台的 Framework.</p>
<h2>创建 Swift Package</h2>
<p>首先是使用 xcodebuild 合并 Framework 成一个 xcframework.</p>
<div class="block-code"><pre><code>xcodebuild -create-xcframework -framework ./lib/iosArm64/releaseFramework/HappyNasa.framework -framework ./lib/ios/releaseFramework/HappyNasa.framework -framework ./lib/macos/releaseFramework/HappyNasa.framework -output ./happy_lib.xcframework</code></pre></div>
<p>随后就是新建我们的 Swift Package 项目了，创建的教程就略过吧，你可以直接到 Github 下载已经完工的项目 <a href="https://github.com/kevinzhow/kotlin_native_swift_package_demo_lib">kotlin_native_swift_package_demo_lib</a></p>
<p>配置 <code>Package.swift</code></p>
<div class="block-code" data-language="swift"><pre><code>import PackageDescription

let package = Package(
    name: &quot;kotlin_demo_lib&quot;,
    products: [
        .library(
            name: &quot;kotlin_demo_lib&quot;,
            targets: [&quot;kotlin_demo_lib&quot;, &quot;HappyNasa&quot;]),
    ],
    targets: [
        .target(
            name: &quot;kotlin_demo_lib&quot;,
            dependencies: []),
        .binaryTarget(
                    name: &quot;HappyNasa&quot;,
                    path: &quot;Sources/happy_lib.xcframework&quot;),
        .testTarget(
            name: &quot;kotlin_demo_libTests&quot;,
            dependencies: [&quot;kotlin_demo_lib&quot;]),
    ]
)</code></pre></div>
<p>主要的操作就是增加 <code>binaryTarget</code>，参考官方文档的 <a href="https://developer.apple.com/documentation/swift_packages/distributing_binary_frameworks_as_swift_packages">Declare a Binary Target in the Package Manifest</a> 即可。</p>
<h2>在 iOS 中使用 HappyNasa</h2>
<p>新建一个 Xcode 的 Swift 项目，加入我们刚刚完工的 Swift Package 的引用，你可以在我的 repo <a href="https://github.com/kevinzhow/kotlin_native_ios_demo">kotlin_native_ios_demo</a> 这里下载完工的项目。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_bb1ea399391ffca31bc5b3efcbcc03b4.png" alt="Screen-Shot-2021-06-22-at-01.09.26" /></figure></div><p>需要注意的是我引用的是本地地址，如果你直接 clone 了我的项目，那么请重新添加 Swift Pakcage 的依赖。</p>
<p><code>ViewController.swift</code></p>
<div class="block-code" data-language="swift"><pre><code>import UIKit
import kotlin_demo_lib
import HappyNasa

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        let a = Greeting().greeting()
        print(a)

        let nasaClient = NASA.init(apiKey: &quot;{API_KEY}&quot;)
        nasaClient.getAPOD { apod, error in
            if let apod = apod {
                print(apod.title)
            } else {
                print(&quot;Get apod failed&quot;)
            }
        }
    }
}</code></pre></div>
<p>使用起来感觉还是蛮优雅的，Kotlin 的 suspend 函数被翻译成了 Swift 的 completion handler. 在 Kotlin 中定义的对象 APOD 也可以正常访问属性。</p>
<h2>结语</h2>
<p>Kotlin Native 看起来是个很不错的跨平台方案，既有高级语言的特性，又能很完美的针对多平台进行编译。</p>
<p>当然，目前还是有美中不足的部分的，Catalyst 和 Apple Silicon 的架构支持还在进行中，根据官方的issue <a href="https://youtrack.jetbrains.com/issue/KT-40442">KT-40442</a> <a href="https://youtrack.jetbrains.com/issue/KT-45302">KT-45302</a> Kotlin 1.5.30 发布的时候，我们应该就可用上了。</p>
<p>但我还有一个梦想，有一天 Swift 可以像 Kotlin 这样十分方便的跑在各种平台上。</p>
<h2>其他资源</h2>
<p><a href="https://github.com/AAkira/Kotlin-Multiplatform-Libraries">Kotlin-Multiplatform-Libraries</a>
<a href="https://kotlinlang.org/docs/native-c-interop.html">Interoperability with C</a>
<a href="https://kotlinlang.org/docs/apple-framework.html">Kotlin/Native as an Apple framework – tutorial</a></p>
]]></content:encoded></item><item><title><![CDATA[小番茄 - 一个只有陪伴的自习室]]></title><guid>https://blog.kevinzhow.com/posts/xiao-fan-qie-yi-ge-zhi-you-pei-ban-de-zi-xi-shi/en</guid><link>https://blog.kevinzhow.com/posts/xiao-fan-qie-yi-ge-zhi-you-pei-ban-de-zi-xi-shi/en</link><pubDate>Fri, 05 Mar 2021 13:20:16 +0000</pubDate><content:encoded><![CDATA[<h2>这是什么产品</h2>
<p>只有陪伴的番茄钟自习室</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_9d6f9e87d94d730bc23c3e9cb75b7849.png" alt="4-Main" /></figure></div><h2>为什么开发这款产品</h2>
<p>陪伴感一直是我很喜欢社交产品的一个原因，尤其是自己在学习或者工作的时候。</p>
<p>但社交产品中参差的言语表达总是会让我感到侵入感，这其实对我来说是一种很糟糕的体验，所以一直很希望有一款产品，能够不掺杂陪伴之外的东西。</p>
<p>因此小番茄这款产品，就是希望让用户能够得到没有压力的陪伴感。</p>
<p>接下来的文章，将解释小番茄如何通过产品设计来尝试达到这个目的。</p>
<h2>小番茄的规则设计</h2>
<p>思考的原点，是希望用户能够没有困扰的，获得符合预期的体验，即：</p>
<ul>
<li>打开小番茄</li>
<li>找到一个符合在做的事情的自习室</li>
<li>碰到几位用户也在里面</li>
<li>做自己的事情互不干扰</li>
</ul>
<p>为了实现最后的「互不干扰」，我决定对用户的表达能力进行一些减法，去掉用户的发言能力，去掉用户名和自我介绍，只保留用户的头像。</p>
<p>当然用户的头像也可能造成一些困扰，所以也给了用户一个「我不希望看到这个人」的能力，可以屏蔽一些头像。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_6ae990ec0dde280ee1cdf361f01d3242.png" alt="13-Main-Block" /></figure></div><p>以此为基础，一个没有干扰能力的自习室就完成了。</p>
<h2>陪伴的真实感</h2>
<p>陪伴的真实感是产品很重要的一部分，我希望用户能够感受到其他用户的真实性，以及其他用户专注状态的变化。</p>
<p>因此 App 会通过计算手机的移动，来区分用户的「专注」与「分神」的状态，当用一段时间不碰手机，就可以进入专注状态，一些有趣的 Emoji 徽章动画也会在这个时候产生。</p>
<p>如果用户移动手机或者后台 App ，会自动变成「分神」状态，离开房间时头像也会相应的立即从房间里消失。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_1dfe39b6f8bf5e56768912421b29b3ef.png" alt="7-Main-Hint-Break" /></figure></div><h2>自习室的时间与统计</h2>
<p>用户进入自习室后，所看到的时钟进度，和其他用户是完全一致的。因此房间内的所有成员会有同样的「学习」和「休息」节奏。</p>
<p>「没有记录，就没有发生」最后一部分，就是用户在房间所花费的时间，会被自动统计。用户既可以查看自己的时间花费在什么地方，也可以查看其他用户的时间统计。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_74b2c684f7eab36ff306b2dfb44612aa.png" alt="15-User-Profile" /></figure></div><p>房间信息里，会统计全员一共在这里专注了多久。</p>
<div class="photo"><figure><img src="https://i.typlog.com/kevinzhow/z_beda9a6fc0a064bff29e2c66ac4c4c9f.png" alt="16-Room-Info" /></figure></div><h2>后记</h2>
<p>以上便是这款产品的设计思考，希望每位尝试的用户，都能得到纯粹的陪伴感。</p>
<p>祝你玩得开心。</p>
<h2>如何加入</h2>
<p>目前「小番茄」开启了 TestFlight 公测，你可以直接点击这里加入 <a href="https://testflight.apple.com/join/0mfa9aIG">https://testflight.apple.com/join/0mfa9aIG</a></p>
]]></content:encoded></item></channel></rss>