<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Zespia</title>
  
  
  <link href="https://zespia.me/atom.xml" rel="self"/>
  
  <link href="https://zespia.me/"/>
  <updated>2022-03-26T09:52:56.914Z</updated>
  <id>https://zespia.me/</id>
  
  <author>
    <name>Tommy Chen</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>tsc-multi – 把 TypeScript 檔案同時轉換成 CommonJS 和 ESM</title>
    <link href="https://zespia.me/blog/2021/05/08/tsc-multi/"/>
    <id>https://zespia.me/blog/2021/05/08/tsc-multi/</id>
    <published>2021-05-08T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.914Z</updated>
    
    <content type="html"><![CDATA[<p>在<a href="/blog/2021/03/21/nodejs-dual-package/" title="上一篇文章">上一篇文章</a>的最後，我提到了為了要把 TypeScript 檔案同時輸出成 CommonJS 和 ECMAScript modules (ESM)，所以開發了 <a href="https://github.com/tommy351/tsc-multi/">tsc-multi</a>。</p><p>tsc-multi 的運作方式其實非常簡單，就是同時執行多個 TypeScript compiler 平行運作而已；除此之外，我還對 compiler 動了一點手腳。</p><span id="more"></span><h2 id="修改-ts-System">修改 <code>ts.System</code></h2><p><code>ts.System</code> 是 TypeScript 用來和作業系統互動的 interface，其中包含了跟檔案系統 (file system) 有關的 method。因為 TypeScript 預設的輸出檔案的副檔名是 <code>.js</code> ，為了要變更輸出檔案的副檔名，我修改了 <code>ts.System</code> 裡跟讀寫檔案有關的 method。</p><pre><code class="language-ts">function rewritePath(path) &#123;  if (path.endsWith(&quot;.js&quot;) return path.replace(/\.js$/, &quot;.mjs&quot;);  return path;&#125;const sys: ts.System = &#123;  ...ts.sys,  fileExists(path) &#123;    return ts.sys.fileExists(rewritePath(path)) || ts.sys.fileExists(path);  &#125;,  readFile(path, encoding) &#123;    return ts.sys.readFile(rewritePath(path), encoding) ??      ts.sys.readFile(path, encoding);  &#125;,  writeFile(path, data, writeBOM) &#123;    ts.sys.writeFile(rewritePath(path), data, writeBOM);  &#125;,  deleteFile(path) &#123;    ts.sys.deleteFile(rewritePath(path));  &#125;&#125;;</code></pre><p>我修改了 <code>fileExists</code> , <code>readFile</code> , <code>writeFile</code> , <code>deleteFile</code> 這四個 method，上面是簡化過的版本，詳細內容可參考<a href="https://github.com/tommy351/tsc-multi/blob/v0.5.0/src/worker/worker.ts#L102">原始碼</a>。</p><h2 id="改寫-Import-路徑">改寫 Import 路徑</h2><p>因為輸出檔案的副檔名被改寫了，為了讓 CommonJS 和 ESM 能夠 import 到正確的檔案，必須在 import 路徑加上副檔名。</p><p>這個部份我用 transformer 的形式來實作，在 transformer 內，可以把 TypeScript AST 替換成任意的程式碼。以這次的案例來說，我們需要替換的 node 有以下四種。</p><pre><code class="language-ts">// ESM import (ImportDeclaration)import foo from &quot;./foo&quot;;// ESM export (ExportDeclaration)export foo from &quot;./foo&quot;;// ESM dynamic import (CallExpression)import(&quot;./foo&quot;);// CommonJS require (CallExpression)require(&quot;./foo&quot;);</code></pre><p>從上面這四種 node 可以取得 import 路徑，如果是相對路徑的話（開頭是 <code>./</code> 或 <code>../</code>），就是需要修改的路徑。</p><p>在 Node.js 裡，import 路徑可能會是檔案或資料夾，但是 ESM 的 import 路徑<a href="https://nodejs.org/dist/latest-v14.x/docs/api/esm.html#esm_mandatory_file_extensions">一定要加上副檔名</a>，所以必須要把資料夾的 import 路徑加上 <code>/index.js</code> 。</p><pre><code class="language-ts">// Inputimport &quot;./file&quot;;import &quot;./dir&quot;;// Outputimport &quot;./file.js&quot;;import &quot;./dir/index.js&quot;;</code></pre><p>總結來說，可以把修改 import 路徑的部分統整成以下程式碼。</p><pre><code class="language-ts">function updateModuleSpecifier(sourceFile: ts.SourceFile, node: ts.Expression) &#123;  if (!ts.isStringLiteral(node) || !isRelativePath(node.text)) return node;  if (isDirectory(sourceFile, node.text)) &#123;    return ts.factory.createStringLiteral(      `$&#123;node.text&#125;/index$&#123;options.extname&#125;`    );  &#125;  const ext = extname(node.text);  const base = ext === &quot;.js&quot; ? trimSuffix(node.text, &quot;.js&quot;) : node.text;  return ts.factory.createStringLiteral(`$&#123;base&#125;$&#123;options.extname&#125;`);&#125;</code></pre><p>詳細內容可參考<a href="https://github.com/tommy351/tsc-multi/blob/v0.5.0/src/transformers/rewriteImport.ts">原始碼</a>。</p><h2 id="避免-Race-Condition">避免 Race Condition</h2><p>一開始 tsc-multi 在小規模的專案（例如 <a href="https://kosko.dev/">Kosko</a> 和 <a href="https://github.com/tommy351/kubernetes-models-ts">kubernetes-models</a>）使用時，都沒有任何異常。可是一旦在 Dcard 這種大規模的 monorepo 使用時，就很容易發生問題。</p><p>主要原因是 TypeScript 在使用 <a href="https://www.typescriptlang.org/docs/handbook/project-references.html">Project References</a> 功能時，為了要加快未來編譯的速度，會寫入 <code>.tsbuildinfo</code> 檔案，內容包含了目前的 build state，檔案大小大約會是幾百 KB。</p><p>因為 tsc-multi 會同時執行多個 TypeScript compiler，在寫入 TS build info 時，其他 compiler 可能就會剛好讀取寫入到一半的檔案，這種問題在一般的電腦上通常不會發生，但是在 CI 等資源有限的環境下偶爾會觸發。</p><p>我的解決方法是變更 <code>tsconfig.json</code> 的 <code>tsBuildInfoFile</code> 設定，讓 TypeScript compiler 不會同時寫入到同一個路徑。</p><pre><code class="language-ts">const host = ts.createSolutionBuilderHost();host.getParsedCommandLine = (path: string) =&gt; &#123;  const config = ts.getParsedCommandLineOfConfigFile(path, &#123;&#125;, ts.sys);  config.options.tsBuildInfoFile = `$&#123;basePath&#125;$&#123;data.extname&#125;.tsbuildinfo`;  return config;&#125;;</code></pre><p>詳細內容可參考<a href="https://github.com/tommy351/tsc-multi/blob/v0.5.0/src/worker/worker.ts#L223">原始碼</a>。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;在&lt;a href=&quot;/blog/2021/03/21/nodejs-dual-package/&quot; title=&quot;上一篇文章&quot;&gt;上一篇文章&lt;/a&gt;的最後，我提到了為了要把 TypeScript 檔案同時輸出成 CommonJS 和 ECMAScript modules (ESM)，所以開發了 &lt;a href=&quot;https://github.com/tommy351/tsc-multi/&quot;&gt;tsc-multi&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;tsc-multi 的運作方式其實非常簡單，就是同時執行多個 TypeScript compiler 平行運作而已；除此之外，我還對 compiler 動了一點手腳。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Node.js" scheme="https://zespia.me/tags/Node-js/"/>
    
    <category term="TypeScript" scheme="https://zespia.me/tags/TypeScript/"/>
    
  </entry>
  
  <entry>
    <title>讓 Node.js Package 同時支援 CommonJS 和 ESM</title>
    <link href="https://zespia.me/blog/2021/03/21/nodejs-dual-package/"/>
    <id>https://zespia.me/blog/2021/03/21/nodejs-dual-package/</id>
    <published>2021-03-21T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.913Z</updated>
    
    <content type="html"><![CDATA[<p>最近為了讓 <a href="https://kosko.dev/">Kosko</a> 和 <a href="https://github.com/tommy351/kubernetes-models-ts">kubernetes-models</a> 能夠支援瀏覽器或是 Deno，所以先做了一些前期準備，首先最重要的就是支援 <a href="https://nodejs.org/dist/latest-v14.x/docs/api/esm.html#esm_introduction">ECMAScript Module (ESM)</a>，因為這是目前所有平台都能支援的標準，但是為了要保持 Node.js 的相容性，所以暫時還是不能放下 CommonJS。</p><p>這篇文章會介紹如何讓 Node.js package 能夠同時支援 CommonJS 和 ESM，以及使用 ESM 時的注意事項。</p><span id="more"></span><h2 id="先講結論">先講結論</h2><p>以最低支援版本來區分。</p><p>Node.js 10 以上：</p><ul><li>CommonJS 輸出成 <code>.js</code> 副檔名</li><li>ESM 輸出成 <code>.mjs</code> 副檔名</li><li><code>package.json</code> 加上 <code>module</code></li></ul><p>Node.js 12 以上：</p><ul><li>CommonJS 輸出成 <code>.cjs</code> 副檔名</li><li>ESM 輸出成 <code>.mjs</code> 副檔名</li><li><code>package.json</code> 加上 <code>module</code> 和 <code>exports</code>，<code>type</code> 可設定為 <code>module</code></li></ul><h2 id="副檔名">副檔名</h2><p>正常來說，比較建議的做法是 CommonJS 一律採用 <code>.cjs</code> 副檔名，ESM 一律採用 <code>.mjs</code> 副檔名，這樣就能避免 Node.js 用 <code>package.json</code> 的 <code>type</code> 來判斷，但是在以下情況下會出問題。</p><h3 id="Node-js-10">Node.js 10</h3><p>如果你需要 require package 裡的路徑的話，在 Node.js 10 可能就會出問題。</p><p>舉例來說，當 require package 的時候，Node.js 會根據 <code>package.json</code> 裡設定的 <code>main</code> 來決定路徑，所以不管副檔名是什麼都無所謂，只要內容是 CommonJS 就好。</p><pre><code class="language-js">// 假設 package.json 的內容是 &#123;&quot;main&quot;: &quot;index.cjs&quot;&#125; 的話require('example');// -&gt; node_modules/example/index.cjs</code></pre><p>但如果是 require package 裡的路徑的話，就不會去參考 <code>package.json</code> 的設定了，如果 require 時沒有加上副檔名的話，就會根據 <code>require.extensions</code> 來尋找對應檔案。</p><pre><code class="language-js">// 預設只支援 .js, .json, .noderequire('example/foo');// -&gt; node_modules/example/foo.js// -&gt; node_modules/example/foo.json// -&gt; node_modules/example/foo.node</code></pre><p>如果把 CommonJS 檔案都一律改成 <code>.cjs</code> 副檔名的話，就會找不到對應檔案。</p><p>其中一種解決方法是在路徑後加上副檔名，但這樣就需要改寫現有的 require。</p><pre><code class="language-js">require('example/foo.cjs');// -&gt; node_modules/example/foo.cjs</code></pre><p>另一種方法則是升級到 Node.js 12 以上，從 12.7.0 開始支援 <a href="https://nodejs.org/dist/latest-v14.x/docs/api/packages.html#packages_conditional_exports">export map</a>，從 12.16.0 開始不用加 <a href="https://nodejs.org/docs/v12.7.0/api/cli.html#cli_experimental_exports"><code>--experimental-exports</code></a>。如果 <code>package.json</code> 裡有指定 <code>exports</code> 的話，Node.js 就會改用 export map 來決定路徑。</p><pre><code class="language-json">&#123;  &quot;exports&quot;: &#123;    &quot;.&quot;: &#123;      &quot;import&quot;: &quot;./index.mjs&quot;,      &quot;require&quot;: &quot;./index.cjs&quot;    &#125;,    &quot;./foo&quot;: &#123;      &quot;import&quot;: &quot;./foo.mjs&quot;,      &quot;require&quot;: &quot;./foo.cjs&quot;    &#125;  &#125;&#125;</code></pre><pre><code class="language-js">require('example/foo');// -&gt; node_modules/example/foo.cjs</code></pre><h3 id="Jest">Jest</h3><p>Jest 為了要實作 mock 機制，所以有自己一套 module resolve 和 import 的機制，在 import 外部 package 路徑的情況下，似乎不會使用 <code>moduleFileExtensions</code> 設定，而是使用 <code>.js</code> 副檔名，我用過的其中一種解決方法是設定 <code>moduleNameMapper</code>，手動在 import 路徑後加上 <code>.cjs</code> 副檔名。</p><pre><code class="language-json">&#123;  &quot;moduleNameMapper&quot;: &#123;    &quot;^example/(.+)$&quot;: &quot;example/$1.cjs&quot;  &#125;&#125;</code></pre><p>Jest 的 ESM 支援還在實驗階段，如果需要執行 ESM 檔案的話需要加上 <code>NODE_OPTIONS=--experimental-vm-modules</code>，目前建議還是使用 CommonJS，並使用 <code>.js</code> 副檔名。</p><h2 id="Import">Import</h2><p>如果要同時 import CommonJS 和 ESM package 的話，唯一的方法就是使用 <code>import</code>，舊有的 <code>require</code> 只支援 CommonJS，<code>import</code> 和 <code>require</code> 相比有很多不同的地方，細節可以參考<a href="https://nodejs.org/dist/latest-v14.x/docs/api/esm.html#esm_differences_between_es_modules_and_commonjs">官方文件</a>，本文只會說明一些我覺得重要的部分。</p><h3 id="檔案路徑一定要加副檔名">檔案路徑一定要加副檔名</h3><p>Import 檔案路徑時，一定要加上副檔名，<code>import</code> 不會根據 <code>require.extensions</code> 來判斷支援哪些副檔名。此外，也不能直接 <code>import</code> 資料夾，必須加上 <code>/index.js</code>。</p><pre><code class="language-js">require('./path')import './path.js';require('./dir');import './dir/index.js';</code></pre><h3 id="不支援-filename-和-dirname">不支援 <code>__filename</code> 和 <code>__dirname</code></h3><p><a href="https://nodejs.org/dist/latest-v14.x/docs/api/modules.html#modules_filename"><code>__filename</code></a> 和 <a href="https://nodejs.org/dist/latest-v14.x/docs/api/modules.html#modules_dirname"><code>__dirname</code></a> 這兩個變數只有 CommonJS 才支援，在 ESM 裡必須改用標準的 <a href="https://nodejs.org/dist/latest-v14.x/docs/api/esm.html#esm_import_meta_url"><code>import.meta.url</code></a>，兩者的內容會有一點點不一樣，需要透過 <code>url</code> package 裡的 <a href="https://nodejs.org/dist/latest-v14.x/docs/api/url.html#url_url_fileurltopath_url"><code>fileURLToPath</code></a> 和 <a href="https://nodejs.org/dist/latest-v14.x/docs/api/url.html#url_url_pathtofileurl_path"><code>pathToFileURL</code></a> 來轉換。</p><pre><code class="language-js">__filename// /workspace/test.js__dirname// /workspaceimport.meta.url// file:///workspace/test.jsfileURLToPath(import.meta.url)// /workspace/test.jsnew URL('.', import.meta.url);// file:///workspace/</code></pre><h3 id="Dynamic-Import">Dynamic Import</h3><p>在 CommonJS 裡，到處都可以直接 <code>require</code>；但是在 ESM 裡，只有最外層可以用 <code>import</code>，其他地方只能使用 async 的 <code>import()</code>，有些地方可能會因此而必須改成 async function。</p><h3 id="檢測現有環境是否支援-ESM">檢測現有環境是否支援 ESM</h3><p>除了檢查 Node.js 版本以外，另一個檢測方法就是利用 <code>import</code> 支援 <code>data:</code> protocol 的特性，來檢查現有環境是否支援 ESM，這是從 <a href="https://github.com/avajs/ava/blob/v3.15.0/lib/worker/subprocess.js#L11">ava</a> 參考來的。</p><pre><code class="language-js">const supportsESM = async () =&gt; &#123;  try &#123;    await import('data:text/javascript,');    return true;  &#125; catch &#123;&#125;  return false;&#125;;</code></pre><p>需要注意的是，使用 TypeScript 時，如果設定為 CommonJS module 的話，<code>import</code> 會被轉為 <code>require</code>，所以建議改為 ESNext module 或改用 JavaScript。</p><h2 id="編譯-TypeScript">編譯 TypeScript</h2><p>目前有幾種方法可以把 TypeScript 編譯成 CommonJS 和 ESM 檔案。</p><h3 id="跑兩次-tsc">跑兩次 tsc</h3><p>這應該是最簡單的方法，只要把原本的 <code>tsc</code> 指令切成兩個然後同時執行就好了。</p><pre><code class="language-bash">tsc -m commonjstsc -m esnext</code></pre><h3 id="用-Babel-把-ESM-轉成-CommonJS">用 Babel 把 ESM 轉成 CommonJS</h3><p>讓 tsc 輸出 ESM，然後再用 Babel 產生 CommonJS 檔案。</p><pre><code class="language-json">&#123;  &quot;plugins&quot;: [&quot;@babel/plugin-transform-modules-commonjs&quot;]&#125;</code></pre><h3 id="tsc-multi">tsc-multi</h3><p>以上這兩種方法需要額外寫 script，如果要支援 monorepo 的話就更加痛苦了，所以我花了一點時間把工作時用的 build tool 重新改寫成 <a href="https://github.com/tommy351/tsc-multi/">tsc-multi</a>，之後會在下一篇文章介紹，用法大概會像是這樣。</p><pre><code class="language-json">&#123;  &quot;targets&quot;: [    &#123; &quot;extname&quot;: &quot;.mjs&quot;, &quot;module&quot;: &quot;esnext&quot; &#125;,    &#123; &quot;extname&quot;: &quot;.js&quot;, &quot;module&quot;: &quot;commonjs&quot; &#125;  ],  &quot;projects&quot;: [&quot;packages/*/tsconfig.json&quot;]&#125;</code></pre>]]></content>
    
    
    <summary type="html">&lt;p&gt;最近為了讓 &lt;a href=&quot;https://kosko.dev/&quot;&gt;Kosko&lt;/a&gt; 和 &lt;a href=&quot;https://github.com/tommy351/kubernetes-models-ts&quot;&gt;kubernetes-models&lt;/a&gt; 能夠支援瀏覽器或是 Deno，所以先做了一些前期準備，首先最重要的就是支援 &lt;a href=&quot;https://nodejs.org/dist/latest-v14.x/docs/api/esm.html#esm_introduction&quot;&gt;ECMAScript Module (ESM)&lt;/a&gt;，因為這是目前所有平台都能支援的標準，但是為了要保持 Node.js 的相容性，所以暫時還是不能放下 CommonJS。&lt;/p&gt;
&lt;p&gt;這篇文章會介紹如何讓 Node.js package 能夠同時支援 CommonJS 和 ESM，以及使用 ESM 時的注意事項。&lt;/p&gt;</summary>
    
    
    
    
    <category term="JavaScript" scheme="https://zespia.me/tags/JavaScript/"/>
    
    <category term="Node.js" scheme="https://zespia.me/tags/Node-js/"/>
    
  </entry>
  
  <entry>
    <title>Kosko 1.0 發佈</title>
    <link href="https://zespia.me/blog/2020/11/22/kosko-1.0-released/"/>
    <id>https://zespia.me/blog/2020/11/22/kosko-1.0-released/</id>
    <published>2020-11-22T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.913Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p>這篇文章是官網上 <a href="https://kosko.dev/blog/2020/11/22/kosko-1.0-released">Kosko 1.0 Released</a> 的中文翻譯版。關於 Kosko 本身，除了<a href="https://kosko.dev">官網</a>，也可以參考<a href="/blog/2019/03/02/kosko-kubernetes-in-javascript/" title="這篇文章">這篇文章</a>。</p></blockquote><p>自從上一個穩定版本 v0.9 已經過了好一段時間了。最近我決定開始實作工作上一直都想用的一些功能，希望這些功能也能幫助到你。</p><span id="more"></span><h2 id="Nested-Manifests">Nested Manifests</h2><p>從 v1.0 開始，component 內的 array 和 function 會被展開，這功能對於在不同 component 之間共用 manifest 會很有用。</p><p>舉例來說，通常在 Kubernetes 裡，資料庫會由一個 <code>Deployment</code> 和一個 <code>Service</code> 組成。如果要在 component 裡使用資料庫的話，在 v1.0 之前，必須要自行展開這兩個 manifest；在 v1.0 之後，就能自動展開了。</p><p>這樣的話，就可以把資料庫當成單一資源，在 component 到處使用了。</p><pre><code class="language-js">function createDatabase() &#123;  return [new Deployment(), new Service()];&#125;// v1.0 之前module.exports = [new Deployment(), ...createDatabase()];// v1.0 之後module.exports = [new Deployment(), createDatabase()];</code></pre><h2 id="ValidationError-包含更詳細的資訊"><code>ValidationError</code> 包含更詳細的資訊</h2><p>在 v1.0 之前，<code>ValidationError</code> 只包含 <code>path</code> 和 <code>index</code>，有時可能會難以定位問題；在 v1.0 之後，<code>ValidationError</code> 加上了 <code>apiVersion</code>、<code>kind</code>、<code>namespace</code> 和 <code>name</code>。以下是新的錯誤訊息的範例。</p><pre><code class="language-bash">ValidationError: data.metadata.annotations['dependencies'] should be string- path: &quot;.../components/config-api&quot;- index: [0]- kind: &quot;apps/v1/Deployment&quot;- name: &quot;config-api&quot;    at resolveComponent (.../node_modules/@kosko/generate/src/generate.ts:81:15)    at resolveComponent (.../node_modules/@kosko/generate/src/generate.ts:59:28)    at Object.generate (.../node_modules/@kosko/generate/src/generate.ts:134:30)    at generateHandler (.../node_modules/@kosko/cli/src/commands/generate/index.ts:156:18)    at handler (.../node_modules/@kosko/cli/src/commands/generate/index.ts:200:20)    at Object.run (.../node_modules/@kosko/cli/src/index.ts:12:3)</code></pre><h2 id="載入-Kubernetes-YAML">載入 Kubernetes YAML</h2><p>如果你過去已經使用 Kubernetes 一陣子的話，可能會像我一樣有一堆 Kubernetes 的 YAML 檔。現在你不需要把這些 YAML 改寫成 JavaScript 了，可以試用看看新的 package <code>@kosko/yaml</code>。</p><p><code>@kosko/yaml</code> 會讀取 YAML 檔案，並建立對應的 <a href="https://github.com/tommy351/kubernetes-models-ts">kubernetes-models</a> class，所以你的 manifest 就能使用 Kubernetes OpenAPI schema 來驗證。</p><pre><code class="language-js">const &#123; loadFile &#125; = require(&quot;@kosko/yaml&quot;);module.exports = loadFile(&quot;manifest.yaml&quot;);</code></pre><p>這個功能有 “nested manifests” 的支援才會更完善，所以別忘了先更新到 Kosko v1.0 喔。</p><p>更多資訊請參考<a href="https://kosko.dev/docs/loading-kubernetes-yaml">文件</a>。</p><h2 id="Breaking-Changes">Breaking Changes</h2><ul><li>放棄支援 Node.js 8。</li><li><code>@kosko/generate</code><ul><li><code>Manifest.index</code> 和 <code>ValidationError.index</code> 的型別由 <code>number</code> 改為 <code>number[]</code>。</li><li><code>Manifest.data</code> 的型別由 <code>any</code> 改為 <code>unknown</code>。</li></ul></li></ul><h2 id="後記">後記</h2><p>這段只會寫在這邊，不會放在原文上。</p><p>我在實作完上述的功能之後，又花了一點時間稍微改了下文件，原本有些只放在 <code>examples</code> 資料夾裡的東西，現在也放到文件裡了，所以整體來說應該會變得更易讀。我之後應該會再花一點時間來研究如何基於 <code>@kosko/yaml</code> 來實作 Helm 的支援。</p>]]></content>
    
    
    <summary type="html">&lt;blockquote&gt;
&lt;p&gt;這篇文章是官網上 &lt;a href=&quot;https://kosko.dev/blog/2020/11/22/kosko-1.0-released&quot;&gt;Kosko 1.0 Released&lt;/a&gt; 的中文翻譯版。關於 Kosko 本身，除了&lt;a href=&quot;https://kosko.dev&quot;&gt;官網&lt;/a&gt;，也可以參考&lt;a href=&quot;/blog/2019/03/02/kosko-kubernetes-in-javascript/&quot; title=&quot;這篇文章&quot;&gt;這篇文章&lt;/a&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;自從上一個穩定版本 v0.9 已經過了好一段時間了。最近我決定開始實作工作上一直都想用的一些功能，希望這些功能也能幫助到你。&lt;/p&gt;</summary>
    
    
    
    
    <category term="JavaScript" scheme="https://zespia.me/tags/JavaScript/"/>
    
    <category term="Kubernetes" scheme="https://zespia.me/tags/Kubernetes/"/>
    
  </entry>
  
  <entry>
    <title>Flutter 的 Isolate 通訊</title>
    <link href="https://zespia.me/blog/2020/11/07/flutter-isolate-communication/"/>
    <id>https://zespia.me/blog/2020/11/07/flutter-isolate-communication/</id>
    <published>2020-11-07T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.911Z</updated>
    
    <content type="html"><![CDATA[<p>九月時 <a href="/blog/2020/06/29/eh-redux-flutter/" title="EH Redux">EH Redux</a> 0.6 終於發布了，這個版本最主要的改進就是下載功能，之所以 0.5 和 0.6 之間隔了這麼久，其實是因為我花了一些時間重寫了幾乎全部的程式碼，前景（foreground）和背景（background）之間的資料同步也讓我卡關了很久。</p><span id="more"></span><h2 id="Moor">Moor</h2><img src="/blog/2020/11/07/flutter-isolate-communication/foreground-only.svg" class=""><p>我目前使用 Moor 做為 ORM，這個 library 的預設使用方式是在前景連接資料庫，在大多數情況下不會有效能問題，是最簡單的使用方式。</p><pre><code class="language-dart">LazyDatabase _openConnection() &#123;  return LazyDatabase(() async &#123;    final dir = await getApplicationDocumentsDirectory();    final file = File(join(dir.path, 'db.sqlite'));    return VmDatabase(file);  &#125;);&#125;</code></pre><img src="/blog/2020/11/07/flutter-isolate-communication/fg-and-bg.svg" class=""><p>一開始實作下載功能時，我是在前景和背景分別連接資料庫，雖然兩方都可以正常讀取和寫入資料，但是無法監聽資料變動，而且如果同時寫入同一筆資料的話，可能會引發 lock。</p><p>以我的例子來說，當背景正在下載時，雖然可以正常把進度寫入到資料庫，但是前景不會觸發更新；當前景暫停或取消下載時，如果背景作業也剛好正在寫入下載進度的話，則會造成死鎖。</p><p>關於這個問題，在 GitHub 上有相關的 <a href="https://github.com/simolus3/moor/issues/637">issue</a> 討論，結論是，如果前景和背景同時執行 migration 的話，可能會造成資料不一致。如果背景確定不會執行 migration，且背景不會干涉前景的話，那就無須特別處理，前景和背景各自連接資料庫即可，否則需要利用 <code>SendPort</code> / <code>ReceivePort</code> 讓前景和背景共用同一個 <code>MoorIsolate</code>。</p><h2 id="Isolate-之間的通訊">Isolate 之間的通訊</h2><p>在 Dart 裡面，所有的程式都在 <a href="https://api.flutter.dev/flutter/dart-isolate/Isolate-class.html"><code>Isolate</code></a> 裡執行，不同的 <code>Isolate</code> 之間如果要通訊的話，就要透過 <code>ReceivePort</code>/<code>SendPort</code> 來傳送和接收訊息。除此之外，Dart 還有另一個 <a href="https://api.flutter.dev/flutter/dart-ui/IsolateNameServer-class.html"><code>IsolateNameServer</code></a> class，用來註冊 global 的 <code>SendPort</code>。</p><p>舉例來說，假設有兩個 isolate 要通訊，其中一個是接收端，另一個則是發送端。</p><p>首先接收端要先建立一個 <code>ReceivePort</code>，然後在 <code>IsolateNameServer</code> 註冊 <code>ReceivePort.sendPort</code>。這裡要注意的是，如果 port 已經被註冊的話，必須要先移除原本註冊的 port，否則新註冊的 port 不會覆蓋掉原本舊的 port。</p><pre><code class="language-dart">final receivePort = ReceivePort();receivePort.listen((msg) &#123;  // Message received&#125;);// 如果要覆蓋的話，必須要先移除原本註冊的 port// IsolateNameServer.removePortNameMapping('example');IsolateNameServer.registerPortWithName(receivePort.sendPort, 'example');</code></pre><p>註冊完成後，發送端就可以用指定的名稱來搜尋已註冊的 port。</p><pre><code class="language-dart">final port = IsolateNameServer.lookupPortByName('example');port?.send('ping');</code></pre><h2 id="改用-Isolate-連接資料庫">改用 Isolate 連接資料庫</h2><img src="/blog/2020/11/07/flutter-isolate-communication/fg-and-bg-via-isolate.svg" class=""><p>首先，必須讓 Moor 產生 Isolate 相關的程式碼，在專案根目錄的 <code>build.yaml</code> 新增以下內容後，重跑 <code>flutter pub run build_runner build</code> 即可。</p><pre><code class="language-yaml">targets:  $default:    builders:      moor_generator:        options:          generate_connect_constructor: true</code></pre><p>接著要改寫 Database class。</p><pre><code class="language-dart">// 這個 class 用來包裝要傳到 isolate 的資料class _Request &#123;  _Request(this.sendPort, this.targetPath);  final SendPort sendPort;  final String targetPath;&#125;void _startBackground(_Request request) &#123;  // 建立新的 VmDatabase  final executor = VmDatabase(File(request.targetPath));  // 因為目前的函數已經在背景 isolate 執行了，所以這邊直接讓 Moor 在目前的 isolate 啟動  final moorIsolate = MoorIsolate.inCurrent(    () =&gt; DatabaseConnection.fromExecutor(executor),  );  // 把 moorIsolate 回傳給 sendPort  request.sendPort.send(moorIsolate);&#125;Future&lt;MoorIsolate&gt; _createMoorIsolate() async &#123;  // 資料庫檔案的路徑  final dir = await getApplicationDocumentsDirectory();  final path = join(dir.path, 'db.sqlite');  // 建立新的 ReceivePort  final receivePort = ReceivePort();  // 在新的 isolate 裡執行 _startBackground  await Isolate.spawn(    _startBackground,    _Request(receivePort.sendPort, path),  );  // 等待 receivePort 回傳的 MoorIsolate  return await receivePort.first as MoorIsolate;&#125;@UseMoor()class Database extends _$Database &#123;  // 這個新的 factory 函數用來從 DatabaseConnection 產生 Database instance  Database.connect(DatabaseConnection connection) : super.connect(connection);&#125;Future&lt;void&gt; main() async &#123;  final isolate = await _createMoorIsolate();  final db = Database.connect(await isolate.connect());  // 現在可以照常使用 db 了&#125;</code></pre><h2 id="共用資料庫連接">共用資料庫連接</h2><img src="/blog/2020/11/07/flutter-isolate-communication/moor-isolate-flow.svg" class=""><p>為了要讓背景能夠共用前景的資料庫連接，我在前景資料庫連接成功後，註冊一個 <code>ReceivePort</code> 用來傳送 <code>MoorIsolate.connectPort</code>。</p><pre><code class="language-dart">const _requestPortName = 'database.request';const _instancePortName = 'database.instance';void shareIsolate(MoorIsolate isolate) &#123;  // 建立一個 ReceivePort  final requestPort = ReceivePort();  // 監聽 requestPort 的事件，當接收到事件時，把 connectPort 回傳給 instancePort  requestPort.listen((message) &#123;    final instancePort =        IsolateNameServer.lookupPortByName(_instancePortName);    instancePort?.send(isolate.connectPort);  &#125;);  // 移除先前註冊的 requestPort  IsolateNameServer.removePortNameMapping(_requestPortName);  // 註冊 requestPort  IsolateNameServer.registerPortWithName(      requestPort.sendPort, _requestPortName);&#125;</code></pre><p>背景方面則是先去尋找前景註冊的 port，如果有的話就對該 port 發送事件並等待回傳的 <code>connectPort</code>，否則就建立一個新的 <code>MoorIsolate</code>。</p><pre><code class="language-dart">Future&lt;MoorIsolate&gt; reuseIsolate() async &#123;  // 尋找已註冊的 requestPort  final requestPort =      IsolateNameServer.lookupPortByName(_requestPortName);  if (requestPort == null) return null;  // 建立一個 ReceivePort 用來接收 connectPort  final instancePort = ReceivePort();  try &#123;    // 註冊 instancePort    IsolateNameServer.registerPortWithName(        instancePort.sendPort, _instancePortName);    // 對 requestPort 發送事件    requestPort.send(null);    // 等待回傳的 connectPort    final connectPort = await instancePort.first as SendPort;    // 利用剛剛回傳的 connectPort 建立 MoorIsolate    return MoorIsolate.fromConnectPort(connectPort);  &#125; finally &#123;    // 最後，移除並關閉 instancePort    IsolateNameServer.removePortNameMapping(_instancePortName);    instancePort.close();  &#125;&#125;</code></pre>]]></content>
    
    
    <summary type="html">&lt;p&gt;九月時 &lt;a href=&quot;/blog/2020/06/29/eh-redux-flutter/&quot; title=&quot;EH Redux&quot;&gt;EH Redux&lt;/a&gt; 0.6 終於發布了，這個版本最主要的改進就是下載功能，之所以 0.5 和 0.6 之間隔了這麼久，其實是因為我花了一些時間重寫了幾乎全部的程式碼，前景（foreground）和背景（background）之間的資料同步也讓我卡關了很久。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Flutter" scheme="https://zespia.me/tags/Flutter/"/>
    
    <category term="Dart" scheme="https://zespia.me/tags/Dart/"/>
    
  </entry>
  
  <entry>
    <title>減少 Go 的 allocation 改善 rdb-go 的效能</title>
    <link href="https://zespia.me/blog/2020/08/11/reduce-golang-allocation/"/>
    <id>https://zespia.me/blog/2020/08/11/reduce-golang-allocation/</id>
    <published>2020-08-11T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.900Z</updated>
    
    <content type="html"><![CDATA[<p>上個月因為工作需要掃描 Redis RDB 檔案，所以用 Go 自幹了一個 parser。雖然已經有各種現成的 library，其中以 Python 實作的 <a href="https://github.com/sripathikrishnan/redis-rdb-tools">redis-rdb-tools</a> 為主，<a href="https://github.com/sripathikrishnan/redis-rdb-tools/wiki/FAQs#i-dont-like-python-is-such-a-parser-available-in-language-x">其他 library</a> 大都以 <a href="https://github.com/sripathikrishnan/redis-rdb-tools">redis-rdb-tools</a> 的邏輯來實作，文件中 Go 的連結已失效，然而我的 codebase 以 Go 為主，所以我決定自己用 Go 實作一個 RDB parser。</p><span id="more"></span><p>RDB parser 本身其實不會太複雜，<a href="https://github.com/sripathikrishnan/redis-rdb-tools">redis-rdb-tools</a> 的作者很貼心地提供了<a href="https://github.com/sripathikrishnan/redis-rdb-tools/wiki/Redis-RDB-Dump-File-Format">詳細的文件</a>說明 RDB 的格式。在 RDB 裡，每個 key 大概會長得像這樣：</p><pre><code class="language-plain">FD/FC $ttl$value-type$string-encoded-key$encoded-value</code></pre><p>整個 RDB 檔案除了開頭和結尾的一些 metadata 以外，大致上都是由這樣的 key 組成的，所以讀取起來很輕鬆，所有值前面都會標明長度，看到特定的 byte 就停下來，我大概花一週左右把初版的 RDB parser 寫完，API 長得像這樣：</p><pre><code class="language-go">parser := NewParser(file)for &#123;  data, err := parser.Next()  if err == io.EOF &#123;    break  &#125;  if err != nil &#123;    panic(err)  &#125;  // do something&#125;</code></pre><p>就是一個很典型的 iterator 的形狀，這樣就不需要等到整個檔案都 parse 完才回傳結果。</p><h2 id="初次測試">初次測試</h2><p>測試時拿一些小型的 RDB 檔案（小於 1 MB）來 parse 的話大概都沒什麼問題，但實際上正式環境是由 16 個大約 1.7 GB 的 RDB 組成的，初版的 parser 大約需要花一分鐘左右才能 parse 完一個檔案，如果要每個 RDB 都 parse 的話就需要大約 15 分鐘。</p><p>雖然說實際上這個 parser 每天只會在半夜跑一次，就算讓它放著跑也無所謂，但我還是很好奇究竟為何這麼耗時，單檔 1.7 GB 照理來說應該不需要花這麼多時間來 parse。</p><h2 id="Key-Filtering">Key Filtering</h2><p>我一開始以為原因是因為每個 key 都 parse 的話會很花時間，如果我只需要其中 20% 的資訊，卻花了其他沒必要的功夫來處理其他 key 的話，很顯然會浪費很多時間，所以我做了一個 key 的過濾機制，API 大概像這樣：</p><pre><code class="language-go">parser := NewParser(file)parser.KeyFilter(func(key string) bool &#123;  return key == &quot;example&quot;&#125;)</code></pre><p>使用者可以自訂 <code>KeyFilter</code> 函數，如果 return <code>false</code> 的話就會直接跳過那個 key 長度的 bytes。</p><p>我原本以為這樣就能解決效能問題了，但事情卻不如我想像，即便 <code>KeyFilter</code> 永遠 return <code>false</code>，也就是 filter 所有 key，速度還是沒差多少，這令我更好奇背後的原因了。</p><h2 id="重複利用-byte">重複利用 <code>[]byte</code></h2><p>就在這時我看到了 Dave Cheney 寫的 <a href="https://dave.cheney.net/high-performance-json.html">Building a high performance JSON parser</a>，這篇文章描述了如何從頭開始做一個高效能的 JSON parser，讓我收穫最多的就是關於<a href="https://dave.cheney.net/high-performance-json.html#_reading">讀取</a>的這段。</p><p>我在這邊大概介紹一下概念，詳細可以參考那篇文章或是 <a href="https://github.com/pkg/json">github.com/pkg/json</a>。</p><p>Go 的 <a href="https://golang.org/pkg/io/#Reader"><code>io.Reader</code></a> interface 長得像這樣。</p><pre><code class="language-go">type Reader interface &#123;  Read(p []byte) (n int, err error)&#125;</code></pre><p>使用方式很簡單，給 <code>Read</code> 方法一個 <code>[]byte</code>，Reader 就會讀取資料並把資料塞到 <code>[]byte</code> 裡，並回傳它塞了多少個 byte 進去。</p><p>初版 RDB parser 的寫法很無腦，就是需要多少長度我就分配多少長度的 <code>[]byte</code>。</p><pre><code class="language-go">buf := make([]byte, 1)n, err := reader.Read(buf)</code></pre><p>這種寫法一般來說沒什麼問題，只是比較沒有效率，需要頻繁的 allocate。例如說 RDB 裡長度多半會是 1~4 bytes，長度在每個 key 或是各種 data type 裡都會出現，那麼我就必須每次都 allocate 這種長度非常小的 <code>[]byte</code>。</p><p>文章裡提到的解決方法就是一次分配一塊 buffer，一次讀取更多資料，然後自己維護 buffer 裡資料的 offset 和 length，視情況需要擴張 buffer 的長度，這樣就不需要每次都 allocate 新的 <code>[]byte</code>，只有在 <code>[]byte</code> 擴張或是 <code>[]byte</code> 轉 <code>string</code> 的時候才會 allocate。</p><h2 id="實作-Buffer">實作 Buffer</h2><p>首先先把 <code>[]byte</code> 切成三個區塊，從 0 到 Offset 之間是已經消化完的資料，從 Offset 到 Length 是已經從 <code>io.Reader</code> 讀取但尚未使用的資料，最後從 Length 到 Capacity 則是 <code>[]byte</code> 的剩餘空間。</p><img src="/blog/2020/08/11/reduce-golang-allocation/buffer-01.svg" class=""><p>每次從這塊 buffer 讀取資料時，都會把 Offset 往右推進，如果 Offset 超過 Length 的話，則會從 <code>io.Reader</code> 讀取新的資料，這時會有四種狀況。</p><p>如果資料還能夠塞得進剩餘空間，那就會直接從 <code>io.Reader</code> 讀取資料，並更新 Length。</p><img src="/blog/2020/08/11/reduce-golang-allocation/buffer-02.svg" class=""><p>如果資料塞不下剩餘空間了，但小於 Capacity 的話，就會把 Offset 歸 0，然後讀取資料。</p><img src="/blog/2020/08/11/reduce-golang-allocation/buffer-03.svg" class=""><p>如果資料比 Capacity 還大的話，就會擴充 buffer 的空間。</p><img src="/blog/2020/08/11/reduce-golang-allocation/buffer-04.svg" class=""><p>我把 buffer 的擴充上限設定為 4096 bytes，如果資料大於這個大小的話，我就會直接 allocate 新的 <code>[]byte</code>，不會把資料放到 buffer 裡，這樣能避免某些太大的 value 把 buffer 撐得太大。</p><p>具體實作可以參考 <a href="https://github.com/tommy351/rdb-go/blob/92a904e/byte_reader.go"><code>rdb-go</code> 的 <code>byte_reader.go</code></a>，或是 <a href="https://github.com/pkg/json/blob/319c2b1/reader.go"><code>pkg/json</code> 的 <code>reader.go</code></a>。</p><h2 id="Benchmark">Benchmark</h2><p>最後來比較一下改用 buffer 前後的差異，機器規格如下：</p><ul><li>CPU: AMD Ryzen 5 3400G</li><li>Memory: 32 GB</li><li>OS: Ubuntu 18.04 on Windows 10 (WSL 2)</li></ul><p>Benchmark 有兩個部分，一個是測試用的小檔案：</p><ul><li><code>empty_database</code> - 完全空的 RDB（10 B）</li><li><code>parser_filters</code> - 包含各種資料型態（1.2 KB）</li><li><code>linked_list</code> - 一個 1000 個元素的 list（50 KB）</li></ul><p>另一個部分則是正式環境的 RDB，大約 1.7 GB。</p><p>首先是初版 RDB parser：</p><pre><code class="language-plain">BenchmarkParser/empty_database-8                 4782122               258 ns/op              64 B/op          5 allocs/opBenchmarkParser/parser_filters-8                   13935             85142 ns/op           37856 B/op       1441 allocs/opBenchmarkParser/linkedlist-8                        3426            355550 ns/op          274337 B/op       6025 allocs/op</code></pre><pre><code class="language-plain">1m0.3905848sAlloc = 0 MiB   TotalAlloc = 11105 MiB  Sys = 139 MiB   NumGC = 3053</code></pre><p>改用 buffer 後的結果：</p><pre><code class="language-plain">BenchmarkParser/empty_database-8                 2588905               388 ns/op           632 B/op          5 allocs/opBenchmarkParser/parser_filters-8                   17988             67288 ns/op         38640 B/op        877 allocs/opBenchmarkParser/linkedlist-8                        3628            329772 ns/op        274921 B/op       5020 allocs/op</code></pre><pre><code class="language-plain">18.9372338sAlloc = 3 MiB   TotalAlloc = 11542 MiB  Sys = 206 MiB   NumGC = 3171</code></pre><p>從小檔測試的部分可以看出雖然在 <code>empty_database</code> 的部分改用 buffer 後反而會更差，但是在其他情況下會好很多，原因是因為 buffer 的初始大小是 512 bytes，所以如果 RDB 小於 512 bytes 的話反而會 allocate 多餘的空間，但實際上不可能用在這麼小的 RDB，所以可以忽略。</p><p>在正式環境測試中，可以看到時間從一分鐘縮減到只需要 18 秒，改用 buffer 的效果十分顯著，雖然 <code>TotalAlloc</code>（總共 allocate 的大小）和 <code>NumGC</code>（GC 次數）沒差多少，推測大概是因為 <code>[]byte</code> 轉 <code>string</code> 的 allocation。</p><p>除了自己實作 buffer 以外，利用 Go 內建的 <a href="https://golang.org/pkg/bufio/#Reader">bufio.Reader</a> 也是一種選擇，但使用時必須要謹慎，我在測試時雖然能夠得出和上面差不多的效能，但是 <code>TotalAlloc</code> 和 <code>NumGC</code> 會暴增三倍，所以還是決定自己實作了。</p><h2 id="結論">結論</h2><p>在反覆嘗試的時候讓我學到了一些關於 Go 的效能最佳化的方法，推薦大家可以去看看文章內提到的原文，以及原文作者引用的一些相關連結。</p><ul><li><a href="https://dave.cheney.net/high-performance-json.html">Building a high performance JSON parser</a></li><li><a href="https://philpearl.github.io/post/reader/">[]byte versus io.Reader</a></li></ul><p>如果剛好像我一樣有 RDB parser 的需求的話，歡迎試用看看我寫的 <a href="https://github.com/tommy351/rdb-go">rdb-go</a>。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;上個月因為工作需要掃描 Redis RDB 檔案，所以用 Go 自幹了一個 parser。雖然已經有各種現成的 library，其中以 Python 實作的 &lt;a href=&quot;https://github.com/sripathikrishnan/redis-rdb-tools&quot;&gt;redis-rdb-tools&lt;/a&gt; 為主，&lt;a href=&quot;https://github.com/sripathikrishnan/redis-rdb-tools/wiki/FAQs#i-dont-like-python-is-such-a-parser-available-in-language-x&quot;&gt;其他 library&lt;/a&gt; 大都以 &lt;a href=&quot;https://github.com/sripathikrishnan/redis-rdb-tools&quot;&gt;redis-rdb-tools&lt;/a&gt; 的邏輯來實作，文件中 Go 的連結已失效，然而我的 codebase 以 Go 為主，所以我決定自己用 Go 實作一個 RDB parser。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Go" scheme="https://zespia.me/tags/Go/"/>
    
    <category term="Redis" scheme="https://zespia.me/tags/Redis/"/>
    
  </entry>
  
  <entry>
    <title>在 Flutter 監聽音量按鈕的事件</title>
    <link href="https://zespia.me/blog/2020/07/15/flutter-volume-button-events/"/>
    <id>https://zespia.me/blog/2020/07/15/flutter-volume-button-events/</id>
    <published>2020-07-15T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.900Z</updated>
    
    <content type="html"><![CDATA[<p>繼<a href="/blog/2020/06/29/eh-redux-flutter/" title="上一篇文章">上一篇文章</a>提到了用 Channel 實作 <a href="https://flutter.dev/">Flutter</a> 和 Android/iOS 之間的通訊。本文將會示範如何用 Channel 來監聽音量按鈕的事件，因為我手邊只有 Android 裝置，所以會用 Kotlin 來示範。</p><p><a href="https://github.com/tommy351/eh-redux/">EH Redux</a> 有一個功能就是能夠使用音量鍵來控制圖片翻頁，這項功能因為目前 <a href="https://flutter.dev/">Flutter</a> 還沒有官方支援，所以必須要在 Android/iOS 這邊自己寫程式去補足。</p><span id="more"></span><h2 id="MethodChannel">MethodChannel</h2><p><a href="https://api.flutter.dev/flutter/services/MethodChannel-class.html">MethodChannel</a> 用於單次的非同步執行，這次我會用在切換音量控制是否開啟，因為 Flutter 在 Android 上預設只會有一個 <a href="https://api.flutter.dev/javadoc/io/flutter/embedding/android/FlutterActivity.html">FlutterActivity</a>，無論在哪個畫面背後實際上都是同一個 activity，而音量控制只會用在圖片瀏覽的畫面上，所以必須動態切換音量控制，否則在其他畫面上也會受到影響。</p><p>首先是 Android 的部分，在 <code>MainActivity</code> 裡實作 <code>configureFlutterEngine</code> 方法，然後在裡面建立一個 <code>MethodChannel</code>，用來監聽從 Flutter 傳來的事件。</p><pre><code class="language-kotlin">// 用這個變數來控制要不要攔截 keydown 事件private var interceptKeyDownEnabled = falseoverride fun configureFlutterEngine(flutterEngine: FlutterEngine) &#123;  super.configureFlutterEngine(flutterEngine)  // Method channel 的名字可以隨便取，只要確保在 Android/iOS 和 Flutter 兩邊用的名字一致就好了  MethodChannel(flutterEngine.dartExecutor, &quot;com.example/method&quot;).setMethodCallHandler &#123; call, result -&gt;    when (call.method) &#123;      &quot;interceptKeyDown&quot; -&gt; &#123;        interceptKeyDownEnabled = true        // 如果成功的話就用 result.success 回傳結果        result.success(true)        // 失敗的話則是用 result.error 回傳錯誤        // result.error(&quot;ERROR_CODE&quot;, &quot;error message&quot;, null)      &#125;      &quot;uninterceptKeyDown&quot; -&gt; &#123;        interceptKeyDownEnabled = false        result.success(true)      &#125;      else -&gt; &#123;        // 其他沒有 handle 到的 method 就回傳 result.notImplemented        result.notImplemented()      &#125;    &#125;  &#125;&#125;override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean &#123;  if (interceptKeyDownEnabled) &#123;    // 攔截 keydown 事件    return true  &#125;  return super.onKeyDown(keyCode, event)&#125;</code></pre><p>接下來是 Flutter 的部分，這部分比較簡單，用 <code>MethodChannel</code> 的 <code>invokeMethod</code> 就能執行上面在 Android 定義的程式碼。</p><pre><code class="language-dart">final methodChannel = MethodChannel('com.example/method');// 開始攔截 keydown 事件await methodChannel.invokeMethod('interceptKeyDown');// 停止攔截 keydown 事件await methodChannel.invokeMethod('uninterceptKeyDown');</code></pre><p>到此為止就能夠從 Flutter 切換音量控制了。</p><h2 id="EventChannel">EventChannel</h2><p><a href="https://api.flutter.dev/flutter/services/EventChannel-class.html">EventChannel</a> 讓 Flutter 能夠監聽從 Android/iOS 傳來的事件，這次會用在監聽 keydown 事件。</p><p>首先是 Android 的部分，在原本的 <code>configureFlutterEngine</code> 額外新增了一個 <code>EventChannel</code>，並用 Rx subject 來傳遞 keydown 事件。</p><pre><code class="language-kotlin">private var interceptKeyDownEnabled = false// 用 Rx 來傳遞事件，也可以改用其他類似的 libraryprivate val keyDownSubject = PublishSubject.create&lt;String&gt;()override fun configureFlutterEngine(flutterEngine: FlutterEngine) &#123;  super.configureFlutterEngine(flutterEngine)  MethodChannel(flutterEngine.dartExecutor, &quot;com.example/method&quot;).setMethodCallHandler &#123; call, result -&gt;    // ...  &#125;  // Event channel 的名字可以隨便取，只要確保在 Android/iOS 和 Flutter 兩邊用的名字一致就好了  EventChannel(flutterEngine.dartExecutor, &quot;com.example/event&quot;).setStreamHandler(object : StreamHandler &#123;    var dispose: Disposable? = null    override fun onListen(arguments: Any?, events: EventChannel.EventSink?) &#123;      // 開始訂閱事件      dispose = keyDownSubject.subscribeBy (        onNext = &#123; events?.success(it) &#125;,        onError = &#123; events?.error(&quot;KEY_DOWN_EVENT&quot;, it.message, it) &#125;,        onComplete = &#123; events?.endOfStream() &#125;      )    &#125;    override fun onCancel(arguments: Any?) &#123;      // 停止訂閱事件      dispose?.dispose()      dispose = null    &#125;  &#125;)&#125;override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean &#123;  if (interceptKeyDownEnabled) &#123;    // 在按下音量 +/- 鍵的時候，送事件到 Rx subject 並攔截 keydown 事件    when (keyCode) &#123;      KeyEvent.KEYCODE_VOLUME_DOWN -&gt; &#123;        keyDownSubject.onNext(&quot;volumeDown&quot;)        return true      &#125;      KeyEvent.KEYCODE_VOLUME_UP -&gt; &#123;        keyDownSubject.onNext(&quot;volumeUp&quot;)        return true      &#125;    &#125;  &#125;  return super.onKeyDown(keyCode, event)&#125;</code></pre><p>接下來是 Flutter 的部分，用 <code>EventChannel</code> 的 <code>receiveBroadcastStream</code> 就能接收事件。</p><pre><code class="language-dart">final eventChannel = EventChannel('com.example/event');// 訂閱事件final subscription = eventChannel.receiveBroadcastStream().listen((event) &#123;  final code = event as String;  // ...&#125;);// 取消訂閱subscription.cancel();</code></pre><p>這樣就能從 Flutter 監聽音量鍵的事件了。實際上的範例可以參考 <a href="https://github.com/tommy351/eh-redux/">EH Redux</a> 的 <a href="https://github.com/tommy351/eh-redux/blob/v0.5.1/android/app/src/main/kotlin/app/ehredux/MainActivity.kt">MainActivity.kt</a> 和 <a href="https://github.com/tommy351/eh-redux/blob/master/lib/utils/key_event.dart">key_event.dart</a>；更複雜一點的可以參考 <a href="https://pub.dev/packages/hardware_buttons">hardware_buttons</a>，它同時實作了 Android 和 iOS 的部分。</p><h2 id="結語">結語</h2><p>上上週的時候把 <a href="https://p5s.jp/">P5S</a> 玩完一輪了，這真的是一款非常優秀的遊戲，與其說是無雙，不如說更像動作 RPG，需要花一點時間適應；而劇情上也很不錯，補完了一些原本在本傳裡戲份比較少的角色的劇情，像是佑介和春，感覺角色更加生動了。</p><p>那麼究竟是為什麼明明遊戲都玩完了，卻還是沒有繼續開發 app 呢，主要是因為最近接觸到<a href="https://www.youtube.com/channel/UC1CfXB_kRs3C-zaeTG3oGyg">赤井はあと</a>拍的一堆狂氣廢片後，讓我開始踏入 Hololive 的坑，又開始浪費時間看 Vtuber 了😜。我預計從這周末開始應該就會重啟開發，應該吧。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;繼&lt;a href=&quot;/blog/2020/06/29/eh-redux-flutter/&quot; title=&quot;上一篇文章&quot;&gt;上一篇文章&lt;/a&gt;提到了用 Channel 實作 &lt;a href=&quot;https://flutter.dev/&quot;&gt;Flutter&lt;/a&gt; 和 Android/iOS 之間的通訊。本文將會示範如何用 Channel 來監聽音量按鈕的事件，因為我手邊只有 Android 裝置，所以會用 Kotlin 來示範。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/tommy351/eh-redux/&quot;&gt;EH Redux&lt;/a&gt; 有一個功能就是能夠使用音量鍵來控制圖片翻頁，這項功能因為目前 &lt;a href=&quot;https://flutter.dev/&quot;&gt;Flutter&lt;/a&gt; 還沒有官方支援，所以必須要在 Android/iOS 這邊自己寫程式去補足。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Flutter" scheme="https://zespia.me/tags/Flutter/"/>
    
    <category term="Dart" scheme="https://zespia.me/tags/Dart/"/>
    
  </entry>
  
  <entry>
    <title>EH Redux – 試用 Flutter 重寫 Android App</title>
    <link href="https://zespia.me/blog/2020/06/29/eh-redux-flutter/"/>
    <id>https://zespia.me/blog/2020/06/29/eh-redux-flutter/</id>
    <published>2020-06-29T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.897Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2020/06/29/eh-redux-flutter/cover.jpg" class=""><p>最近心血來潮，決定重新開始學習打從一年前就想玩玩看的 <a href="https://flutter.dev/">Flutter</a>，試試看能不能做出我廢棄多年的 <a href="/blog/2014/04/19/ehreader-android/" title="E-Hentai 閱讀器 for Android">E-Hentai 閱讀器 for Android</a>。</p><p><a href="https://flutter.dev/">Flutter</a> 是 Google 開發的跨平台 UI toolkit，可以同時支援 Android、iOS 和 Web，其原理就是用 canvas 來繪製所有的 UI，不需要像 <a href="https://reactnative.dev/">React Native</a> 一樣得在 UI 和 JavaScript engine 兩邊互相溝通而導致效能問題。</p><p>另一個優勢就是 <a href="https://flutter.dev/">Flutter</a> 本身已經提供了非常完整的 UI library，無論是 Android 或 iOS 風格皆有對應的元件可直接取用，雖然有些時候可能會發現和原生的 UI 在外觀或是動畫上有些微妙的差異，但整體來說已經非常實用了。</p><p>本文會以 Web 的角度來分析 <a href="https://flutter.dev/">Flutter</a> 的優缺點，因為我比較熟 <a href="https://reactjs.org">React</a>，所以主要會拿它來做比較。</p><span id="more"></span><h2 id="Declarative-UI">Declarative UI</h2><p><a href="https://flutter.dev/">Flutter</a> 和 <a href="https://reactjs.org">React</a> 一樣都是採用 Declarative 的形式來建構 UI，這似乎是最近越來越流行的做法。Android 現在有 <a href="https://developer.android.com/jetpack/compose">Jetpack Compose</a>，iOS 有 <a href="https://developer.apple.com/xcode/swiftui/">SwiftUI</a>。</p><p>和傳統的 Imperative 比較的話，最明顯的差別就是，不需要在狀態更新的時候手動更新對應的元素；Declarative 只要定義好介面，程式就會自動去判斷哪些元素需要更新。</p><h3 id="語法">語法</h3><p><a href="https://reactjs.org">React</a> 可以用 <a href="https://reactjs.org/docs/introducing-jsx.html">JSX</a>，語法會比較接近 HTML，在編譯時會把它轉換成對應的 JavaScript。</p><pre><code class="language-jsx">&lt;div className=&quot;foo&quot;&gt;Hello&lt;/div&gt;React.createElement('div', &#123;className: 'foo'&#125;, 'Hello')</code></pre><p><a href="https://flutter.dev/">Flutter</a> 就沒有提供這種語法，所有 widget 都是 class。</p><pre><code class="language-dart">// new 可以省略new Text('Hello')</code></pre><p>由於 <a href="https://flutter.dev/">Flutter</a> 不是用 CSS 來宣告元件的樣式，而是把各種樣式實作在不同的 widget 上。舉例來說 padding 就有一個獨立的 widget。</p><pre><code class="language-dart">Padding(  padding: EdgeInsets.all(8),  child: Text('Hello'))</code></pre><p>有些情況如果 widget 層級過深的話，比起 JSX 或 HTML 來說，要搬移或是修改會稍微困難一點。好在 IDE 提供了非常方便的功能，可以輕鬆的修改 widget 層級。</p><img src="/blog/2020/06/29/eh-redux-flutter/widget-context-actions.png" class=""><h3 id="狀態">狀態</h3><p><a href="https://flutter.dev/">Flutter</a> 的 widget 分為兩種：<a href="https://api.flutter.dev/flutter/widgets/StatelessWidget-class.html"><code>StatelessWidget</code></a> 是不儲存狀態的 widget，類似 <a href="https://reactjs.org">React</a> 的 function component。這種 widget 不需要管理任何狀態或生命週期，只需要把 UI 建構出來就好了，會在屬性變動的時候自動更新。</p><pre><code class="language-dart">class Foo extends StatelessWidget &#123;  const Foo(&#123;    Key key,    this.name,  &#125;) : super(key: key);  final String name;  @override  Widget build(BuildContext context) &#123;    return Text('Hello $name');  &#125;&#125;</code></pre><p><a href="https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html"><code>StatefulWidget</code></a> 則是類似 <a href="https://reactjs.org">React</a> 的 class component，本身可儲存狀態，也有生命週期。這種 widget 會分為兩個 class，一個是用來建立狀態的 <code>StatefulWidget</code> class，另一個則是儲存狀態用的 <code>State</code> class。在 <code>State</code> class 裡，可以用 <code>setState</code> 來更新狀態。</p><pre><code class="language-dart">class Foo extends StatefulWidget &#123;  @override  _FooState createState() =&gt; _FooState();&#125;class _FooState extends State&lt;Foo&gt; &#123;  int count = 0;  @override  Widget build(BuildContext context) &#123;    return FlatButton(      child: Text('You have clicked $count times'),      onPressed: () &#123;        setState(() &#123;          count++;        &#125;);      &#125;,    );  &#125;&#125;</code></pre><h3 id="Hot-Reload">Hot Reload</h3><p><a href="https://flutter.dev/">Flutter</a> 本身對於 hot reload 的支援非常好，任何改變幾乎都能在兩秒左右就反映在裝置上，即便是有儲存狀態的 <code>StatefulWidget</code>，也能把狀態保留下來更新裡面的內容，即便是部分<a href="https://flutter.dev/docs/development/tools/hot-reload#special-cases">特殊情況</a>，通常也能用 hot restart 的方式重開，無需等待重新編譯的時間，大幅增進了開發效率。</p><h2 id="Dart">Dart</h2><p><a href="https://flutter.dev/">Flutter</a> 採用 <a href="https://dart.dev/">Dart</a> 做為開發的程式語言，雖然和 <a href="https://golang.org/">Go</a> 一樣都是由 Google 出品，但兩者的差異非常大，各有優缺點。我覺得 Google 應該融合這兩個程式語言的優點，再開發一個更好的版本。</p><h3 id="Nullable">Nullable</h3><p>目前 Dart 所有型別都是 nullable 的（<a href="https://dart.dev/null-safety">Null safety</a> 仍在 tech preview，我還沒用過），就連原始型別（primitive types，如 <code>boolean</code>, <code>int</code>, <code>double</code>）也是 nullable，這點和我用過的 JavaScript 或 Go 不同，導致一開始踩到一些雷。</p><p>目前 Dart 有幾種方法可以緩解這個問題，一種是以 <code>@required</code> annotation 來標示必須的變數，這樣在 IDE 或 analyzer 都能透過靜態檢查確認有沒有設值。</p><pre><code class="language-dart">void foo(&#123;@required String bar&#125;) &#123;&#125;</code></pre><p>另一種則是用 <a href="https://dart.dev/guides/language/language-tour#assert"><code>assert</code></a> function 檢查，但是這個只能在開發模式下使用，在正式環境時會被完全忽略掉。</p><pre><code class="language-dart">assert(value != null);</code></pre><p>不過 <a href="https://dart.dev/">Dart</a> 有個優點，就是支援 optional chaining 和 nullish coalescing。這些功能稍微緩解了 nullable 的問題，讓平常習慣寫 <a href="https://www.typescriptlang.org/">TypeScript</a> 的我感到非常親切。</p><pre><code class="language-dart">foo?.bar?.baz ?? value;a ??= value;</code></pre><h3 id="Code-Generating">Code Generating</h3><p><a href="https://dart.dev/">Dart</a> 和 <a href="https://golang.org/">Go</a> 一樣，都很依賴 code generating。我覺得 <a href="https://dart.dev/">Dart</a> 用到 code generating 的頻率更勝於 Go，例如：</p><ul><li>Immutable data：<a href="https://pub.dev/packages/built_value">built_value</a>, <a href="https://pub.dev/packages/freezed">freezed</a></li><li>資料流：<a href="https://pub.dev/packages/mobx">mobx</a></li><li>ORM：<a href="https://pub.dev/packages/moor">moor</a></li><li>JSON：<a href="https://pub.dev/packages/json_serializable">json_serializable</a></li></ul><p>這些 library 是我這次寫 app 有用到的，它們都非常依賴 code generating，相比之下 <a href="https://golang.org/">Go</a> 多半依賴於反射。這樣的好處是執行時效能更好、更加安全，但壞處就是每次改動都需要重新跑 codegen，像我寫的這個小 app 每次重跑都需要半分鐘。</p><h3 id="工具">工具</h3><p><a href="https://dart.dev/">Dart</a> 本身也提供了很多好用的工具，像 <a href="https://golang.org/">Go</a> 一樣，<a href="https://dart.dev/">Dart</a> 也有 <a href="https://dart.dev/tools/dartfmt">dartfmt</a>，用來格式化程式碼，這樣可以讓多數用 Dart 寫的程式看起來都很接近。</p><p>除此之外，還有 <a href="https://github.com/dart-lang/linter">dartanalyzer</a>，用來檢查語法問題，這個工具本身就內建了很多規則，但多半都需要手動開啟，我現在是用 <a href="https://github.com/passsy/dart-lint">lint</a>，它本身開啟了很多有用的規則。</p><h2 id="原生部分">原生部分</h2><p>我原本以為這次寫 <a href="https://flutter.dev/">Flutter</a> 可以完全不需要碰到任何 Android 的原生部分，結果有些部分還是得寫一些 Kotlin 才能實作。</p><h3 id="全螢幕">全螢幕</h3><p><a href="https://flutter.dev/">Flutter</a> 雖然提供了方法可以隱藏上方的狀態列（status bar）和下方的導覽列（navigation bar）。</p><pre><code class="language-dart">// 把 top 或 bottom 從這個 array 移除掉的話，就能隱藏對應的系統狀態/導覽列SystemChrome.setEnabledSystemUIOverlays([  SystemUiOverlay.top,  SystemUiOverlay.bottom,]);</code></pre><p>然而現在很多手機螢幕會有瀏海或挖洞，在 Android 顯示和隱藏系統狀態列的時候，會導致整個畫面跳動，必須要在 Android 設定才可以讓畫面延伸到最上方。</p><pre><code class="language-xml">&lt;item name=&quot;android:windowLayoutInDisplayCutoutMode&quot;&gt;shortEdges&lt;/item&gt;</code></pre><p>另一個問題就是預設下方的系統導覽列是黑色的，如果要改透明的話也要在 Android 這邊設定。（參考：<a href="https://github.com/flutter/flutter/issues/34678#issuecomment-536028077">flutter#34678</a>, <a href="https://github.com/flutter/flutter/issues/40974#issuecomment-645413064">flutter#40974</a>）</p><pre><code class="language-xml">&lt;item name=&quot;android:windowTranslucentStatus&quot;&gt;true&lt;/item&gt;&lt;item name=&quot;android:windowTranslucentNavigation&quot;&gt;true&lt;/item&gt;</code></pre><pre><code class="language-kotlin">window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or  View.SYSTEM_UI_FLAG_HIDE_NAVIGATION</code></pre><h3 id="硬體按鈕">硬體按鈕</h3><p>硬體按鈕（例如音量鍵、返回鍵等）目前官方還不支援，雖然已經有現成的 <a href="https://pub.dev/packages/hardware_buttons">hardware_buttons</a> 套件能夠直接使用，但因為我想研究看看 <a href="https://flutter.dev/">Flutter</a> 和 Android 之間的通訊，所以就自己實作看看了。我覺得實際上不會很難實作，只是相對來說可能除錯比較麻煩一點而已。</p><p><a href="https://flutter.dev/">Flutter</a> 對於平台本身的通訊有兩種，一種是 <a href="https://api.flutter.dev/flutter/services/MethodChannel-class.html">MethodChannel</a>，用於單次的非同步執行；另一種則是 <a href="https://api.flutter.dev/flutter/services/EventChannel-class.html">EventChannel</a>，用來監聽連續的事件。</p><p>因為這部分似乎寫起來會有點長，我決定放在之後的文章，各位如果有興趣的話可以先看官方的<a href="https://flutter.dev/docs/development/platform-integration/platform-channels">教學</a>或<a href="https://github.com/flutter/flutter/tree/master/examples/platform_channel">範例</a>。</p><h2 id="結語">結語</h2><p>目前為止 <a href="https://flutter.dev/">Flutter</a> 大概寫了三週左右，我覺得其實開發體驗意外的和 <a href="https://reactjs.org">React</a> 蠻接近的，兩者都提供了 declarative UI 和 hot reload，也有 Redux 或 MobX 可以用。</p><img src="/blog/2020/06/29/eh-redux-flutter/pkg-score.png" class=""><p>差別大概在於 JavaScript 生態系實在太龐大，有時候選擇困難，光是挑個 library 可能就會浪費好幾天；寫 Flutter 就沒這種困擾了，本身套件庫沒那麼龐大，且每個套件都有<a href="https://pub.dev/help#scoring">評分</a>和 <a href="https://flutter.dev/docs/development/packages-and-plugins/favorites">Flutter Favorite</a> 標誌做為參考，相對來說比較好選擇。</p><p>最後各位如果有興趣的話，可以下載 <a href="https://github.com/tommy351/eh-redux/">EH Redux</a> 來玩玩看，雖然最近沉迷於 <a href="https://p5s.jp/">P5S</a> 所以開發會停滯幾週，但目前除了下載以外的主要功能大致上都完成了，如果在使用時有遇到問題的話歡迎到 GitHub 留 issue。</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2020/06/29/eh-redux-flutter/cover.jpg&quot; class=&quot;&quot;&gt;
&lt;p&gt;最近心血來潮，決定重新開始學習打從一年前就想玩玩看的 &lt;a href=&quot;https://flutter.dev/&quot;&gt;Flutter&lt;/a&gt;，試試看能不能做出我廢棄多年的 &lt;a href=&quot;/blog/2014/04/19/ehreader-android/&quot; title=&quot;E-Hentai 閱讀器 for Android&quot;&gt;E-Hentai 閱讀器 for Android&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://flutter.dev/&quot;&gt;Flutter&lt;/a&gt; 是 Google 開發的跨平台 UI toolkit，可以同時支援 Android、iOS 和 Web，其原理就是用 canvas 來繪製所有的 UI，不需要像 &lt;a href=&quot;https://reactnative.dev/&quot;&gt;React Native&lt;/a&gt; 一樣得在 UI 和 JavaScript engine 兩邊互相溝通而導致效能問題。&lt;/p&gt;
&lt;p&gt;另一個優勢就是 &lt;a href=&quot;https://flutter.dev/&quot;&gt;Flutter&lt;/a&gt; 本身已經提供了非常完整的 UI library，無論是 Android 或 iOS 風格皆有對應的元件可直接取用，雖然有些時候可能會發現和原生的 UI 在外觀或是動畫上有些微妙的差異，但整體來說已經非常實用了。&lt;/p&gt;
&lt;p&gt;本文會以 Web 的角度來分析 &lt;a href=&quot;https://flutter.dev/&quot;&gt;Flutter&lt;/a&gt; 的優缺點，因為我比較熟 &lt;a href=&quot;https://reactjs.org&quot;&gt;React&lt;/a&gt;，所以主要會拿它來做比較。&lt;/p&gt;</summary>
    
    
    
    
    <category term="E-Hentai" scheme="https://zespia.me/tags/E-Hentai/"/>
    
    <category term="Flutter" scheme="https://zespia.me/tags/Flutter/"/>
    
    <category term="Dart" scheme="https://zespia.me/tags/Dart/"/>
    
  </entry>
  
  <entry>
    <title>建構精簡的 Yarn Workspace Docker Image</title>
    <link href="https://zespia.me/blog/2020/06/12/build-docker-image-for-yarn-workspace/"/>
    <id>https://zespia.me/blog/2020/06/12/build-docker-image-for-yarn-workspace/</id>
    <published>2020-06-12T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.896Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2020/06/12/build-docker-image-for-yarn-workspace/cover.jpg" class=""><p>本篇接續 <a href="/blog/2020/05/10/yarn-2-and-monorepo/" title="Yarn 2 和 Monorepo">Yarn 2 和 Monorepo</a> 提到的部屬的部分，因為 monorepo 裡包含了很多套件和網站，如果直接在根目錄執行 <code>docker build</code> 把整個 monorepo 打包成 Docker image 的話，勢必會做出大於 1 GB 而且內含一堆無用垃圾的 Docker image；為了要讓 Docker image 能夠最小化，必須只打包正式環境會需要用到的套件，確保不會浪費任何空間和時間。</p><p>我把建構 Docker image 的步驟分為：</p><ul><li>解析正式環境需要用到的套件</li><li>複製 workspaces</li><li>建構 Docker image</li></ul><span id="more"></span><h2 id="解析套件的相依關係">解析套件的相依關係</h2><p>這部分對於 Yarn 2 來說非常容易，在擴充套件裡可以直接讀取整個 monorepo 的狀態，可以參考 <a href="https://github.com/yarnpkg/berry/blob/master/packages/plugin-workspace-tools/sources/commands/focus.ts"><code>yarn workspaces focus</code></a> 的原始碼，這個指令是用來只安裝特定 workspace 需要用到的套件，剛好和我需要的功能相同。</p><pre><code class="language-ts">import &#123; Configuration, Project, Cache &#125; from '@yarnpkg/core';// 讀取設定const configuration = await Configuration.find(this.context.cwd, this.context.plugins);const &#123; project &#125; = await Project.find(configuration, this.context.cwd);// 取得指定的 workspaceconst workspace = project.getWorkspaceByIdent(  structUtils.parseIdent('@foo/bar'),);const requiredWorkspaces = new Set([workspace]);for (const ws of requiredWorkspaces) &#123;  // scope 可以是 `dependencies`, `devDependencies`  // 因為我們只需要正式環境會用到的套件，所以這邊只用 `dependencies`  const deps = ws.manifest.getForScope('dependencies').values();  // 把相依的 workspace 新增到 `requiredWorkspaces` 裡  for (const dep of deps) &#123;    const workspace = project.tryWorkspaceByDescriptor(dep);    if (workspace) &#123;      requiredWorkspaces.add(workspace);    &#125;  &#125;&#125;// 接著把 project 裡所有 workspace 的 manifest (package.json) 都清理一遍for (const ws of project.workspaces) &#123;  // 如果這個 workspace 在正式環境會用到，那麼只清掉 `devDependencies`  if (requiredWorkspaces.has(ws)) &#123;    ws.manifest.devDependencies.clear();  &#125; else &#123;    // 否則就把所有的 dependencies 都清掉    ws.manifest.dependencies.clear();    ws.manifest.devDependencies.clear();    ws.manifest.peerDependencies.clear();  &#125;&#125;</code></pre><p>透過上面的程式碼，就能得到正式環境需要用到的 workspaces。接下來，我會重跑一遍 <code>yarn install</code>，因為已經有快取了，所以不需要花多少時間，這是為了產生更新後的 <code>yarn.lock</code>，並了解有哪些 <code>.yarn/cache</code> 的檔案會被用到。</p><pre><code class="language-ts">// 讀取現有快取const cache = await Cache.find(configuration);// 解析 dependencies// 這部分相當於 `yarn install` 裡的 `Resolution Step`await project.resolveEverything(&#123; report, cache &#125;);// 下載 dependencies// 這部分相當於 `yarn install` 裡的 `Fetch Step`await project.fetchEverything(&#123; report, cache &#125;);// 執行完上面兩個步驟後，就能產生新的 `yarn.lock`const newLockFile = project.generateLockFile();// 也能知道有哪些 cache 會被用到for (const file of cache.markedFiles) &#123;&#125;</code></pre><h2 id="複製-workspaces">複製 workspaces</h2><p>除了相依套件外，還需要把 workspaces 的原始碼也複製到 Docker image 裡，為了精簡需要複製的檔案量，可以參考 <a href="https://github.com/yarnpkg/berry/blob/master/packages/plugin-pack/sources/packUtils.ts"><code>yarn pack</code></a> 的原始碼，我在這邊用 <code>packUtils</code> 取得檔案列表，然後再複製到指定的資料夾裡。</p><pre><code class="language-ts">import &#123; packUtils &#125; from '@yarnpkg/plugin-pack';// `prepareForPack` 是用來執行 `prepack` 和 `postpack` 等 lifecycle hooks 的await packUtils.prepareForPack(workspace, &#123; report &#125;, async () =&gt; &#123;  // 取得檔案列表  const files = await packUtils.genPackList(workspace);  // 如果想要把檔案壓成壓縮檔的話，可以用 `genPackStream`  const stream = await packutils.genPackStream(workspace);&#125;);</code></pre><h2 id="建構-Docker-image">建構 Docker image</h2><p>最後，檔案會被分成兩個部分複製到不同的資料夾，一個是 <code>manifests</code>，用來儲存 <code>yarn install</code> 需要用到的檔案，像是 <code>package.json</code>, <code>yarn.lock</code> 和快取；另一個部分則是 workspaces 的原始碼，也就是上面 <code>yarn pack</code> 產生的結果。</p><p>以下是 <code>Dockerfile</code> 的範例：</p><pre><code class="language-dockerfile">FROM node:12-alpine AS builderWORKDIR /workspaceCOPY manifests ./RUN yarn install --immutableRUN rm -rf .yarn/cacheFROM node:12-alpineWORKDIR /workspaceCOPY --from=builder /workspace ./COPY packs ./CMD yarn workspace @foo/bar start</code></pre><h2 id="結論">結論</h2><p>在寫完 Yarn 2 那篇文章後，我花了一些時間把內部使用的 Yarn 擴充套件整理了一下並開源，各位可以試用看看：<a href="https://github.com/Dcard/yarn-plugins/tree/master/packages/docker-build">yarn-plugin-docker-build</a>。</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2020/06/12/build-docker-image-for-yarn-workspace/cover.jpg&quot; class=&quot;&quot;&gt;
&lt;p&gt;本篇接續 &lt;a href=&quot;/blog/2020/05/10/yarn-2-and-monorepo/&quot; title=&quot;Yarn 2 和 Monorepo&quot;&gt;Yarn 2 和 Monorepo&lt;/a&gt; 提到的部屬的部分，因為 monorepo 裡包含了很多套件和網站，如果直接在根目錄執行 &lt;code&gt;docker build&lt;/code&gt; 把整個 monorepo 打包成 Docker image 的話，勢必會做出大於 1 GB 而且內含一堆無用垃圾的 Docker image；為了要讓 Docker image 能夠最小化，必須只打包正式環境會需要用到的套件，確保不會浪費任何空間和時間。&lt;/p&gt;
&lt;p&gt;我把建構 Docker image 的步驟分為：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;解析正式環境需要用到的套件&lt;/li&gt;
&lt;li&gt;複製 workspaces&lt;/li&gt;
&lt;li&gt;建構 Docker image&lt;/li&gt;
&lt;/ul&gt;</summary>
    
    
    
    
    <category term="Node.js" scheme="https://zespia.me/tags/Node-js/"/>
    
    <category term="Monorepo" scheme="https://zespia.me/tags/Monorepo/"/>
    
    <category term="Yarn" scheme="https://zespia.me/tags/Yarn/"/>
    
    <category term="Docker" scheme="https://zespia.me/tags/Docker/"/>
    
  </entry>
  
  <entry>
    <title>試用 Tailwind CSS 重寫主題</title>
    <link href="https://zespia.me/blog/2020/05/20/try-out-tailwind-css/"/>
    <id>https://zespia.me/blog/2020/05/20/try-out-tailwind-css/</id>
    <published>2020-05-20T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.895Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2020/05/20/try-out-tailwind-css/tailwind-css-intro.png" class=""><p>上週試著用 <a href="https://tailwindcss.com/">Tailwind CSS</a> 重新打造了網誌的主題，一開始使用的時候，覺得一直翻文件很煩，因為大部分的 CSS 規則大概都知道怎麼寫，卻得要翻文件才知道對應的 class；但寫了一段時間後，開始覺得還不錯，大部分的 class 都很容易預測，也很容易根據需求客製變數或外掛。</p><p>跟 <a href="https://getbootstrap.com/">Bootstrap</a> 或 <a href="https://semantic-ui.com/">Semantic UI</a> 這類 UI library 相比，Tailwind CSS 不提供現成的元件（另有提供須付費的 <a href="https://tailwindui.com/">Tailwind UI</a>），而是把每個 CSS 規則都寫成單獨的 class，因此即便完全不寫 CSS，只要在 HTML 中設定 class，也很容易能夠拼湊出想要的樣式，但缺點就是還是需要有基本的 CSS 知識才能上手。</p><span id="more"></span><h2 id="安裝">安裝</h2><p>首先用 npm 或 yarn 安裝 <a href="https://www.npmjs.com/package/tailwindcss">tailwindcss</a>。</p><pre><code class="language-shell">npm install tailwindcss</code></pre><p>可以搭配 <a href="https://postcss.org/">PostCSS</a> 使用。</p><pre><code class="language-js">const postcss = require('postcss');postcss([  require('tailwindcss'),  require('autoprefixer')]);</code></pre><h2 id="使用方式">使用方式</h2><p>Tailwind 本身只需要在 CSS 的開頭加上下列語法就能使用，這包含了 <a href="https://necolas.github.io/normalize.css/">normalize.css</a> 和所有可能會用到的 class，展開來大約會有數萬行之多。但是無須擔心，在正式環境下，Tailwind 會利用 <a href="https://purgecss.com/">PurgeCSS</a> 把沒用到的 class 都清掉，像是本網誌在正式環境下，尚未壓縮過的 CSS 大約不到千行。</p><pre><code class="language-css">@tailwind base;@tailwind components;@tailwind utilities;</code></pre><h3 id="HTML">HTML</h3><p>引入基本 class 後，就能直接在 HTML 使用。舉例來說，一個按鈕可以寫成：</p><pre><code class="language-html">&lt;button class=&quot;rounded p-4 border-indigo-500 text-base&quot;&gt;&lt;/button&gt;</code></pre><p>上面的 HTML 裡，每個 class 都各自代表了下列的 CSS 規則。</p><pre><code class="language-css">.rounded &#123; border-radius: .25rem &#125;.p-4 &#123; padding: 1rem &#125;.border-indigo-500 &#123; border-color: #667EEA &#125;.text-base &#123; font-size: 1rem &#125;</code></pre><p>這就是 Tailwind 提倡的 <a href="https://tailwindcss.com/docs/utility-first">Utility-First</a> 概念，因為每個 class 都非常基本，因此很容易組合，不需要特地想每個元件的 class name，也不會每加一個新元件就多一個 class，只要直接使用 Tailwind 提供的 class 就好。</p><h3 id="Variants">Variants</h3><p>只要在原本的 class 前面加上 prefix，就能支援 <a href="https://tailwindcss.com/docs/pseudo-class-variants">pseudo class</a> 和 <a href="https://tailwindcss.com/docs/responsive-design">responsive design</a>。</p><pre><code class="language-html">&lt;button class=&quot;rounded p-4 border-indigo-500 text-base hover:font-bold lg:text-lg&quot;&gt;&lt;/button&gt;</code></pre><p>舉例來說，在原本的按鈕加上兩個新的 class <code>hover:font-bold lg:text-lg</code>。它們分別代表：</p><pre><code class="language-css">.hover\:font-bold:hover &#123;  font-weight: bold;&#125;@media (min-width: 1024px) &#123;  .lg\:text-lg &#123;    font-size: 1.125rem;  &#125;&#125;</code></pre><h3 id="CSS">CSS</h3><p>如果要重複使用元件的話，也可以在 CSS 使用 <code>@apply</code> directive。舉例來說，上面的按鈕就可以寫成：</p><pre><code class="language-css">.btn &#123;  @apply rounded p-4 border-indigo-500 text-base;&#125;</code></pre><p>有些情況直接寫 CSS 會更方便，例如 nested elements，或是 pseudo elements（<code>::before</code>, <code>::after</code>），本網站左上角和右下角的「」，以及暗色系的捲軸就是這樣實作的。</p><h2 id="結論">結論</h2><p>對我來說 Tailwind CSS 的確能提升開發效率，大幅節省了我寫 CSS 的時間，幾乎大部分的元件都能直接在 HTML 寫 class 就好。</p><p>它本身的命名系統也很不錯，顏色在 <code>background-color</code>、<code>color</code>、<code>border-color</code> 等各種情境下都是通用的，所以只需要記下 prefix 就好。我還另外設定了顏色的 alias，這樣就能在任何地方使用指定的顏色，例如 <code>bg-accent</code>、<code>text-accent</code>，一看名字就知道意思。</p><p>數字的部分變化就稍微多一點，<code>margin</code> 和 <code>padding</code> 是以數字命名，<code>font-size</code> 則是用 <code>sm</code>、<code>lg</code>、<code>xl</code> 命名，<code>line-height</code> 甚至是數字和名詞混用。我在使用的時候覺得這些單位的選擇都很符合我的需求，或許這些以非數字來命名的 class 就是設計者認為的最佳單位？</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2020/05/20/try-out-tailwind-css/tailwind-css-intro.png&quot; class=&quot;&quot;&gt;
&lt;p&gt;上週試著用 &lt;a href=&quot;https://tailwindcss.com/&quot;&gt;Tailwind CSS&lt;/a&gt; 重新打造了網誌的主題，一開始使用的時候，覺得一直翻文件很煩，因為大部分的 CSS 規則大概都知道怎麼寫，卻得要翻文件才知道對應的 class；但寫了一段時間後，開始覺得還不錯，大部分的 class 都很容易預測，也很容易根據需求客製變數或外掛。&lt;/p&gt;
&lt;p&gt;跟 &lt;a href=&quot;https://getbootstrap.com/&quot;&gt;Bootstrap&lt;/a&gt; 或 &lt;a href=&quot;https://semantic-ui.com/&quot;&gt;Semantic UI&lt;/a&gt; 這類 UI library 相比，Tailwind CSS 不提供現成的元件（另有提供須付費的 &lt;a href=&quot;https://tailwindui.com/&quot;&gt;Tailwind UI&lt;/a&gt;），而是把每個 CSS 規則都寫成單獨的 class，因此即便完全不寫 CSS，只要在 HTML 中設定 class，也很容易能夠拼湊出想要的樣式，但缺點就是還是需要有基本的 CSS 知識才能上手。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Tailwind" scheme="https://zespia.me/tags/Tailwind/"/>
    
    <category term="CSS" scheme="https://zespia.me/tags/CSS/"/>
    
  </entry>
  
  <entry>
    <title>Yarn 2 和 Monorepo</title>
    <link href="https://zespia.me/blog/2020/05/10/yarn-2-and-monorepo/"/>
    <id>https://zespia.me/blog/2020/05/10/yarn-2-and-monorepo/</id>
    <published>2020-05-10T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.893Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2020/05/10/yarn-2-and-monorepo/yarn-logo.png" class=""><p>今年初隨著公司的 repo 越來越多，我們決定把 web 前端部分轉為 monorepo 的形式，一開始花了一段時間研究各個 monorepo 方案的利弊，最後決定基於 Yarn 2 打造一套自用的工具。這篇文章會大概分析一些我試過的 monorepo 方案的優缺點，以及最後用 Yarn 2 的成果。</p><span id="more"></span><h2 id="現有-Monorepo-方案">現有 Monorepo 方案</h2><h3 id="Lerna"><a href="https://lerna.js.org/">Lerna</a></h3><p><a href="https://lerna.js.org/">Lerna</a> 是我一開始比較熟悉的方案，在 <a href="https://github.com/tommy351/kosko/">Kosko</a> 和 <a href="https://github.com/tommy351/kubernetes-models-ts/">kubernetes-models-ts</a> 都有用到，算是 JavaScript monorepo 非常普遍的選擇。</p><ul><li>👍 支援 npm，也可以善用 Yarn 提供的 workspace 功能。</li><li>👍 可以偵測檔案變動，只更新並發佈有變動的 npm packages。</li><li>💩 主要是設計用來「發佈到 npm」的，如果是內部使用的話，並不需要用到這功能，必須得客製 <code>lerna version</code> 才能符合我們的需求。</li></ul><h3 id="Yarn"><a href="https://classic.yarnpkg.com/">Yarn</a></h3><ul><li>👍 本身就內建了 workspace 功能，對於 monorepo 有最基本的支援。</li><li>👍 效能好，會把共用的 dependencies 抽到最上層的 <code>node_modules</code> 共用避免浪費空間。</li><li>💩 如果要在 workspace 之間互相引用的話，<code>yarn workspace @scope/a add @scope/b</code> 總是會試圖從 npm 下載 package，而不是先安裝 local 版本 (<a href="https://github.com/yarnpkg/yarn/issues/4878">yarnpkg/yarn#4878</a>)。</li></ul><h3 id="pnpm"><a href="https://pnpm.js.org/">pnpm</a></h3><ul><li>👍 本身就內建了 workspace 功能，相較於 Yarn 1 來說更強大一點。</li><li>👍 能夠用 <a href="https://pnpm.js.org/en/pnpmfile"><code>pnpmfile.js</code></a> 客製 <code>pnpm install</code> 的行為，可用來限制 dependencies 版本或是竄改 <code>package.json</code>。</li><li>💩 相較於 npm 和 Yarn 來說比較小眾，使用前必須先安裝。如果是 Yarn 的話，CI 和 Docker image 均有內建。</li></ul><h3 id="Rush"><a href="https://rushjs.io/">Rush</a></h3><p><a href="https://rushjs.io/">Rush</a> 是微軟推出的 JavaScript monorepo 方案，設計更加嚴謹且繁瑣。</p><ul><li>👍 可以同時支援 npm、Yarn 和 pnpm，官方建議選用 pnpm。</li><li>👍 可指定跨 workspace 之間的 dependencies 版本，避免衝突。</li><li>👍 可以避免漏裝 dependencies。Yarn 會把共用 dependencies 都裝到最上層的 <code>node_modules</code>，因此所有 workspace 都能直接引用，即便沒有寫在 <code>package.json</code> 裡。pnpm 則是會把所有 dependencies 都裝到另外的資料夾，再用 symlink 連結到各個 workspace 的 <code>node_modules</code>。</li><li>👍 能夠自動偵測 workspaces 之間的相依性，決定編譯順序，並實作平行編譯、增量編譯。</li><li>💩 必須手動指定所有 workspace 的路徑。</li><li>💩 有些功能實際上必須依賴於 pnpm，因此得先安裝 pnpm。</li></ul><h3 id="Bazel"><a href="https://bazel.build/">Bazel</a></h3><p><a href="https://bazel.build/">Bazel</a> 是 Google 推出的跨語言 monorepo 方案，很強大也很複雜，對於我們來說，只是要支援 JavaScript 卻要寫這麼多設定，實在讓人頭痛。</p><ul><li>👍 能夠快取並增量編譯。</li><li>👍 能夠處理編譯、測試、部署，可以說是一條龍的方案。</li><li>💩 有獨特的 DSL 和生態系，學習成本很高，除非像 Angular 有現成的套件，否則設定很花時間。</li></ul><h2 id="Yarn-2"><a href="https://yarnpkg.com/">Yarn 2</a></h2><p>在我研究的這段期間，Yarn 2 剛好推出了 RC 版，相較於 Yarn 1 變化非常大，詳細內容可以參考 <a href="https://dev.to/arcanis/introducing-yarn-2-4eh1">Introducing Yarn 2</a>。</p><h3 id="更完善的-Workspace-支援">更完善的 Workspace 支援</h3><p>現在 <code>yarn workspaces foreach</code> 的功能更完善，有點接近 Lerna。</p><pre><code class="language-shell">yarn workspaces foreach --parallel --interlaced --topological run ...</code></pre><p>Workspace 之間相互引用時，不再出現上面提到的 <code>yarn add</code> 問題。</p><pre><code class="language-shell">yarn workspace @scope/a add @scope/b</code></pre><h3 id="限制-dependencies-版本">限制 dependencies 版本</h3><p>透過新功能 <a href="https://yarnpkg.com/features/constraints">Constraints</a>，可以限制 dependencies 的版本，在官方的 <a href="https://github.com/yarnpkg/berry/blob/master/constraints.pro"><code>constraints.pro</code></a> 可以看到許多有趣的範例。</p><p>例如下面這段可以用來確保每個 workspace 所用的 dependencies 版本統一。</p><pre><code class="language-prolog">gen_enforced_dependency(WorkspaceCwd, DependencyIdent, DependencyRange2, DependencyType) :-  workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType),  workspace_has_dependency(OtherWorkspaceCwd, DependencyIdent, DependencyRange2, DependencyType2),  DependencyRange \= DependencyRange2.</code></pre><h3 id="容易擴充">容易擴充</h3><p>所有功能幾乎都是以擴充套件的形式實作的，官方本身提供了一些非常好用的擴充套件。我們用到了：</p><ul><li><a href="https://github.com/yarnpkg/berry/tree/master/packages/plugin-constraints">constraints</a> - 提供了上面提到的 constraint 功能。</li><li><a href="https://github.com/yarnpkg/berry/tree/master/packages/plugin-typescript">typescript</a> - 在安裝 dependencies 的時候順便安裝對應的 <code>@types/</code> 套件。</li><li><a href="https://github.com/yarnpkg/berry/tree/master/packages/plugin-workspace-tools">workspace-tools</a> - 提供了上面提到的 <code>yarn workspaces foreach</code> 功能。</li></ul><p>如果要自己實作擴充套件也非常簡單，透過 Yarn 2 的 API 可以輕鬆地得到每個 workspace 的狀態。我們自己也實作了一些簡單的擴充套件：</p><ul><li><a href="https://github.com/Dcard/yarn-plugins/tree/master/packages/changed">changed</a> - 偵測有變動的 workspaces。</li><li><a href="https://github.com/Dcard/yarn-plugins/tree/master/packages/tsconfig-references">tsconfig-references</a> - 在安裝 dependencies 的時候順便更新 <code>tsconfig.json</code> 的 <code>references</code>。</li></ul><h3 id="Zero-Installs">Zero-Installs</h3><p>Yarn 2 預設會啟用 Zero-Installs (Plug’n’Play)，也就是把所有 dependencies 安裝到 <code>.yarn</code> 資料夾，完全消滅了 <code>node_modules</code> 的存在，藉此解決效能和 <code>node_modules</code> 占用太多硬碟空間的問題。</p><p>這個功能需要 toolchain 的配合，因為它徹底改寫了 Node.js 的 module resolution 機制，雖然目前很多主流的工具都支援了 PnP，但是 VSCode 目前沒有辦法預覽套件內容，因為 Yarn 2 用 zip 儲存套件，VSCode 雖然能夠解析路徑，但無法讀取 zip 檔的內容 (<a href="https://github.com/microsoft/vscode/issues/75559">microsoft/vscode#75559</a>)。</p><p>目前的解法是關閉 Zero-Installs 功能，在 <code>.yarnrc.yml</code> 設定 <code>nodeLinker</code> 即可。</p><pre><code class="language-yaml">nodeLinker: node-modules</code></pre><h3 id="速度">速度</h3><p>Yarn 2 比起 Yarn 1 也並非完全沒有缺點，Yarn 2 在 <code>yarn install</code> 會切分成多個步驟，分別是 Resolution、Fetch、Link。Resolution 和 Fetch 得益於新的設計會把所有 packages 儲存在 <code>.yarn/cache</code> 資料夾所以非常快，但是 Link 階段就慢一些，平均大概需要 30 秒至一分鐘以上，或許開啟 Zero-Installs 會快一些？</p><h3 id="Vendorizing">Vendorizing</h3><p>Yarn 2 會在 <code>.yarn</code> 儲存用 Webpack 編過的 Yarn 本體和擴充套件，大約佔 3 MB，這樣的好處是可以確保在不同環境下使用的 Yarn 版本都完全相同，缺點就是在 repo 裡會多了一些額外的檔案。</p><p>Yarn 官方更是把整個 <a href="https://github.com/yarnpkg/berry/tree/master/.yarn/cache"><code>.yarn/cache</code></a> 資料夾都 commit 到 Git 上，這樣的好處或許是能夠直接省去 fetch packages 的時間，但 git clone 的時間應該也會更長。</p><h2 id="部署流程">部署流程</h2><p>我們把部屬流程分成了三塊：測試→編譯→發佈。</p><img src="/blog/2020/05/10/yarn-2-and-monorepo/deploy-flow.png" class=""><h3 id="測試">測試</h3><p>在這個階段會對整個 monorepo 進行 lint 和 unit tests，目前整個過程需時大約不到 3 分鐘，所以沒有拆開來執行。</p><h3 id="編譯">編譯</h3><p>這個階段相對來說非常耗時，在執行前會用 <code>yarn changed</code> 來檢查 workspace 以及其依賴的套件有沒有變動，如果沒有的話就會直接跳過不做，藉此可以省下時間和成本。在圖中可以看到有些 job 花的時間特別短，就是因為那些沒有變動的部分都直接跳過了。</p><p>檢查的 script 如下，只需要三行，非常簡短。</p><pre><code class="language-shell">if ! yarn changed list --git-range &quot;$GIT_COMMIT_RANGE&quot; | grep -q &quot;$WORKSPACE_NAME&quot;; then  circleci-agent step haltfi</code></pre><p>在編譯完成後，會將 Docker image 部屬到測試環境，確保測試環境和 master branch 同步。</p><h3 id="發佈">發佈</h3><p>最後要發佈到正式環境時，會利用 <a href="https://semantic-release.gitbook.io/semantic-release/">semantic-release</a> 更新版號，把測試環境的 Docker image 複製到正式環境上，一切就大功告成了。</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2020/05/10/yarn-2-and-monorepo/yarn-logo.png&quot; class=&quot;&quot;&gt;
&lt;p&gt;今年初隨著公司的 repo 越來越多，我們決定把 web 前端部分轉為 monorepo 的形式，一開始花了一段時間研究各個 monorepo 方案的利弊，最後決定基於 Yarn 2 打造一套自用的工具。這篇文章會大概分析一些我試過的 monorepo 方案的優缺點，以及最後用 Yarn 2 的成果。&lt;/p&gt;</summary>
    
    
    
    
    <category term="JavaScript" scheme="https://zespia.me/tags/JavaScript/"/>
    
    <category term="Node.js" scheme="https://zespia.me/tags/Node-js/"/>
    
    <category term="Monorepo" scheme="https://zespia.me/tags/Monorepo/"/>
    
    <category term="Yarn" scheme="https://zespia.me/tags/Yarn/"/>
    
  </entry>
  
  <entry>
    <title>Pullup – 在 Kubernetes 上部署與測試 Pull Request</title>
    <link href="https://zespia.me/blog/2019/08/04/pullup-deploy-pull-requests-on-kubernetes/"/>
    <id>https://zespia.me/blog/2019/08/04/pullup-deploy-pull-requests-on-kubernetes/</id>
    <published>2019-08-04T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.891Z</updated>
    
    <content type="html"><![CDATA[<p>本篇接續<a href="/blog/2019/03/02/kosko-kubernetes-in-javascript/" title="上一篇文章">上一篇文章</a>，是我在 Dcard 開發的第二個 Kubernetes 工具。</p><h2 id="開發流程">開發流程</h2><figure>  <img src="/blog/2019/08/04/pullup-deploy-pull-requests-on-kubernetes/github-flow.png" class="">  <figcaption><a href="https://hackernoon.com/15-tips-to-enhance-your-github-flow-6af7ceb0d8a3">圖片來源</a></figcaption></figure><p>目前敝社的開發流程基於 <a href="https://guides.github.com/introduction/flow/">GitHub flow</a>，大致上如上圖所示。要開發新的功能時，會從主要分支 <code>master</code> 開出新的功能分支 <code>feature</code>，功能開發完成後會提出 pull request，審核通過後就會合併進 <code>master</code>，並部署到 staging 伺服器上。</p><p>通常在審核 pull request 時，我們僅會檢視程式碼和附帶的測試，如果一切順利而且 CI 測試通過的話，就會直接合併進 <code>master</code>。然而在某些情況下，特別是以前端極少測試的狀況來說，只看程式碼有時無法看出問題所在，必須實際執行才能確保程式能夠正常運作，有時也可能需要 PM 確認程式符合 spec，或是讓設計師確認程式符合設計稿。</p><p>對於工程師來說，要把程式執行起來並不困難，但對於其他人來說，光是設定環境可能就是件讓人頭痛的事了。我們常用的方法是直接讓他們透過區網或 <a href="https://ngrok.com/">ngrok</a> 連到工程師的電腦上，有時甚至為了上 staging 伺服器就直接合併進 <code>master</code> 了。讓還沒審核通過的程式碼進入 <code>master</code> 不僅可能造成 staging 伺服器的異常，也有可能會影響其他工程師的開發進度。</p><span id="more"></span><h2 id="部署-Pull-Request">部署 Pull Request</h2><p>為了解決這個問題，我想到的解決方法是利用 webhook 接收 GitHub 的 pull request 事件，在建立新的 pull request 時，自動把程式碼部署到 Kubernetes 上，讓使用者可以透過子網域測試 pull request，有點類似 <a href="https://www.netlify.com/docs/continuous-deployment/#branches-deploys">Netlify 的 Deploy Preview</a>，能夠在 pull request 建立時自動部屬到子網域 <code>deploy-preview-42--yoursitename.netlify.com</code>。</p><p>Pullup 在收到 pull request 事件時，會以原資源為基準複製新的資源，並自動在 pull request 更新時一併更新資源。以上圖為例，通常大部分部屬在 Kubernetes 裡的服務都會包含 <code>Deployment</code> 和 <code>Service</code>，Pullup 能更新 Deployment 所使用的 Docker image，並修改 Service 的 selector，確保 <code>pr1.example.dev</code> 能存取到新服務。</p><img src="/blog/2019/08/04/pullup-deploy-pull-requests-on-kubernetes/deploy-pull-request.svg" class=""><p>當 pull request 被合併或關閉時，Pullup 會利用 <a href="https://kubernetes.io/docs/concepts/workloads/controllers/garbage-collection/">Garbage Collection</a> 刪除已部屬的資源，避免資源浪費。</p><img src="/blog/2019/08/04/pullup-deploy-pull-requests-on-kubernetes/delete-pull-request.svg" class=""><h2 id="安裝">安裝</h2><p>以下指令會在 <code>pullup</code> namespace 中安裝 Pullup 相關的 CRD 和各種必要元件。</p><pre><code class="language-shell">kubectl apply -f https://github.com/tommy351/pullup/releases/latest/download/pullup-deployment.yml</code></pre><p>你可在 <a href="https://github.com/tommy351/pullup/blob/master/deployment">deployment</a> 資料夾中檢視原始碼，YAML 檔中包含：</p><ul><li>Pullup CRD</li><li>服務帳號 (service account)</li><li>用於存取 Pullup CRD、寫入事件以及 leader election 的 RBAC，你還必須根據<a href="https://github.com/tommy351/pullup/#rbac">文件</a>來設定其他資源的 RBAC。這是為了讓使用者便於控制 Pullup 的權限。</li><li>Controller 和 webhook 的 deployment</li><li>Service</li></ul><p>更詳細的說明請參考<a href="https://github.com/tommy351/pullup">文件</a>。</p><h2 id="使用範例">使用範例</h2><h3 id="Deployment-Service">Deployment + Service</h3><p>常見的使用範例是部屬新的 Deployment 並搭配相對應的 Service。下面的範例會更新 Deployment 的 image 和 labels，並修改 Service 的 selector。除了範例裡的欄位以外，你也可以更新其他既有欄位，Pullup 會使用類似 kustomize 的策略去建立資源。</p><pre><code class="language-yaml">apiVersion: pullup.dev/v1alpha1kind: Webhookmetadata:  name: examplespec:  repositories:    - type: github      name: tommy351/pullup  resources:    - apiVersion: apps/v1      kind: Deployment      metadata:        name: example      spec:        # 更新 selector 和 labels 以避免和原資源混淆        selector:          matchLabels:            app: &quot;&#123;&#123; .Name &#125;&#125;&quot;        template:          metadata:            labels:              app: &quot;&#123;&#123; .Name &#125;&#125;&quot;          spec:            - name: foo              # 更新 image              image: &quot;tommy351/foo:&#123;&#123; .Spec.Head.SHA &#125;&#125;&quot;    - apiVersion: v1      kind: Service      metadata:        name: example      spec:        # 對應到新 Deployment 的 labels        selector:          app: &quot;&#123;&#123; .Name &#125;&#125;&quot;</code></pre><h3 id="子網域">子網域</h3><p>如果想要自動建立子網域，因為 Pullup 僅能用於建立新資源，無法修改現有的 Ingress，可以改用 <a href="https://github.com/heptio/contour">Contour</a> 的 <a href="https://github.com/heptio/contour/blob/master/docs/ingressroute.md">IngressRoute</a>。<a href="https://github.com/heptio/contour">Contour</a> 利用 <a href="https://www.envoyproxy.io/">Envoy</a> 實作了 Ingress controller，它提供獨立的 <a href="https://github.com/heptio/contour/blob/master/docs/ingressroute.md">IngressRoute</a> 資源比較容易以 pull request 為單位建立子網域。</p><pre><code class="language-yaml">apiVersion: pullup.dev/v1alpha1kind: Webhookmetadata:  name: examplespec:  resources:    # 中略    - apiVersion: contour.heptio.com/v1beta1      kind: IngressRoute      metadata:        name: example      spec:        virtualhost:          fqdn: &quot;&#123;&#123; .Name &#125;&#125;.example.dev&quot;        routes:          - match: /            services:              - name: &quot;&#123;&#123; .Name &#125;&#125;&quot;                port: 80</code></pre><h3 id="TLS-憑證">TLS 憑證</h3><p>利用 <a href="https://github.com/jetstack/cert-manager">cert-manager</a> 自動申請並更新 TLS 憑證，以下僅提供 Certificate 部分的範例，詳細的設定方式請參照文件。</p><h4 id="Wildcard">Wildcard</h4><p>直接申請 wildcard TLS 憑證，優點是只需要申請一次憑證就可用於所有 pull request，缺點則是每次申請憑證耗時需數分鐘，且僅能利用 DNS-01 驗證網域。</p><pre><code class="language-yaml">apiVersion: certmanager.k8s.io/v1alpha1kind: Certificatemetadata:  name: examplespec:  secretName: example-tls  issuerRef:    name: letsencrypt-prod    kind: Issuer  commonName: *.example.dev  dnsNames: [&quot;*.example.dev&quot;, &quot;.example.dev&quot;]  acme:    config:      # Wildcard 憑證只能利用 DNS-01 驗證      - dns01:          provider: cloudflare        domains: [&quot;*.example.dev&quot;, &quot;.example.dev&quot;]</code></pre><h4 id="單一網域">單一網域</h4><p>如果你無法透過 DNS-01 驗證，可以試試看只申請單一網域的 TLS 憑證，通常每次申請憑證耗時不到一分鐘，比 wildcard 憑證快速許多。</p><pre><code class="language-yaml">apiVersion: pullup.dev/v1alpha1kind: Webhookmetadata:  name: examplespec:  resources:    # 中略    - apiVersion: certmanager.k8s.io/v1alpha1      kind: Certificate      metadata:        name: &quot;&#123;&#123; .Name &#125;&#125;&quot;      spec:        secretName: &quot;&#123;&#123; .Name &#125;&#125;-tls&quot;        issuerRef:          name: letsencrypt-prod          kind: Issuer        commonName: &quot;&#123;&#123; .Name &#125;&#125;.example.dev&quot;        dnsNames: [&quot;&#123;&#123; .Name &#125;&#125;.example.dev&quot;]        acme:          config:            # 可以用 DNS-01 或 HTTP-01 驗證            - dns01:                provider: cloudflare              domains: [&quot;&#123;&#123; .Name &#125;&#125;.example.dev&quot;]</code></pre><h2 id="結語">結語</h2><p>這是我第一次開發 Kubernetes controller，一開始花了很多時間摸索，中途才發現了 <a href="https://github.com/kubernetes-sigs/controller-runtime">controller-runtime</a> 和 <a href="https://coreos.com/operators/">Operators SDK</a>，大幅地節省了我的開發時間，之後預計再寫一篇關於 <a href="https://github.com/kubernetes-sigs/controller-runtime">controller-runtime</a> 的基本教學，以及 Pullup 實際上如何應用 <a href="https://github.com/kubernetes-sigs/controller-runtime">controller-runtime</a>。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;本篇接續&lt;a href=&quot;/blog/2019/03/02/kosko-kubernetes-in-javascript/&quot; title=&quot;上一篇文章&quot;&gt;上一篇文章&lt;/a&gt;，是我在 Dcard 開發的第二個 Kubernetes 工具。&lt;/p&gt;
&lt;h2 id=&quot;開發流程&quot;&gt;開發流程&lt;/h2&gt;
&lt;figure&gt;
  &lt;img src=&quot;/blog/2019/08/04/pullup-deploy-pull-requests-on-kubernetes/github-flow.png&quot; class=&quot;&quot;&gt;
  &lt;figcaption&gt;&lt;a href=&quot;https://hackernoon.com/15-tips-to-enhance-your-github-flow-6af7ceb0d8a3&quot;&gt;圖片來源&lt;/a&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;目前敝社的開發流程基於 &lt;a href=&quot;https://guides.github.com/introduction/flow/&quot;&gt;GitHub flow&lt;/a&gt;，大致上如上圖所示。要開發新的功能時，會從主要分支 &lt;code&gt;master&lt;/code&gt; 開出新的功能分支 &lt;code&gt;feature&lt;/code&gt;，功能開發完成後會提出 pull request，審核通過後就會合併進 &lt;code&gt;master&lt;/code&gt;，並部署到 staging 伺服器上。&lt;/p&gt;
&lt;p&gt;通常在審核 pull request 時，我們僅會檢視程式碼和附帶的測試，如果一切順利而且 CI 測試通過的話，就會直接合併進 &lt;code&gt;master&lt;/code&gt;。然而在某些情況下，特別是以前端極少測試的狀況來說，只看程式碼有時無法看出問題所在，必須實際執行才能確保程式能夠正常運作，有時也可能需要 PM 確認程式符合 spec，或是讓設計師確認程式符合設計稿。&lt;/p&gt;
&lt;p&gt;對於工程師來說，要把程式執行起來並不困難，但對於其他人來說，光是設定環境可能就是件讓人頭痛的事了。我們常用的方法是直接讓他們透過區網或 &lt;a href=&quot;https://ngrok.com/&quot;&gt;ngrok&lt;/a&gt; 連到工程師的電腦上，有時甚至為了上 staging 伺服器就直接合併進 &lt;code&gt;master&lt;/code&gt; 了。讓還沒審核通過的程式碼進入 &lt;code&gt;master&lt;/code&gt; 不僅可能造成 staging 伺服器的異常，也有可能會影響其他工程師的開發進度。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Kubernetes" scheme="https://zespia.me/tags/Kubernetes/"/>
    
    <category term="Go" scheme="https://zespia.me/tags/Go/"/>
    
  </entry>
  
  <entry>
    <title>Kosko – 用 JavaScript 管理 Kubernetes</title>
    <link href="https://zespia.me/blog/2019/03/02/kosko-kubernetes-in-javascript/"/>
    <id>https://zespia.me/blog/2019/03/02/kosko-kubernetes-in-javascript/</id>
    <published>2019-03-02T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.887Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2019/03/02/kosko-kubernetes-in-javascript/costco.jpg" class=""><p>敝社從 2016 年就開始 <a href="https://kubernetes.io/">Kubernetes</a>，應該能算是相當早期的使用者了，也因此我們累積了一堆的 Kubernetes YAML 設定檔，從某個時間開始 staging 和 production 環境的設定檔更開始分裂，自此以來一直無法合併。因此這次的目標就是：</p><ul><li>整合各環境的設定</li><li>能夠重複利用</li><li>能夠驗證設定是否正確</li></ul><span id="more"></span><h2 id="現有工具">現有工具</h2><p>一開始我先從 <a href="https://github.com/ramitsurana/awesome-kubernetes#configuration">awesome-kubernetes</a> 尋找現有的設定管理工具，以下列出一些我覺得還不錯的工具以及它們的優缺點。</p><h3 id="kustomize"><a href="https://github.com/kubernetes-sigs/kustomize">kustomize</a></h3><img src="/blog/2019/03/02/kosko-kubernetes-in-javascript/kustomize-overlay.jpg" class=""><ul><li>👍 屬於 <a href="https://github.com/kubernetes/community/blob/master/sig-cli/README.md">sig-cli</a> 的專案，應該能夠保障更新活躍且不會突然棄坑。</li><li>👍 學習成本低，使用熟悉的 YAML。</li><li>👍 用 Overlay 的概念讓不同環境的參數去 patch 基礎設定檔。</li><li>👎 沒有驗證設定的功能。</li></ul><h3 id="ksonnet"><a href="https://ksonnet.io/">ksonnet</a></h3><img src="/blog/2019/03/02/kosko-kubernetes-in-javascript/ksonnet-overview.svg" class=""><ul><li>👍 彈性極高，能透過變數及函數共享設定。</li><li>👍 支援 <a href="https://helm.sh/">Helm</a>。</li><li>👍 能夠驗證設定。</li><li>👎 使用比較少見的 <a href="https://jsonnet.org/">jsonnet</a>，需要另外花時間學習，而且資源也比較少。</li><li>😢 已<a href="https://blogs.vmware.com/cloudnative/2019/02/05/welcoming-heptio-open-source-projects-to-vmware/">停止維護</a>。</li></ul><h3 id="kapitan"><a href="https://github.com/deepmind/kapitan">kapitan</a></h3><img src="/blog/2019/03/02/kosko-kubernetes-in-javascript/kapitan-overview.png" class=""><ul><li>👍 能夠管理 secrets。</li><li>👍 能夠產生文件、Terraform 設定以及一些 scripts。</li><li>👍 用 Inventory 的概念管理不同環境和共享設定。</li><li>👎 使用 <a href="https://jsonnet.org/">jsonnet</a>，理由同上。</li><li>👎 使用 <a href="http://jinja.pocoo.org/">jinja2</a> 做為 template engine，我不太能夠理解既然都用上 <a href="https://jsonnet.org/">jsonnet</a> 的話那為何還需要用 templates？</li></ul><h2 id="造輪子">造輪子</h2><p>因為現有工具對我來說都有些不足的地方，所以我最後決定根據 <a href="https://ksonnet.io/">ksonnet</a> 的概念，並稍微調整一些部份讓我用起來更順手一些：</p><ul><li>改用 JavaScript，因為資源豐富而且大家都會用。</li><li>不支援 <a href="https://helm.sh/">Helm</a>，因為我們沒在用。</li><li>只負責產生和驗證 YAML，完全不和 Kubernetes cluster 接觸。</li></ul><p>相較於 <a href="https://ksonnet.io/">ksonnet</a> 來說砍了很多功能，所以實際上實作並沒有花太多時間，麻煩的是把現有的上百個 YAML 檔轉換成 JavaScript、整合 staging 和 production 環境並實際在 Kubernetes 上測試，大約花了 5 週才完成所有工作，最後的結果非常可觀。</p><img src="/blog/2019/03/02/kosko-kubernetes-in-javascript/compare-commits.jpg" class=""><h2 id="kosko"><a href="https://github.com/tommy351/kosko/">kosko</a></h2><h3 id="安裝">安裝</h3><pre><code class="language-shell">npm install kosko -g</code></pre><h3 id="初始化">初始化</h3><pre><code class="language-shell">kosko init examplecd examplenpm install</code></pre><h3 id="產生-YAML">產生 YAML</h3><pre><code class="language-shell"># 輸出到 consolekosko generate# Apply 到 Kubernetes cluster 上kosko generate | kubectl apply -f -</code></pre><h3 id="驗證-YAML">驗證 YAML</h3><p>其實在執行 <code>kosko generate</code> 時也會順帶驗證，這個指令只是用來方便在 CI 上跑測試時不會把設定輸出到 log。</p><pre><code class="language-shell">kosko validate</code></pre><h3 id="轉移現有的-YAML">轉移現有的 YAML</h3><pre><code class="language-shell"># 單一檔案kosko migrate -f nginx-deployment.yml# 資料夾kosko migrate -f nginx</code></pre><h3 id="資料夾結構">資料夾結構</h3><p>預設的資料夾結構參考 <a href="https://ksonnet.io/">ksonnet</a>，<code>components</code> 資料夾用來放 manifests，<code>environments</code> 則是各環境的參數。</p><pre><code class="language-shell">.├── components│   ├── nginx.js│   └── postgres.js├── environments│   ├── staging│   │   ├── index.js│   │   ├── nginx.js│   │   └── postgres.js│   └── production│       ├── index.js│       ├── nginx.js│       └── postgres.js├── kosko.toml└── templates</code></pre><p>但實際使用時發現這種結構在 components 過多時使用起來必須要在 <code>components</code> 和 <code>environments</code> 兩個資料夾來回，不太方便，所以最後加上了自訂路徑的功能。</p><pre><code class="language-toml">[paths.environment]component = &quot;components/#&#123;component&#125;/#&#123;environment&#125;&quot;</code></pre><p>上述的設定改變了 component environments 的檔案路徑，變成了下列的結構：</p><pre><code class="language-shell">.├── components│   ├── nginx│   │   ├── index.js│   │   ├── staging.js│   │   └── production.js│   └── postgres│       ├── index.js│       ├── staging.js│       └── production.js├── environments│   ├── staging.js│   └── production.js├── kosko.toml└── templates</code></pre><h2 id="kubernetes-models-ts"><a href="https://github.com/tommy351/kubernetes-models-ts/">kubernetes-models-ts</a></h2><p>為了能夠驗證設定是否符合 schema，我根據 <a href="https://github.com/kubernetes/kubernetes/tree/master/api/openapi-spec">Kubernetes 的 OpenAPI specification</a> 產生了相對應的 TypeScript。不僅能夠在編譯時找出一些基本的型別錯誤，即使沒有使用 TypeScript 也能透過 JSON schema 驗證設定。</p><p>下面列出一些開發時遇到的問題：</p><h3 id="JSON-沒有-undefined">JSON 沒有 undefined</h3><p>JSON 實際上是沒有 <code>undefined</code> 型別的，雖然 <code>JSON.stringify</code> 會直接忽略，但是 <a href="https://github.com/nodeca/js-yaml">js-yaml</a> 卻不會，所以我必須在 <code>toJSON()</code> 函數裡刪除所有 <code>undefined</code> 的欄位。</p><h3 id="int-or-string">int-or-string</h3><p>在 Kubernetes 裡有一種特殊型別叫做 <code>int-or-string</code>，雖然在 JSON schema 是 <code>string</code>，但在 TypeScript 必須轉為 <code>string | number</code>，不然編譯器常會報錯。舉例來說，<code>Service</code> 中的 <code>targetPort</code> 就是常見的情況，它同時可以是 port number (int) 或 named port (string)。</p><pre><code class="language-js">new Service(&#123;  spec: &#123;    ports: [&#123; port: 80, targetPort: 80 &#125;]  &#125;&#125;);</code></pre><h3 id="編輯器支援">編輯器支援</h3><p>最後炫耀一下，在支援 TypeScript 的編輯器裡寫設定有多爽 😎</p><div class="video-container"><iframe src="https://www.youtube.com/embed/CFAhIFmVNoU" frameborder="0" loading="lazy" allowfullscreen></iframe></div><h2 id="結語">結語</h2><p>一開始其實是打算用 <a href="https://ksonnet.io/">ksonnet</a> 的，但是必須要另外學 <a href="https://jsonnet.org/">jsonnet</a> 很麻煩。開始造輪子大約一個月後發現 ksonnet 竟然停止維護了，不禁感嘆幸好當初選擇了自己造輪子？</p><p>其他使用 Kubernetes 的大大們可能也會遇到設定管理的問題，不知道各位是怎麼解決的？是使用官方的 <a href="https://github.com/kubernetes-sigs/kustomize">kustomize</a>？還是也自己開發工具？又是如何管理 secrets 呢？如果可以的話，希望能互相交流。</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2019/03/02/kosko-kubernetes-in-javascript/costco.jpg&quot; class=&quot;&quot;&gt;
&lt;p&gt;敝社從 2016 年就開始 &lt;a href=&quot;https://kubernetes.io/&quot;&gt;Kubernetes&lt;/a&gt;，應該能算是相當早期的使用者了，也因此我們累積了一堆的 Kubernetes YAML 設定檔，從某個時間開始 staging 和 production 環境的設定檔更開始分裂，自此以來一直無法合併。因此這次的目標就是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;整合各環境的設定&lt;/li&gt;
&lt;li&gt;能夠重複利用&lt;/li&gt;
&lt;li&gt;能夠驗證設定是否正確&lt;/li&gt;
&lt;/ul&gt;</summary>
    
    
    
    
    <category term="JavaScript" scheme="https://zespia.me/tags/JavaScript/"/>
    
    <category term="Kubernetes" scheme="https://zespia.me/tags/Kubernetes/"/>
    
  </entry>
  
  <entry>
    <title>在 Monorepo 裡用 TypeScript</title>
    <link href="https://zespia.me/blog/2019/02/25/typescript-monorepo/"/>
    <id>https://zespia.me/blog/2019/02/25/typescript-monorepo/</id>
    <published>2019-02-25T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.887Z</updated>
    
    <content type="html"><![CDATA[<p>最近在開發公司內部使用的工具時，心血來潮想用 <a href="https://lernajs.io/">Lerna</a> 來管理 <a href="https://en.wikipedia.org/wiki/Monorepo">monorepo</a>，但是又想用 <a href="https://www.typescriptlang.org/">TypeScript</a>，結果碰到了一些編譯上的問題，例如套件之間互相依賴時，TypeScript 不知道依賴關係而無法了解編譯順序，導致整個 monorepo 無法編譯成功。</p><p>之後發現了 <a href="https://github.com/Quramy/lerna-yarn-workspaces-example">Quramy/lerna-yarn-workspaces-example</a> 這個範例，裡頭用了 Yarn、Lerna 和 TypeScript，在這之中 Yarn 並不是必須的可以忽視，重點是在 TypeScript 3.0 推出的 <a href="https://www.typescriptlang.org/docs/handbook/project-references.html">Project References</a>，這個功能讓 TypeScript 能夠知道各個模組之間的依賴關係，因此自動解決了編譯順序的問題。</p><p>要使用 <a href="https://www.typescriptlang.org/docs/handbook/project-references.html">Project References</a> 的話必須在 <code>tsconfig.json</code> 加上下列選項。</p><pre><code class="language-json">&#123;  &quot;compilerOptions&quot;: &#123;    &quot;composite&quot;: true,    &quot;declaration&quot;: true,    &quot;rootDir&quot;: &quot;src&quot;,    &quot;outDir&quot;: &quot;dist&quot;  &#125;&#125;</code></pre><ul><li><code>composite</code> - 讓 TypeScript 能夠快速找到被引用專案的位置。</li><li><code>declaration</code> - 編譯定義檔 (<code>.d.ts</code>)。</li><li><code>rootDir</code> - 設定專案的根目錄，預設是 <code>tsconfig.json</code> 的所屬資料夾。</li><li><code>outDir</code> - 編譯的輸出路徑。</li></ul><p>並在要引用的地方加上 <code>references</code>。</p><pre><code class="language-json">&#123;  &quot;references&quot;: [    &#123; &quot;path&quot;: &quot;../x-core&quot; &#125;  ]&#125;</code></pre><p>然後在執行 <code>tsc</code> 時加上 <code>-b</code> 選項以及要編譯的專案路徑，就能順利編譯。</p><pre><code class="language-shell">tsc -b packages/x-core packages/x-cli</code></pre><p>因為專案比較多，所以我另外寫了一個 script 自動找出所有需要編譯的專案路徑。</p><pre><code class="language-js">&quot;use strict&quot;;const spawn = require(&quot;cross-spawn&quot;);const globby = require(&quot;globby&quot;);const &#123; dirname &#125; = require(&quot;path&quot;);const TSC = &quot;tsc&quot;;const pkgs = globby.sync(&quot;packages/*/tsconfig.json&quot;).map(dirname);const args = [&quot;-b&quot;, ...pkgs, ...process.argv.slice(2)];console.log(TSC, ...args);spawn.sync(TSC, args, &#123;  stdio: &quot;inherit&quot;&#125;);</code></pre><p>執行 <code>tsc</code> 時加上 <code>--watch</code> 就能夠監看檔案變化並自動重新編譯。</p><pre><code class="language-shell">tsc -b packages/x-core packages/x-cli --watch</code></pre><p>執行 <code>tsc</code> 時加上 <code>--clean</code> 則是能夠自動根據 <code>outDir</code> 設定清除編譯後的檔案。</p><pre><code class="language-shell">tsc -b packages/x-core packages/x-cli --clean</code></pre><p>可以在 <a href="https://github.com/tommy351/kosko">tommy351/kosko</a> 看到實際運用的範例。</p>]]></content>
    
    
      
      
    <summary type="html">&lt;p&gt;最近在開發公司內部使用的工具時，心血來潮想用 &lt;a href=&quot;https://lernajs.io/&quot;&gt;Lerna&lt;/a&gt; 來管理 &lt;a href=&quot;https://en.wikipedia.org/wiki/Monorepo&quot;&gt;monorepo&lt;/a&gt;，但是又想用 &lt;a</summary>
      
    
    
    
    
    <category term="JavaScript" scheme="https://zespia.me/tags/JavaScript/"/>
    
    <category term="TypeScript" scheme="https://zespia.me/tags/TypeScript/"/>
    
    <category term="Lerna" scheme="https://zespia.me/tags/Lerna/"/>
    
    <category term="Monorepo" scheme="https://zespia.me/tags/Monorepo/"/>
    
  </entry>
  
  <entry>
    <title>用 Elixir 和 hackney 做 proxy</title>
    <link href="https://zespia.me/blog/2016/07/24/build-proxy-with-elixir-and-hackney/"/>
    <id>https://zespia.me/blog/2016/07/24/build-proxy-with-elixir-and-hackney/</id>
    <published>2016-07-24T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.875Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2016/07/24/build-proxy-with-elixir-and-hackney/58015555.png" class="" title="hibimegane - おにんぎょうあそび (id&#x3D;58015555)"><p>前幾個月改版時，我決定用 <a href="http://elixir-lang.org/">Elixir</a> 來實作 OAuth server + Proxy，這是一門結合了 <a href="https://www.erlang.org/">Erlang</a> VM 和 <a href="https://www.ruby-lang.org">Ruby</a> 語法的程式語言，可以很容易運用 <a href="https://www.erlang.org/">Erlang</a> 的特性做出低延遲、高並發且高容錯度的系統，又不用學習 <a href="https://www.erlang.org/">Erlang</a> 比較特殊的 Prolog 式語法（但是你可能還是多少要懂 <a href="https://www.erlang.org/">Erlang</a> 語法，因為很多時候你會直接運用 Erlang library）。</p><p>Erlang 的這些強大特性拿來做 OAuth server + Proxy 似乎有些大材小用，不過因為我爽，所以就決定用 <a href="http://elixir-lang.org/">Elixir</a> 來寫了。</p><span id="more"></span><h2 id="OAuth-Server">OAuth Server</h2><p>實作 OAuth 的部分很無聊就不在本文贅述了，我強烈推薦 <a href="https://blog.yorkxin.org/2013/09/30/oauth2-1-introduction">Yu-Cheng Chuang 大大寫的 OAuth 2.0 筆記</a>，搭配 <a href="https://tools.ietf.org/html/rfc6749">RFC 6479</a> spec 很快就能實作出符合規格的 OAuth server。</p><h2 id="Proxy">Proxy</h2><p>接著就是今天的重頭戲 Proxy，用 <a href="http://elixir-lang.org/">Elixir</a> 實作可能不會是你的最佳選擇，所以看看就好，不要模仿。</p><p>首先必須先選個 HTTP client，在 Node.js 有個非常強大的 <a href="https://github.com/request/request">request</a>，而 Elixir 有：</p><ul><li><a href="https://github.com/edgurgel/httpoison">HTTPoison</a>: 基於 <a href="https://github.com/benoitc/hackney">hackney</a></li><li><a href="https://github.com/myfreeweb/httpotion">HTTPotion</a>: 基於 <a href="https://github.com/cmullaparthi/ibrowse">ibrowse</a></li></ul><p>或是 Erlang：</p><ul><li><a href="https://github.com/benoitc/hackney">hackney</a></li><li><a href="https://github.com/cmullaparthi/ibrowse">ibrowse</a></li><li><a href="https://github.com/esl/lhttpc">lhttpc</a></li><li><a href="https://github.com/esl/fusco">fusco</a></li><li><a href="https://github.com/ninenines/gun">gun</a></li><li><a href="https://github.com/inaka/shotgun">shotgun</a>: <a href="https://github.com/ninenines/gun">gun</a> + Server-sent Events</li></ul><p>Elixir 的 library 因為經過封裝而損失了一些比較底層的功能，所以我決定直接使用 Erlang library，這時我就瞭解到學會 Erlang 的重要性，因為有些 library 是沒有寫文件的，必須直接讀原始碼才能瞭解如何運用。</p><h3 id="hackney"><a href="https://github.com/benoitc/hackney">hackney</a></h3><p><a href="https://github.com/benoitc/hackney">hackney</a> 是我第一個接觸的 library，它是這幾個 library 裡更新最勤勞，而且在 Elixir 中使用也比較不突兀，用起來最順手的 library，但是因為一些已知問題（<a href="https://github.com/benoitc/hackney/issues/191">#191</a>, <a href="https://github.com/benoitc/hackney/issues/267">#267</a>，可能會在 <a href="https://github.com/benoitc/hackney/issues/275">hackney 2.0</a> 解決），所以我決定尋求其他 library。</p><h3 id="ibrowse"><a href="https://github.com/cmullaparthi/ibrowse">ibrowse</a></h3><p><a href="https://github.com/cmullaparthi/ibrowse">ibrowse</a> 是這裡頭第二靠譜的 library，但是運用上比 <a href="https://github.com/benoitc/hackney">hackney</a> 麻煩一些，要事先把 binary 轉成 list，而且可能是 HTTP 規格實作上的差異導致有些 request 無法正確完成。</p><h3 id="lhttpc"><a href="https://github.com/esl/lhttpc">lhttpc</a></h3><p>已停止維護。</p><h3 id="fusco"><a href="https://github.com/esl/fusco">fusco</a></h3><p>宣稱還在早期開發階段，然而已經超過兩年沒有任何 commit，而且沒有文件，是給人用的嗎？</p><h3 id="gun"><a href="https://github.com/ninenines/gun">gun</a></h3><p>與 <a href="https://github.com/ninenines/cowboy">cowboy</a> 系出同門，都是 <a href="http://ninenines.eu/">Nine Nines</a> 的作品，感覺相當不錯，可惜的是使用到了 <a href="https://github.com/ninenines/cowlib">cowlib</a> 1.3.0，和 <a href="https://github.com/ninenines/cowboy">cowboy</a> 1 使用的 <a href="https://github.com/ninenines/cowlib">cowlib</a> 1.0 衝突，因此無法使用。</p><h3 id="shotgun"><a href="https://github.com/inaka/shotgun">shotgun</a></h3><p>因為 <a href="https://github.com/ninenines/gun">gun</a> 沒辦法用，所以 <a href="https://github.com/inaka/shotgun">shotgun</a> 自然也用不了了。</p><h2 id="調整效能">調整效能</h2><p>既然 Erlang 世界裡沒有其他更好的選擇了，那麼我唯一能做的就只有慢慢壓榨出效能，一開始的 proxy 很陽春，在網路上找到的大部分範例都這樣實作：</p><pre><code class="language-elixir">defmodule Proxy do  import Plug.Conn  def init(opts), do: opts  def call(conn, _) do    &#123;:ok, client&#125; = :hackney.request(method_to_atom(method), make_url(url), conn.req_headers, :stream, [])    conn    |&gt; write_proxy(client)    |&gt; read_proxy(client)  end  defp method_to_atom(method) do    method |&gt; String.downcase |&gt; String.to_atom  end  defp make_url(conn) do    base = &quot;http://localhost:4000&quot; &lt;&gt; conn.request_path    case conn.query_string do      &quot;&quot; -&gt; base      qs -&gt; base &lt;&gt; &quot;?&quot; &lt;&gt; qs    end  end  defp write_proxy(conn, client) do    case read_body(conn, []) do      &#123;:ok, body, conn&#125; -&gt;        :hackney.send_body(client, body)        conn      &#123;:more, body, conn&#125; -&gt;        :hackney.send_body(client, body)        write_proxy(conn, client)    end  end  defp read_proxy(conn, client) do    &#123;:ok, status, headers, client&#125; = :hackney.start_response(client)    &#123;:ok, body&#125; = :hackney.body(client)    %&#123;conn | resp_headers: headers&#125;    |&gt; send_resp(status, body)  endend</code></pre><p>很明顯有些地方可以改善：</p><h3 id="靜態-method-to-atom">靜態 <code>method_to_atom</code></h3><p><code>method_to_atom</code> 函數雖然簡單，只是把 <code>method</code> 改成小寫後再轉為 atom，但如果能夠節省每次的轉換開銷的話就能快些。</p><pre><code class="language-elixir">for method &lt;- [&quot;GET&quot;, &quot;POST&quot;, &quot;PUT&quot;, &quot;PATCH&quot;, &quot;DELETE&quot;, &quot;HEAD&quot;, &quot;OPTIONS&quot;] do  defp method_to_atom(unquote(method)) do    unquote(method |&gt; String.downcase |&gt; String.to_atom)  endend</code></pre><h3 id="Stream-response">Stream response</h3><p><code>:hackney.body</code> 會一次讀完所有 response body，但邊讀邊寫想必更有效率。我的做法是先判斷 <code>transfer-encoding: chunked</code> header，如果存在的話就以 chunk 形式回傳。</p><pre><code class="language-elixir">defp read_body(conn, client) do  &#123;:ok, status, headers, client&#125; = :hackney.start_response(client)  case List.Keyfind(headers, &quot;transfer-encoding&quot;, 0) do    &#123;_, &quot;chunked&quot;&#125; -&gt;      conn      |&gt; send_chunked(status)      |&gt; stream_body(client)    _ -&gt;      &#123;:ok, body&#125; = :hackney.body(client)      conn |&gt; send_resp(status, body)  endenddefp normalize_headers(headers) do  Enum.map(headers, fn &#123;k, v&#125; -&gt;    &#123;String.downcase(k), v&#125;  end)enddefp stream_body(conn, client) do  case :hackney.stream_body(client) do    &#123;:ok, body&#125; -&gt;      &#123;:ok, conn&#125; = chunk(conn, body)      stream_body(conn, client)    :done -&gt; conn  endend</code></pre><h3 id="Async-response">Async response</h3><p>hackney 加上 <code>async</code> 選項後，可以用 <code>receive</code> 來一步步的接收到 status, headers 和 body，但實際上使用會碰到許多問題（<a href="https://github.com/benoitc/hackney/issues/224">#224</a>, <a href="https://github.com/benoitc/hackney/issues/267">#267</a>），因此作罷。</p><h3 id="直接使用-Cowboy">直接使用 Cowboy</h3><p>看來 hackney 方面已經沒什麼好調整了，只好把觸手伸到 Plug 上了，透過 Plug 送 body 需要額外的開銷，那麼直接使用 Cowboy 說不定會更快？以這樣的想法不斷琢磨後，最後的成品就是 <a href="https://github.com/tommy351/plug-proxy">PlugProxy</a>。</p><pre><code class="language-elixir">forward &quot;/v2&quot;, to: PlugProxy, upstream: &quot;http://localhost:4000&quot;</code></pre><p>使用上很簡單，不過實際上浪費了我很多時間，而且效能也真的不算多好，中途遇到一些 hackney 的坑都讓我想另外造一個 HTTP client 的輪子了，用 Node.js 的 <a href="https://github.com/request/request">request</a> 解決可能簡單的多吧哈哈。</p><pre><code class="language-js">const request = require('request');req.pipe(request.get('http://localhost:4000')).pipe(res);</code></pre><h2 id="後記">後記</h2><img src="/blog/2016/07/24/build-proxy-with-elixir-and-hackney/IMAG0135.jpg" class="" title="小暴君和蘿莉控"><p>在改版完成的一個月後，<s>我就回老家種田了</s>，就和朋友一起去極上爆音體驗震撼人心（物理）的ガルパン＋聖地巡禮了，旅遊真他媽爽啊！</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2016/07/24/build-proxy-with-elixir-and-hackney/58015555.png&quot; class=&quot;&quot; title=&quot;hibimegane - おにんぎょうあそび (id&amp;#x3D;58015555)&quot;&gt;
&lt;p&gt;前幾個月改版時，我決定用 &lt;a href=&quot;http://elixir-lang.org/&quot;&gt;Elixir&lt;/a&gt; 來實作 OAuth server + Proxy，這是一門結合了 &lt;a href=&quot;https://www.erlang.org/&quot;&gt;Erlang&lt;/a&gt; VM 和 &lt;a href=&quot;https://www.ruby-lang.org&quot;&gt;Ruby&lt;/a&gt; 語法的程式語言，可以很容易運用 &lt;a href=&quot;https://www.erlang.org/&quot;&gt;Erlang&lt;/a&gt; 的特性做出低延遲、高並發且高容錯度的系統，又不用學習 &lt;a href=&quot;https://www.erlang.org/&quot;&gt;Erlang&lt;/a&gt; 比較特殊的 Prolog 式語法（但是你可能還是多少要懂 &lt;a href=&quot;https://www.erlang.org/&quot;&gt;Erlang&lt;/a&gt; 語法，因為很多時候你會直接運用 Erlang library）。&lt;/p&gt;
&lt;p&gt;Erlang 的這些強大特性拿來做 OAuth server + Proxy 似乎有些大材小用，不過因為我爽，所以就決定用 &lt;a href=&quot;http://elixir-lang.org/&quot;&gt;Elixir&lt;/a&gt; 來寫了。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Elixir" scheme="https://zespia.me/tags/Elixir/"/>
    
    <category term="hackney" scheme="https://zespia.me/tags/hackney/"/>
    
  </entry>
  
  <entry>
    <title>使用 Ansible 管理 Google Compute Engine</title>
    <link href="https://zespia.me/blog/2016/02/01/ansible-gce/"/>
    <id>https://zespia.me/blog/2016/02/01/ansible-gce/</id>
    <published>2016-02-01T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.869Z</updated>
    
    <content type="html"><![CDATA[<p>最近忙著佈署新的測試伺服器，而 Google Cloud Platform 剛好有提供 $300 兩個月的免費試用，且在台灣又有設點，所以我就決定拿 Google Compute Engine 來建置測試伺服器了。</p><h2 id="Dynamic-inventory">Dynamic inventory</h2><p>在開始之前，先稍微解釋一下何謂 Ansible 的 inventory，inventory 即代表伺服器，在 Ansible 中，可把伺服器列在 inventory file 中，藉此來分類伺服器，例如：</p><pre><code class="language-ini">[webservers]1.2.3.4 ansible_ssh_user=john5.6.7.8 ansible_ssh_user=john[dbservers]9.10.11.12 ansible_ssh_user=mary</code></pre><p>然而伺服器一多，管理 inventory file 就顯得有些麻煩，這時可以利用 <a href="http://docs.ansible.com/ansible/intro_dynamic_inventory.html">dynamic inventory</a>，只要把 inventory file 指定為可執行檔，Ansible 就能從執行檔的輸出中取得 inventory 資料。</p><p>Ansible 官方提供了各種主流主機商的 dynamic inventory，可以直接取用，而 GCE 當然也沒有缺席：<a href="http://docs.ansible.com/ansible/guide_gce.html">http://docs.ansible.com/ansible/guide_gce.html</a>。</p><span id="more"></span><h2 id="服務帳戶">服務帳戶</h2><p>為了要從 Google API 動態取得伺服器資料，必須申請一個服務帳戶，在申請之前，請先確認 Google Compute Engine 的 API 權限是否已經開啟。</p><img src="/blog/2016/02/01/ansible-gce/api-enabled.png" class=""><p>接著，申請一個服務帳戶，並順便建立私密金鑰。雖然 Ansible 官方教學使用 P12 金鑰，但其實 P12 金鑰已經棄用了，建議改用 JSON 金鑰，還可以省去金鑰解密的步驟。</p><img src="/blog/2016/02/01/ansible-gce/create-service-account.png" class=""><h2 id="設定-gce-ini">設定 gce.ini</h2><p>準備好服務帳戶和 JSON 金鑰之後，在 <a href="https://github.com/ansible/ansible/tree/devel/contrib/inventory">https://github.com/ansible/ansible/tree/devel/contrib/inventory</a> 下載 <code>gce.ini</code> 和 <code>gce.py</code> 兩個檔案，並放到 <code>inventory</code> 資料夾裡，別忘了要在 <code>gce.py</code> 加上執行權限。</p><pre><code class="language-sh">chmod +x inventory/gce.py</code></pre><p>安裝 <a href="https://libcloud.apache.org/">libcloud</a>，因為 libcloud 0.20.0 有個 bug 會使得認證失敗，所以必須安裝小於 0.20 的版本。</p><pre><code class="language-sh">pip install &quot;apache-libcloud&lt;0.20&quot;</code></pre><p>接著修改 <code>gce.ini</code> 的設定，在檔案底部可以看到這三行：</p><pre><code class="language-ini">gce_service_account_email_address =gce_service_account_pem_file_path =gce_project_id =</code></pre><ul><li><code>gce_service_account_email_address</code> - 服務帳戶的 Email 位址</li><li><code>gce_service_account_pem_file_path</code> - 金鑰路徑（雖然名稱是 <code>pem_file</code>，但是也可使用 JSON）</li><li><code>gce_project_id</code> - 專案 ID</li></ul><p>修改完成後應該會像這樣：</p><pre><code class="language-ini">gce_service_account_email_address = deploy@dcard-staging.iam.gserviceaccount.comgce_service_account_pem_file_path = credentials/dcard-staging-d6a7cf380e10.jsongce_project_id = dcard-staging</code></pre><p>可以執行 <code>gce.py</code> 看看是否正確設定：</p><pre><code class="language-sh">inventory/gce.py --list</code></pre><p>並確認 Ansible 可以透過 SSH 存取伺服器：</p><pre><code class="language-sh">ansible -i inventory/gce.py -m setup all</code></pre><p>如果無法存取的話，可以試著透過 Google 官方提供的 <a href="https://cloud.google.com/sdk/gcloud/">gcloud</a> 工具設定或是把 public key 新增到中繼資料頁面。</p><img src="/blog/2016/02/01/ansible-gce/ssh-keys.png" class=""><h2 id="伺服器標籤">伺服器標籤</h2><p><code>gce.py</code> 預設提供了主機位置、規格、作業系統等分類，但實際上不太夠用，可在 Google Developer Console 中新增標籤，這樣 Ansible 就能透過標籤篩選伺服器。</p><p>例如在伺服器加上 <code>mq-server</code> 和 <code>staging</code> 兩個標籤：</p><img src="/blog/2016/02/01/ansible-gce/server-tag.png" class=""><p>就能在 playbook 中使用 <code>tag_mq-server</code> 和 <code>tag_staging</code>。</p><h2 id="整合-Travis-CI">整合 Travis CI</h2><p>整合到 CI 聽起來很簡單，但實際上有些坑，因為 CI 的環境比較難 debug，Travis CI 又沒提供 SSH session 的功能，在 debug 時只能不斷 push 然後期待數分鐘後能收到好的結果，我一度想要放棄 Travis CI 轉換到比較容易使用的 <a href="https://semaphoreci.com/">Semaphore</a>，最後還是花了幾天終於試出正確設定。</p><h3 id="抓不到-pycrypto">抓不到 pycrypto</h3><p>第一個問題就是 Ansible 根本連跑都跑不起來，因為 <code>gce.py</code> 沒辦法抓到 pycrypto，紅色的那行字可能會讓你以為是權限問題，並試著照它說的用 <code>chmod -x</code>，然而這樣只會在 <code>chmod +x</code> 和 <code>chmod -x</code> 之間不斷切換而已，這錯誤訊息根本標錯重點了吧。</p><img src="/blog/2016/02/01/ansible-gce/travis-ci-pycrypto-error.png" class=""><p>我試了很多不同的方法來裝 pycrypto，然而並沒有什麼卵用。</p><pre><code class="language-sh">sudo apt-get install python-devsudo easy_install pycryptosudo pip install pycrypto</code></pre><p>最後發現根本不是 pycrypto 有沒有安裝的問題，因為 pycrypto 本來就裝在系統中了，解法意外的簡單，只要使用 <strong>sudo</strong> 權限執行 ansible 即可。</p><pre><code class="language-sh">sudo ansible-playbook -i inventory/gce.py api.yml</code></pre><h3 id="SSH-連線失敗">SSH 連線失敗</h3><p>解決了 pycrypto 的問題之後，以為這樣就能夠順利部署了，然而卻出現了 SSH 連線失敗的問題。</p><img src="/blog/2016/02/01/ansible-gce/travis-ci-ssh-failed.png" class=""><p>我反覆檢查了好幾次 SSH key，也確認 GCE 的設定無誤，但就是無法連線，最後又花了一天才找出原因，<a href="https://cloud.google.com/sdk/gcloud/">gcloud</a> 在建立 SSH 連線時會自動幫目前的使用者建立新帳戶，所以每個使用者可能會有不同的 SSH key，而我們現在再來重溫一次 SSH key 設定頁面吧。</p><img src="/blog/2016/02/01/ansible-gce/ssh-keys.png" class=""><p>注意到前面的「使用者名稱」欄位了嗎？這就是使用者對應的 SSH key，一旦知道這個奇怪的坑之後，就非常容易解決了，只要在 Ansible 執行時加上 <code>-u</code> 參數指定使用者即可：</p><pre><code class="language-sh">sudo ansible-playbook -i inventory/gce.py -u SkyArrow api.yml</code></pre><h2 id="後記">後記</h2><div class="video-container"><iframe src="https://www.youtube.com/embed/tY7iZwjW38g" frameborder="0" loading="lazy" allowfullscreen></iframe></div><p>最近偶然在 PlayStation Store 上找到《<a href="http://atlus-vanillaware.jp/osl/index.html">奧丁領域</a>》，原本並沒有購買這部遊戲的計畫，不過剛好有點閒錢於是就爽快的買下來了，一玩之後樂不釋手，最近正遊玩到妖精女王的篇章。</p>]]></content>
    
    
    <summary type="html">&lt;p&gt;最近忙著佈署新的測試伺服器，而 Google Cloud Platform 剛好有提供 $300 兩個月的免費試用，且在台灣又有設點，所以我就決定拿 Google Compute Engine 來建置測試伺服器了。&lt;/p&gt;
&lt;h2 id=&quot;Dynamic-inventory&quot;&gt;Dynamic inventory&lt;/h2&gt;
&lt;p&gt;在開始之前，先稍微解釋一下何謂 Ansible 的 inventory，inventory 即代表伺服器，在 Ansible 中，可把伺服器列在 inventory file 中，藉此來分類伺服器，例如：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;[webservers]
1.2.3.4 ansible_ssh_user=john
5.6.7.8 ansible_ssh_user=john

[dbservers]
9.10.11.12 ansible_ssh_user=mary
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然而伺服器一多，管理 inventory file 就顯得有些麻煩，這時可以利用 &lt;a href=&quot;http://docs.ansible.com/ansible/intro_dynamic_inventory.html&quot;&gt;dynamic inventory&lt;/a&gt;，只要把 inventory file 指定為可執行檔，Ansible 就能從執行檔的輸出中取得 inventory 資料。&lt;/p&gt;
&lt;p&gt;Ansible 官方提供了各種主流主機商的 dynamic inventory，可以直接取用，而 GCE 當然也沒有缺席：&lt;a href=&quot;http://docs.ansible.com/ansible/guide_gce.html&quot;&gt;http://docs.ansible.com/ansible/guide_gce.html&lt;/a&gt;。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Ansible" scheme="https://zespia.me/tags/Ansible/"/>
    
    <category term="DevOps" scheme="https://zespia.me/tags/DevOps/"/>
    
    <category term="Google Cloud Platform" scheme="https://zespia.me/tags/Google-Cloud-Platform/"/>
    
  </entry>
  
  <entry>
    <title>Algolia DocSearch</title>
    <link href="https://zespia.me/blog/2016/01/01/algolia-doc-search/"/>
    <id>https://zespia.me/blog/2016/01/01/algolia-doc-search/</id>
    <published>2016-01-01T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.867Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2016/01/01/algolia-doc-search/algolia-docsearch.jpg" class=""><p><a href="https://community.algolia.com/docsearch/">DocSearch</a> 是 <a href="https://www.algolia.com/">Algolia</a> 最近提供的一個免費搜尋引擎服務，只要登錄網站，他們就會爬好整個網站，使用者只要把 JavaScript 腳本貼到適當的位置上即可使用，和 <a href="https://swiftype.com/">Swiftype</a> 差不多，只是搜尋結果更好，而且速度更快。</p><p>這篇文章可能看起來很像業配文，不過我真的沒收這家公司的錢啦 XD。</p><span id="more"></span><p>在我登錄網站後，客服不到一小時就寄信跟我聯絡了，確認了一些事情後很快的就幫我設定好搜尋引擎，我只要把 JavaScript 腳本貼上即可使用，之後的各種要求也很快就回覆了，最後我得以在 12/31 前把所有東西都處理完成。</p><img src="/blog/2016/01/01/algolia-doc-search/searchbar.png" class=""><p>各位可以在 <a href="https://hexo.io/">hexo.io</a> 看到最後的成果，搜尋速度很快，就算是中文搜尋精準度也相當不錯，然而目前的缺點就是所有的設定都必須經過客服，我想可能要等一段時間才會解決吧。</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2016/01/01/algolia-doc-search/algolia-docsearch.jpg&quot; class=&quot;&quot;&gt;
&lt;p&gt;&lt;a href=&quot;https://community.algolia.com/docsearch/&quot;&gt;DocSearch&lt;/a&gt; 是 &lt;a href=&quot;https://www.algolia.com/&quot;&gt;Algolia&lt;/a&gt; 最近提供的一個免費搜尋引擎服務，只要登錄網站，他們就會爬好整個網站，使用者只要把 JavaScript 腳本貼到適當的位置上即可使用，和 &lt;a href=&quot;https://swiftype.com/&quot;&gt;Swiftype&lt;/a&gt; 差不多，只是搜尋結果更好，而且速度更快。&lt;/p&gt;
&lt;p&gt;這篇文章可能看起來很像業配文，不過我真的沒收這家公司的錢啦 XD。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Algolia" scheme="https://zespia.me/tags/Algolia/"/>
    
  </entry>
  
  <entry>
    <title>從 Redux 1 升級到 3</title>
    <link href="https://zespia.me/blog/2015/11/21/redux-1-to-3/"/>
    <id>https://zespia.me/blog/2015/11/21/redux-1-to-3/</id>
    <published>2015-11-21T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.843Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2015/11/21/redux-1-to-3/53131016.png" class="" title="kazenokaze - Akari x Aria (id&#x3D;53131016)"><p>好久不見，在 <a href="http://innoserve.tca.org.tw/">資服創新競賽</a> 結束後，我耍廢了好一段時間，更準確來說，從校內專題比賽結束後就開始耍廢了 XD，Hexo 的開發停滯這麼久真是不好意思 てへぺろ(・ω&lt;)。</p><p>我在暑假時開始進入 <a href="https://www.dcard.tw/">Dcard</a>，利用 <a href="https://facebook.github.io/react/">React</a> + <a href="http://rackt.org/redux/">Redux</a> + <a href="https://github.com/rackt/react-router">React Router</a> 寫了一個和現行網站完全分離的行動版網站，那時候 <a href="http://rackt.org/redux/">Redux</a> 的文件還非常不齊全，很多東西都得自己摸索，不過拜 <a href="https://github.com/tomchentw">@tomchentw</a> 所賜，解決了很多架構上的問題。</p><p>然而在我跑去寫 V2 API 的這兩個月，<a href="http://rackt.org/redux/">Redux</a> 竟然從 1.0.0 升級到 3.0.4 了！<a href="https://github.com/rackt/react-router">React Router</a> 也終於推出 1.0.0 了！原本以為新版 Admin panel 也能沿用相同的配置，沒想到還是有一些部分得推倒重來，不禁令人感嘆前端變化之快。</p><span id="more"></span><h2 id="Redux">Redux</h2><p>先說說 <a href="http://rackt.org/redux/">Redux</a> 究竟有什麼差別吧，其實 <a href="http://rackt.org/redux/">Redux</a> 本身的修改倒還好，只要看 <a href="https://github.com/rackt/redux/releases/tag/v2.0.0">Changelog</a> 就 OK 的程度，但是它周邊的東西就麻煩了。<a href="https://github.com/rackt/react-redux">react-redux</a> 從 0.2.1 跳到 4.0.0，此外還多了一堆伙伴，<a href="http://rackt.org/redux/">Redux</a> 生態圈的形成也他媽的太快了吧！</p><h3 id="react-redux">react-redux</h3><p><a href="https://github.com/rackt/react-redux">react-redux</a> 本身的 API 很簡單，但是 <a href="https://github.com/rackt/react-redux/releases">Changelog</a> 卻能出現好幾次的 <strong>Breaking Change</strong>，這東西是有這麼容易 Break 逆！</p><p>主要的差別在於 <code>connect</code> function 變得更好用了，過去只能用來綁定 props，現在也能綁定 action，如此一來就能省去 <code>bindActionCreators</code> 的過程了。</p><pre><code class="language-js">@connect(state =&gt; (&#123;  // props&#125;), &#123;  // actions&#125;)</code></pre><p>另一點則是 <code>Provider</code> class，原本的寫法實在不怎麼優雅，<code>this.props.children</code> 傳入的是一個函數，而現在可以直接傳入 React element。</p><pre><code class="language-js">React.render(  &lt;Provider store=&#123;store&#125;&gt;    &lt;App/&gt;  &lt;/Provider&gt;, document.getElementById('root'));</code></pre><h3 id="redux-router">redux-router</h3><p><a href="https://github.com/rackt/redux-router">redux-router</a> 是用來整合 <a href="http://rackt.org/redux/">Redux</a> 和 <a href="https://github.com/rackt/react-router">React Router</a> 的，他做的事情很簡單，就是把 Router 的資料存在 Redux store 裡，但是實際上用起來卻有不少問題，所以我先說結論：</p><blockquote><p>改用 <a href="https://github.com/jlongster/redux-simple-router">redux-simple-router</a>，不然就不要把 Router 狀態存在 Redux store 裡。</p></blockquote><p>你一定會很好奇我怎麼會去推薦一個版號連 0.1 都不到的超新星套件，但是 <a href="https://github.com/rackt/redux-router">redux-router</a> 這貨真的很坑，為了你的肝指數著想，請等到 1.0.0 正式版推出後再考慮它吧。</p><p>說了這麼多，這東西究竟有什麼問題呢？咱們來一一列舉吧：</p><ul><li><a href="https://github.com/rackt/redux-router">redux-router</a> 和 <a href="https://github.com/jlongster/redux-simple-router">redux-simple-router</a> 最大的不同就在於：前者把完整的 Router 狀態都存到了 Redux store，而後者只存了路徑，在 Dehydration/Rehydration 時，根本沒辦法處理 <a href="https://github.com/rackt/redux-router">redux-router</a> 的資料，只能忽略。</li><li><a href="https://github.com/rackt/redux-router">redux-router</a> 的觸發時機早於 Rehydration，因此有些資料尚未初始化而發生錯誤。</li><li><a href="https://github.com/rackt/redux-router">redux-router</a> 的 <code>getRoutes</code> 參數半壞，你如果想把 Redux store 傳進 routes 裡的話，自己實作比較保險。</li><li><a href="https://github.com/rackt/redux-router">redux-router</a> 的原始碼不知道在複雜幾點的，而 <a href="https://github.com/jlongster/redux-simple-router">redux-simple-router</a> 的原始碼不到 100 行，雖然功能比較少，但是好用多了。</li></ul><p>接著比較實際的程式碼吧：</p><pre><code class="language-js">import React from 'react';import &#123;render&#125; from 'react-dom';import &#123;createStore, compose&#125; from 'redux';import &#123;ReduxRouter, reduxReactRouter&#125; from 'redux-router';import &#123;createHistory&#125; from 'history';const store = compose(  middlewares,  reduxReactRouter(&#123;    routes,    createHistory  &#125;))(createStore)(rootReducer, initialState);render(  &lt;Provider store=&#123;store&#125;&gt;    &lt;ReduxRouter/&gt;  &lt;/Provider&gt;, document.getElementById('root'));</code></pre><pre><code class="language-js">import React from 'react';import &#123;render&#125; from 'react-dom';import &#123;createStore, compose&#125; from 'redux';import &#123;createHistory&#125; from 'history';import &#123;syncReduxAndRouter&#125; from 'redux-simple-router';import &#123;Router&#125; from 'react-router';const history = createHistory();const store = compose(  middlewares)(createStore)(rootReducer, initialState);syncReduxAndRouter(history, store);render(  &lt;Provider store=&#123;store&#125;&gt;    &lt;Router routes=&#123;routes&#125; history=&#123;history&#125;/&gt;  &lt;/Provider&gt;, document.getElementById('root'));</code></pre><p><a href="https://github.com/rackt/redux-router">redux-router</a> 需要在 createStore 時就加入 <code>reduxReactRouter</code> middleware，而 <a href="https://github.com/jlongster/redux-simple-router">redux-simple-router</a> 則是在 store 創建完成後才同步 router 狀態；<a href="https://github.com/rackt/redux-router">redux-router</a> render 時使用 <code>ReduxRouter</code> 元件，而 <a href="https://github.com/jlongster/redux-simple-router">redux-simple-router</a> 直接使用 <a href="https://github.com/rackt/react-router">React Router</a> 的 <code>Router</code> 元件。由於後者非常簡單，也減少了錯誤發生的機率。</p><h3 id="redux-devtools">redux-devtools</h3><img src="/blog/2015/11/21/redux-1-to-3/687474703a2f2f692e696d6775722e636f6d2f4a34476557304d2e676966.gif" class=""><p>這東西真的很炫，它可以在頁面中顯示側欄來顯示 action 的動態，還可以復原某些 action 對 store 的變更，使用起來非常簡單，讓我愛不釋手。</p><pre><code class="language-js">import React from 'react';import &#123;render&#125; from 'react-dom';import &#123;createStore, compose&#125; from 'redux';import &#123;persistState&#125; from 'redux-devtools';import &#123;createDevTools&#125; from 'redux-devtools';import LogMonitor from 'redux-devtools-log-monitor';import DockMonitor from 'redux-devtools-dock-monitor';const DevTools = createDevTools(  &lt;DockMonitor    toggleVisibilityKey='H'    changePositionKey='Q'&gt;    &lt;LogMonitor/&gt;  &lt;/DockMonitor&gt;);const store = compose(  DevTools.instrument(),  persistState(    window.location.href.match(      /[?&amp;]debug_session=([^&amp;]+)\b/    )  ))(createStore)(rootReducer, initialState);render(  &lt;Provider store=&#123;store&#125;&gt;    &lt;DevTools/&gt;  &lt;/Provider&gt;, document.getElementById('root'));</code></pre><h2 id="React-Router">React Router</h2><p><a href="https://github.com/rackt/react-router">React Router</a> 從 0.13 到 1.0 的差別非常大：</p><ul><li>Named route 被移除了，原本 <code>Link</code> 元件能從 named route 自動產生完整的路徑，現在必須自己來</li><li>不一定得用 JSX 來寫 route，現在也能用 plain object 了，這還挺方便的</li><li><code>willTransitionTo</code> =&gt; <code>onEnter</code>, <code>willTransitionFrom</code> =&gt; <code>onLeave</code></li><li>支援 Async child routes，現在能更方便的切頁面了</li></ul><p>我從暑假時因為 React context 的關係就開始用 React 0.14 + React Router 1.0 beta 了，所以轉換起來比較無痛，但是 1.0 beta 和正式版還是有些 API 差別的，因為一言難盡，還是看 <a href="https://github.com/rackt/react-router/releases">Changelog</a> 吧。</p><h2 id="React-Transform">React Transform</h2><img src="/blog/2015/11/21/redux-1-to-3/687474703a2f2f692e696d6775722e636f6d2f416847593238542e676966.gif" class=""><p>原本我使用的是 <a href="https://github.com/gaearon/react-hot-loader">React Hot Loader</a>，這種方法其實用起來也沒什麼大問題，只是得另外開個 <a href="https://webpack.github.io/docs/webpack-dev-server.html">webpack-dev-server</a>。不過原作者決定廢棄 <a href="https://github.com/gaearon/react-hot-loader">React Hot Loader</a>，又另外開了個 <a href="https://github.com/gaearon/react-transform-boilerplate">React Transform</a>，所以我也只好轉移到 <a href="https://github.com/gaearon/react-transform-boilerplate">React Transform</a> 了。相較上面幾個大改變來說，這只是編譯過程的改變而已，只需要改 Webpack 和 Babel 的配置即可。</p><p>首先把 <a href="https://webpack.github.io/docs/webpack-dev-server.html">webpack-dev-server</a> 相關的東西都移除掉，安裝：</p><pre><code class="language-bash">npm install babel-plugin-react-transform --save-devnpm install react-transform-catch-errors --save-devnpm install react-transform-hmr --save-devnpm install redbox-react --save-devnpm install webpack-dev-middleware --save-devnpm install webpack-hot-middleware --save-dev</code></pre><p>以下程式碼使用的是 <a href="http://expressjs.com/">Express</a>，如果你用的是 <a href="http://koajs.com/">Koa</a> 的話，請把 <code>webpack-dev-middleware</code> 改成 <code>koa-webpack-dev-middleware</code>，<code>webpack-hot-middleware</code> 改成 <code>koa-webpack-hot-middleware</code>，這兩個 middleware 的使用方式會有些差異，不過大致上是差不多的。</p><pre><code class="language-js">import webpack from 'webpack';import webpackDevMiddleware from 'webpack-dev-middleware';import webpackHotMiddleware from 'webpack-hot-middleware';import webpackConfig from './config';const compiler = webpack(webpackConfig);app.use(webpackDevMiddleware(compiler, &#123;  noInfo: true,  publicPath: webpackConfig.output.publicPath&#125;));app.use(webpackHotMiddleware(compiler));</code></pre><pre><code class="language-js">import webpack from 'webpack';export default &#123;  entry: &#123;    main: [      'webpack-hot-middleware/client',      './src/main'    ]  &#125;,  module: &#123;    loaders: [      &#123;        test: /\.jsx?$/,        loaders: ['babel'],        exclude: /node_modules/,        query: &#123;          plugins: ['react-transform'],          extra: &#123;            'react-transform': [              &#123;                transform: 'react-transform-hmr',                imports: ['react'],                locals: ['module']              &#125;,              &#123;                transform: 'react-transform-catch-errors',                imports: ['react', 'redbox-react']              &#125;            ]          &#125;        &#125;      &#125;    ]  &#125;,  plugins: [    new webpack.HotModuleReplacementPlugin(),    new webpack.NoErrorsPlugin(),    new webpack.optimize.DedupePlugin(),    new webpack.optimize.OccurenceOrderPlugin()  ]&#125;;</code></pre><h2 id="Babel">Babel</h2><p><a href="https://babeljs.io/">Babel</a> 在上個月也從 5 升級到 6 了，但現在還有很多問題：</p><ul><li>眾多功能拆成 Plugin 後，由於執行順序的問題，可能互相衝突，例如 Decorator 怪怪的</li><li><code>import *</code> 的行為和過去不同，這有可能是上一點的問題</li><li>最重要的是，<a href="https://github.com/gaearon/react-transform-boilerplate">React Transform</a> 尚未支援</li></ul><p>如果你現在 Babel 5 用得好好的，那就別折騰了。</p><h2 id="後記">後記</h2><p>你可以在 <a href="https://github.com/tommy351/redux-example">tommy351/redux-example</a> 看到完整的 Universal Redux example。</p><p>前端的變化這麼快實在太可怕了，明年說不定又有新玩意了，<s>我還是回去寫後端壓壓驚吧</s>。</p><div class="video-container"><iframe src="https://www.youtube.com/embed/Bf1is7k_0N0" frameborder="0" loading="lazy" allowfullscreen></iframe></div><p>題圖是本季動畫「<a href="http://ariaaa.tv/index.html">緋弾のアリアAA</a>」，我原本以為這是和本傳差不多的動畫，沒想到一看就讓我震驚了，花了兩天把原作漫畫讀完，在某些方面來說，這的確和本傳差不多：</p><ul><li>主角「間宮明里」，和金次一樣有吸引後宮的體質，只是明里專門吸引サイコレズ，這世界就沒半個正常人吧。</li><li>明里在平常就是個低等廢物，但是危急的時候總是能用祖傳炫泡絕技打爆敵人來收後宮。</li><li>白雪（本傳）和志乃（AA）都非常喜歡主角，也都對亞莉亞抱有敵意www</li></ul><p>目前動畫除了有些設定變更和大☆崩★壞的第五話以外，日常場景都挺不錯的，看來動畫工房的百合日常比較穩定。</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2015/11/21/redux-1-to-3/53131016.png&quot; class=&quot;&quot; title=&quot;kazenokaze - Akari x Aria (id&amp;#x3D;53131016)&quot;&gt;
&lt;p&gt;好久不見，在 &lt;a href=&quot;http://innoserve.tca.org.tw/&quot;&gt;資服創新競賽&lt;/a&gt; 結束後，我耍廢了好一段時間，更準確來說，從校內專題比賽結束後就開始耍廢了 XD，Hexo 的開發停滯這麼久真是不好意思 てへぺろ(・ω&amp;lt;)。&lt;/p&gt;
&lt;p&gt;我在暑假時開始進入 &lt;a href=&quot;https://www.dcard.tw/&quot;&gt;Dcard&lt;/a&gt;，利用 &lt;a href=&quot;https://facebook.github.io/react/&quot;&gt;React&lt;/a&gt; + &lt;a href=&quot;http://rackt.org/redux/&quot;&gt;Redux&lt;/a&gt; + &lt;a href=&quot;https://github.com/rackt/react-router&quot;&gt;React Router&lt;/a&gt; 寫了一個和現行網站完全分離的行動版網站，那時候 &lt;a href=&quot;http://rackt.org/redux/&quot;&gt;Redux&lt;/a&gt; 的文件還非常不齊全，很多東西都得自己摸索，不過拜 &lt;a href=&quot;https://github.com/tomchentw&quot;&gt;@tomchentw&lt;/a&gt; 所賜，解決了很多架構上的問題。&lt;/p&gt;
&lt;p&gt;然而在我跑去寫 V2 API 的這兩個月，&lt;a href=&quot;http://rackt.org/redux/&quot;&gt;Redux&lt;/a&gt; 竟然從 1.0.0 升級到 3.0.4 了！&lt;a href=&quot;https://github.com/rackt/react-router&quot;&gt;React Router&lt;/a&gt; 也終於推出 1.0.0 了！原本以為新版 Admin panel 也能沿用相同的配置，沒想到還是有一些部分得推倒重來，不禁令人感嘆前端變化之快。&lt;/p&gt;</summary>
    
    
    
    
    <category term="JavaScript" scheme="https://zespia.me/tags/JavaScript/"/>
    
    <category term="React" scheme="https://zespia.me/tags/React/"/>
    
    <category term="Redux" scheme="https://zespia.me/tags/Redux/"/>
    
  </entry>
  
  <entry>
    <title>Fetch 壓縮後會在 Chrome 上發生 Illegal Invocation 錯誤</title>
    <link href="https://zespia.me/blog/2015/06/12/fetch-illegal-invocation-error/"/>
    <id>https://zespia.me/blog/2015/06/12/fetch-illegal-invocation-error/</id>
    <published>2015-06-12T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.834Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2015/06/12/fetch-illegal-invocation-error/50392856.jpg" class="" title="wara - れっつごー シアン ♪ (id&#x3D;50392856)"><p>最近在寫專題時，嘗試了許多新東西，例如改用 Go 來寫 API Server，還試了最近很潮的 Isomorphic JavaScript，如果未來有時間的話可能會針對這個主題再另外寫一篇文章，今天先寫最近遇到的一個怪問題。</p><span id="more"></span><p>各位可能有聽過 <a href="https://fetch.spec.whatwg.org/">Fetch</a> 這個新一代的標準，它簡化了瀏覽器麻煩的 XMLHttpRequest API，而且支援 Promise。</p><p>在開發時，<a href="https://fetch.spec.whatwg.org/">Fetch</a> 運行順利，然而上線後卻會發生 Illegal Invocation 錯誤，更弔詭的是，這種錯誤只在 Chrome 上發生。（我沒有在 Safari 或 Opera 上試過）</p><img src="/blog/2015/06/12/fetch-illegal-invocation-error/illegal-invocation-error.png" class="" title="Uncaught TypeError: Illegal invocation"><p>我把原始碼都翻過了遍，上網搜尋關於 Illegal Invocation 也大都是關於 jQuery 的解答，在苦心搜尋幾小時就快放棄時，終於在 GitHub 上解答：<a href="https://github.com/matthew-andrews/isomorphic-fetch/pull/20">matthew-andrews/isomorphic-fetch#20</a></p><p>解決方式非常簡單，就是把原本的：</p><pre><code class="language-js">import fetch from 'isomorphic-fetch';</code></pre><p>改成：</p><pre><code class="language-js">import fetch_ from 'isomorphic-fetch';const fetch = fetch_.bind(this);</code></pre><p>只要把 import 進來的 <code>fetch</code> 綁定 <code>this</code>，就能解決這個詭異的問題了。</p><h2 id="後記">後記</h2><p>題圖是本季新番「<a href="http://showbyrock-anime.com/">Show By Rock</a>」，雖然一開始的展開很腦殘，不過之後的劇情卻非常有趣，重點是，<strong>每個角色都好可愛啊</strong>！</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2015/06/12/fetch-illegal-invocation-error/50392856.jpg&quot; class=&quot;&quot; title=&quot;wara - れっつごー シアン ♪ (id&amp;#x3D;50392856)&quot;&gt;
&lt;p&gt;最近在寫專題時，嘗試了許多新東西，例如改用 Go 來寫 API Server，還試了最近很潮的 Isomorphic JavaScript，如果未來有時間的話可能會針對這個主題再另外寫一篇文章，今天先寫最近遇到的一個怪問題。&lt;/p&gt;</summary>
    
    
    
    
    <category term="JavaScript" scheme="https://zespia.me/tags/JavaScript/"/>
    
  </entry>
  
  <entry>
    <title>用 Travis CI 自動部署網站到 GitHub</title>
    <link href="https://zespia.me/blog/2015/01/21/continuous-deployment-to-github-with-travis/"/>
    <id>https://zespia.me/blog/2015/01/21/continuous-deployment-to-github-with-travis/</id>
    <published>2015-01-21T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.831Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2015/01/21/continuous-deployment-to-github-with-travis/48083477.png" class="" title="志田 - 熊 (id&#x3D;48083477)"><p>長久以來，Hexo 官網都是由我手動在本機產生靜態檔案後，再 push 到 GitHub 上。這種方式對於簡單的網誌來說或許很輕鬆，但是對於偶爾會有 Pull Request 的專案來說就比較麻煩了。</p><p>在合併了 Pull Request 後，我必須自行把最新的 commit 拉到本機後再手動部署，有時比較忙就會擺爛，因此你會發現，雖然 Pull Request 已經被合併了，Hexo 網站本身卻仍未更新的情況。</p><span id="more"></span><h2 id="開始之前">開始之前</h2><p>在開始之前，請先申請 Travis CI 帳號，把你的 GitHub repo 新增到 Travis CI 上，如果還沒建立 <code>.travis.yml</code> 的話，請先製作一個新的 <code>.travis.yml</code>。</p><h2 id="Deploy-Key">Deploy Key</h2><p>因此，我花了一個晚上嘗試出了透過免費的 Travis CI 服務自動佈署的方法，首先你必須用 <code>ssh-keygen</code> 製作一個 SSH Key，供 GitHub 當作 Deploy key 使用。</p><pre><code class="language-bash">ssh-keygen -t rsa -C &quot;your_email@example.com&quot;</code></pre><p>在製作 SSH key 時，請把 passphrase 留空，因為在 Travis 上輸入密碼很麻煩，我目前還找不到比較簡便的方式，如果各位知道的話歡迎提供給我。</p><p>當 SSH key 製作完成後，複製 Public key 到 GitHub 上的 Deploy key 欄位，如下：</p><img src="/blog/2015/01/21/continuous-deployment-to-github-with-travis/deploy_key.png" class=""><h2 id="加密-Private-Key">加密 Private Key</h2><p>首先，安裝 Travis 的命令列工具：</p><pre><code class="language-bash">gem install travis</code></pre><p>在安裝完畢後，透過命令列工具登入到 Travis：</p><pre><code class="language-bash">travis login --auto</code></pre><p>如此一來，我們就能透過 Travis 提供的命令列工具加密剛剛所製作的 Private key，並把它上傳到 Travis 上供日後使用。</p><p>假設 Private key 的檔案名稱為 <code>ssh_key</code>， Travis 會加密並產生 <code>ssh_key.enc</code>，並自動在 <code>.travis.yml</code> 的 <code>before_install</code> 欄位中，自動插入解密指令。</p><pre><code class="language-bash">travis encrypt-file ssh_key --add</code></pre><p>正常來說 Travis 會自動解析目前的 repo 並把 Private key 上傳到相對應的 repo，但有時可能會秀逗，這時你必須在指令後加上 <code>-r</code> 選項來指定 repo 名稱，例如：</p><pre><code class="language-bash">travis encrypt-file ssh_key --add -r hexojs/site</code></pre><h2 id="設定-travis-yml">設定 .travis.yml</h2><p>把剛剛製作的 <code>ssh_key.enc</code> 移至 <code>.travis/ssh_key.enc</code>，並在 <code>.travis</code> 資料夾中建立 <code>ssh_config</code> 檔案，指定 Travis 上的 SSH 設定。</p><pre><code class="language-plain">Host github.comUser gitStrictHostKeyChecking noIdentityFile ~/.ssh/id_rsaIdentitiesOnly yes</code></pre><p>因為剛剛修改了 <code>ssh_key.enc</code> 的位址，所以我們要順帶修改剛剛 Travis 在 <code>.travis.yml</code> 幫我們插入的那條解密指令。請注意，<strong>不要照抄這段指令</strong>，每個人的環境變數都不一樣。</p><pre><code class="language-bash">openssl aes-256-cbc -K $encrypted_06b8e90ac19b_key -iv $encrypted_06b8e90ac19b_iv -in .travis/ssh_key.enc -out ~/.ssh/id_rsa -d</code></pre><p>這條指令會利用 openssl 解密 Private key，並把解密後的檔案存放在 <code>~/.ssh/id_rsa</code>，接著指定這個檔案的權限：</p><pre><code class="language-bash">chmod 600 ~/.ssh/id_rsa</code></pre><p>然後，把 Private key 加入到系統中：</p><pre><code class="language-bash">eval $(ssh-agent)ssh-add ~/.ssh/id_rsa</code></pre><p>記得剛剛我們製作的 <code>ssh_config</code> 檔案嗎？別忘了把他複製到 <code>~/.ssh</code> 資料夾：</p><pre><code class="language-bash">cp .travis/ssh_config ~/.ssh/config</code></pre><p>為了讓 <code>git</code> 操作能順利進行，我們必須先設定 <code>git</code> 的使用者資訊：</p><pre><code class="language-bash">git config --global user.name &quot;Tommy Chen&quot;git config --global user.email tommy351@gmail.com</code></pre><p>最後的結果可能如下，如果你和我一樣使用 Hexo 的話可以參考看看：</p><pre><code class="language-yaml">language: node_jsnode_js:  - &quot;0.10&quot;before_install:  # Decrypt the private key  - openssl aes-256-cbc -K $encrypted_06b8e90ac19b_key -iv $encrypted_06b8e90ac19b_iv -in .travis/ssh_key.enc -out ~/.ssh/id_rsa -d  # Set the permission of the key  - chmod 600 ~/.ssh/id_rsa  # Start SSH agent  - eval $(ssh-agent)  # Add the private key to the system  - ssh-add ~/.ssh/id_rsa  # Copy SSH config  - cp .travis/ssh_config ~/.ssh/config  # Set Git config  - git config --global user.name &quot;Tommy Chen&quot;  - git config --global user.email tommy351@gmail.com  # Install Hexo  - npm install hexo@beta -g  # Clone the repository  - git clone https://github.com/hexojs/hexojs.github.io .deployscript:  - hexo generate  - hexo deploybranches:  only:    - master</code></pre><h2 id="後記">後記</h2><p>已經好久沒更新網誌了，這次的題圖維持傳統，和本文完全沒有任何關係，而是這季一部名為「<a href="http://www.yurikuma.jp/">ユリ熊嵐</a>」的動畫，從標題完全看不出來在演啥小，就算看了內容也不懂！不過這動畫有種神奇的魔力會讓人想看下去呢，真不可思議。</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2015/01/21/continuous-deployment-to-github-with-travis/48083477.png&quot; class=&quot;&quot; title=&quot;志田 - 熊 (id&amp;#x3D;48083477)&quot;&gt;
&lt;p&gt;長久以來，Hexo 官網都是由我手動在本機產生靜態檔案後，再 push 到 GitHub 上。這種方式對於簡單的網誌來說或許很輕鬆，但是對於偶爾會有 Pull Request 的專案來說就比較麻煩了。&lt;/p&gt;
&lt;p&gt;在合併了 Pull Request 後，我必須自行把最新的 commit 拉到本機後再手動部署，有時比較忙就會擺爛，因此你會發現，雖然 Pull Request 已經被合併了，Hexo 網站本身卻仍未更新的情況。&lt;/p&gt;</summary>
    
    
    
    
    <category term="Hexo" scheme="https://zespia.me/tags/Hexo/"/>
    
    <category term="Travis CI" scheme="https://zespia.me/tags/Travis-CI/"/>
    
    <category term="GitHub" scheme="https://zespia.me/tags/GitHub/"/>
    
  </entry>
  
  <entry>
    <title>在伺服器上使用 Google Analytics API</title>
    <link href="https://zespia.me/blog/2014/07/28/use-google-analytics-api-on-server/"/>
    <id>https://zespia.me/blog/2014/07/28/use-google-analytics-api-on-server/</id>
    <published>2014-07-28T00:00:00.000Z</published>
    <updated>2022-03-26T09:52:56.821Z</updated>
    
    <content type="html"><![CDATA[<img src="/blog/2014/07/28/use-google-analytics-api-on-server/45547375.jpg" class="" title="Tea@ナケナシ - ろこどる！ (id&#x3D;45547375)"><p>最近在公司負責 Dashboard 的開發，除了從資料庫挖資料以外，還得想盡辦法從其他來源找到更多的資料，而其中一個資料來源就是 Google Analytics。</p><p>我過去都是在瀏覽器上使用 OAuth，這次花了一個下午才研究出如何在伺服器的 OAuth 用法，途中碰壁了好幾次，以下我將從頭到尾介紹如何接上 Google API，全文將以 Node.js 示範，其中原理可運用在其他程式語言，或 Google 其他服務的 API。</p><span id="more"></span><h2 id="建立專案">建立專案</h2><img src="/blog/2014/07/28/use-google-analytics-api-on-server/enable_ga_sdk.png" class=""><p>首先，在 <a href="https://console.devlopers.google.com">Google Developer Console</a> 建立專案，並開啟「<strong>Analytics API</strong>」的存取權，到此為止都還是小菜一碟，跟不上的可以洗洗睡了，接下來才是正題。</p><img src="/blog/2014/07/28/use-google-analytics-api-on-server/auth_page.png" class=""><p>為了獲得 API 的存取權，我們必須申請一個新的用戶端 ID。請進入側邊欄中的「API 和驗證」→「憑證」頁面，你將可以看到一個預先建立的「Compute Engine 和 App Engine」的用戶端 ID，那個一點屁用都沒有，別管它。</p><img src="/blog/2014/07/28/use-google-analytics-api-on-server/service_account.png" class=""><p>點選橘色按鈕「建立新的用戶端 ID」後，會跳出一個視窗，請選擇「服務帳戶」，並按下藍色按鈕「建立用戶端 ID」。</p><img src="/blog/2014/07/28/use-google-analytics-api-on-server/download_key.png" class=""><p>經過數秒後，新的用戶端 ID 便建立完成，同時會跳出一個 JSON 檔案的下載視窗，該檔案是用來存放 Private key 的，非常重要，請妥善保存。</p><img src="/blog/2014/07/28/use-google-analytics-api-on-server/key_page.png" class=""><p>服務帳戶到此為止便建立完成，你可在頁面下方看到熱騰騰剛建好的服務帳戶，你現在可以關掉這個頁面了，因為稍後的操作都會圍繞在剛剛下載的 JSON 檔案；如果你不小心遺失了 Private key，可以使用「Generate new JSON key」按鈕建立一組新的 Public/Private key。</p><h2 id="OAuth-認證">OAuth 認證</h2><img src="/blog/2014/07/28/use-google-analytics-api-on-server/server_key.png" class=""><p>Google API 採用 OAuth 2.0 認證，然而認證方式與我們平常所熟悉的方式不同，現在許多網站上都會有「以 Google 帳號登入」的按鈕，依照簡易的操作步驟即可認證；不過在伺服器上可沒有按鈕讓你按，於是我們必須透過剛剛申請的服務帳戶進行認證。</p><img src="/blog/2014/07/28/use-google-analytics-api-on-server/jwt_flow.png" class=""><p>上圖來自 <a href="https://developers.google.com/accounts/docs/OAuth2ServiceAccount">Google Developers</a>，完美解釋了接下來的流程，首先，我們必須建立 JWT（JSON Web Token），並使用 JWT 向 Google 索取 Token，之後方可存取 Google API。</p><p>一個完整的 JWT 應具備以下內容：</p><pre><code class="language-plain">&#123;Base64url encoded header&#125;.&#123;Base64url encoded claim set&#125;.&#123;Base64url encoded signature&#125;</code></pre><p>第一部分是 <strong>Header</strong>，此部份用來指示 JWT 所使用的演算法及類型，在存取 Google API 時，我們使用 RSA SHA256 演算法，因此內容為：</p><pre><code class="language-json">&#123;  &quot;alg&quot;: &quot;RS256&quot;,  &quot;typ&quot;: &quot;JWT&quot;&#125;</code></pre><p>第二部分是 <strong>Claim set</strong>，此部份是 JWT 的主要資料部分，內容如下：</p><pre><code class="language-json">&#123;   &quot;iss&quot;: &quot;761326798069-r5mljlln1rd4lrbhg75efgigp36m78j5@developer.gserviceaccount.com&quot;,   &quot;scope&quot;: &quot;https://www.googleapis.com/auth/analytics.readonly&quot;,   &quot;aud&quot;: &quot;https://accounts.google.com/o/oauth2/token&quot;,   &quot;iat&quot;: 1328550785,   &quot;exp&quot;: 1328554385&#125;</code></pre><table><thead><tr><th>名稱</th><th>說明</th></tr></thead><tbody><tr><td><code>iss</code></td><td>Client Email，即為剛剛下載的 JSON private key 中的 <code>client_email</code> 欄位。</td></tr><tr><td><code>scope</code></td><td>應用程式所請求的資料範圍，因為我們需要取得 Google Analytics 的資料，因此為 <code>https://www.googleapis.com/auth/analytics.readonly</code>。</td></tr><tr><td><code>aud</code></td><td>認證目標。在此為 <code>https://accounts.google.com/o/oauth2/token</code>。</td></tr><tr><td><code>iat</code></td><td>此請求的發起時間（秒）。</td></tr><tr><td><code>exp</code></td><td>Token 的過期時間（秒），最大時間為 <code>iat</code>（發起時間）的 1 小時後。</td></tr></tbody></table><p>在介紹第三部分之前，請先將前兩部分的資料以 Base64 方式編碼，並以 <code>.</code> 串接。如果你不知道怎麼在 Node.js 內進行 Base64 編碼，可參考以下程式碼：</p><pre><code class="language-js">new Buffer(str).toString('base64');</code></pre><p>第三部分是 <strong>Signature</strong>，即是將前兩部分的編碼字串以 Private key 加密後的結果，你可從剛剛下載的 JSON private key 中的 <code>private_key</code> 欄位取得 Private key，並參考以下的程式碼取得加密字串。</p><pre><code class="language-js">var crypto = require('crypto');crypto.createSign('sha256').update(jwt).sign(privateKey, 'base64');</code></pre><p>完成後，以 <code>.</code> 串接所有部分就是一個正確的 JWT 了。接著請使用 JWT 向 Google 索取 Token，POST 到 <code>https://accounts.google.com/o/oauth2/token</code> ，並加上以下資料：</p><table><thead><tr><th>名稱</th><th>說明</th></tr></thead><tbody><tr><td><code>grant_type</code></td><td>認證類型。在這裡為 <code>urn:ietf:params:oauth:grant-type:jwt-bearer</code>，別忘了 URL 編碼。</td></tr><tr><td><code>assertion</code></td><td>就是剛剛建立的 JWT。</td></tr></tbody></table><p>以下使用 <a href="https://github.com/mikeal/request">request</a> 為例：</p><pre><code class="language-js">var request = require('request'),  querystring = require('querystring');request.post('https://accounts.google.com/o/oauth2/token', &#123;  headers: &#123;    'Content-Type': 'application/x-www-form-urlencoded'  &#125;,  body: querystring.stringify(&#123;    grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',    assertion: jwt  &#125;),  encoding: 'utf8'&#125;, function(err, res, body)&#123;  // ...&#125;);</code></pre><p>若所有資料正確無誤的話，應該可得到以下回應：</p><pre><code class="language-json">&#123;  &quot;access_token&quot; : &quot;1/8xbJqaOZXSUZbHLl5EOtu1pxz3fmmetKx9W8CV4t79M&quot;,  &quot;token_type&quot; : &quot;Bearer&quot;,  &quot;expires_in&quot; : 3600&#125;</code></pre><p>其中 <code>access_token</code> 就是我們需要的 Token，而 <code>expires_in</code> 代表這個 Token 的有效時間（秒）；簡而言之，這個 Token 的有效時間只有 1 小時（3600 秒）。</p><h2 id="取得資料">取得資料</h2><p>一旦取得 Token 後，一切都變得簡單了，但是別忘了把閱覽權限開放給服務帳戶的 Client Email，否則將無法透過 API 存取資料。</p><img src="/blog/2014/07/28/use-google-analytics-api-on-server/ga_resource_id.png" class=""><p>此外，還必須取得「資源數據編號」，別搞錯，這個可不是追蹤編號喔！完成後，使用以下方式即可取得資料。</p><pre><code class="language-plain">Authorization: Bearer &#123;oauth2-token&#125;GET https://www.googleapis.com/analytics/v3/data/ga  ?ids=ga:&#123;id&#125;  &amp;start-date=2008-10-01  &amp;end-date=2008-10-31  &amp;metrics=ga:sessions,ga:bounces</code></pre><h2 id="後記">後記</h2><div class="video-container"><iframe src="https://www.youtube.com/embed/QAUk9Q3yziw" frameborder="0" loading="lazy" allowfullscreen></iframe></div><p>這篇文章最難的部份大概就是如何產生出 JWT，其他部分就只是向 Google 請求資料而已，沒什麼好解釋的，所以我接下來就寫些廢話吧。</p><p>這季有一部雖然看起來很普通，而且連名稱也有「普通」這詞的動畫《<a href="http://www.tbs.co.jp/anime/locodol/">普通の女子校生が【ろこどる】やってみた</a>》，我原本以為這只是一部普通的地區推銷動畫，沒想到女二卻是一名サイコレズ！如果各位喜歡百合的話，請務必要看看這部動畫！</p><p>週末時我花了一小時的時間把筆電升級到 Yosemite Beta，接下來我大概會寫一篇文章講述關於此系統的心得；7 月 22 日起，<a href="http://nic.moe/">.moe</a> 網域開放一般註冊，於是我買了 <a href="http://maji.moe/">maji.moe</a>，未來大概會開放子網域的申請服務，目前正在研究如何利用 Go 語言架站。</p>]]></content>
    
    
    <summary type="html">&lt;img src=&quot;/blog/2014/07/28/use-google-analytics-api-on-server/45547375.jpg&quot; class=&quot;&quot; title=&quot;Tea@ナケナシ - ろこどる！ (id&amp;#x3D;45547375)&quot;&gt;
&lt;p&gt;最近在公司負責 Dashboard 的開發，除了從資料庫挖資料以外，還得想盡辦法從其他來源找到更多的資料，而其中一個資料來源就是 Google Analytics。&lt;/p&gt;
&lt;p&gt;我過去都是在瀏覽器上使用 OAuth，這次花了一個下午才研究出如何在伺服器的 OAuth 用法，途中碰壁了好幾次，以下我將從頭到尾介紹如何接上 Google API，全文將以 Node.js 示範，其中原理可運用在其他程式語言，或 Google 其他服務的 API。&lt;/p&gt;</summary>
    
    
    
    
    <category term="JavaScript" scheme="https://zespia.me/tags/JavaScript/"/>
    
    <category term="Node.js" scheme="https://zespia.me/tags/Node-js/"/>
    
    <category term="Google" scheme="https://zespia.me/tags/Google/"/>
    
  </entry>
  
</feed>
