tag:blogger.com,1999:blog-53631898388843045442024-03-14T16:42:24.583+08:00Prochain Science理論科學打造雙翼,用資訊技術飛向藍天Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.comBlogger314125tag:blogger.com,1999:blog-5363189838884304544.post-38636785179213839152022-03-24T22:13:00.004+08:002022-03-24T22:18:47.677+08:00使用 Skaffold 來建立 Kubernetes Debugger 環境 (AWS EKS/ECR)<p> </p><span><a name='more'></a></span><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Skaffold 用途</span></h2><p><br /></p><p>Skaffold 是一個可以加以整合 dev 開發環境跟 kubernetes 環境更貼近的一個開發流程工具,你可以透過 skaffold 來協助你對 kubernetes 做開發測試、logging、甚至 debugging。</p><p><br /></p><p>基本上 skaffold 的文章都是拿地端環境 (minikube) 來做測試,不過我沒有,我的環境直接就上 EKS (AWS) 來做這件事,所以我應該不用考慮太多地端的問題。</p><p><br /></p><p>這一篇文章記錄的是將現有專案環境套上 skaffold 的工作流,使得線上線下環境整合得宜。</p><p><br /></p><p>而這一篇文章直得注意的是,我的專案中有 private repo,我們的專案程式語言是 Golang,裡頭需要用到 private go package,而且 skaffold 也拉的是 private docker image。</p><p>而專案中也沒有 k8s manifest ,所以也有涵蓋測試以現有的 k8s namespace 上有的 configmap, secret 和 dependencies pod,直接共享設定和環境,甚至也使用 elb + externaldns 簡略達到對外服務。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Skaffold 專案環境設定</span></h2><p><br /></p><p>Skaffold 安裝後,是一個指令叫做 skaffold,跑起指令後會讀取專案下的 skaffold.yaml 檔案,可以使用 skaffold init 來產生,或是自己寫也可以,以下是自己寫的內容:</p><p>skaffold.yaml:</p><pre class="brush: javascript;" name="code">apiVersion: skaffold/v2beta26
kind: Config
build:
tagPolicy:
envTemplate: # build 後會把 docker image 推到 ECR 這時候會用這個 policy 上 tag
template: "my-staging-latest"
artifacts:
- image: 000000000.dkr.ecr.ap-northeast-1.amazonaws.com/xxxx_your_ecr_repo
docker:
dockerfile: skaffold/dev.dockerfile # docker file 位置
buildArgs: # 如果 docker file 有使用到 env 就可以帶入
AWS_ACCOUNT_ID: xxxxx
deploy:
kubectl:
manifests: # 要套用哪一些 k8s manifest 檔案
- skaffold/k8s-skaffold-dev.yaml
</pre><p></p><p><br /></p><p>(我有故意開一個資料夾放 skaffold 有關的特製檔案 dockerfile, ...etc,原因是我的專案中沒有 k8s manifest,k8s manifest 是在 infra 的 project 中統一管理的)</p><p>skaffold/dev.docekrfile:</p><pre class="brush: javascript;" name="code"># syntax=docker/dockerfile:1
FROM 00000000.dkr.ecr.ap-northeast-1.amazonaws.com/priv-golang-build:1.17 as builder<br />
# first (build) stage
WORKDIR /app
COPY . /app
RUN CGO_ENABLED=0 go build
# final (target) stage
FROM 00000000.dkr.ecr.ap-northeast-1.amazonaws.com/priv-alpine:3.14<br />COPY --from=builder /app/my-program /
EXPOSE 8080
ENTRYPOINT [ "./my-program" ]
</pre><p><br /></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: Times; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p>skaffold/k8s-skaffold-dev.yaml:</p><pre class="brush: javascript;" name="code">---
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-staging-dev
namespace: my-staging
labels:
group: my-staging-dev
spec:
replicas: 1
strategy:
type: Recreate
rollingUpdate: null
selector:
matchLabels:
app: my-staging-dev
template:
metadata:
name: my-staging-dev
labels:
app: my-staging-dev
spec:
containers:
- image: 00000000.dkr.ecr.ap-northeast-1.amazonaws.com/my-program:my-staging-latest<br /> name: my-staging-dev
envFrom:
- configMapRef: # 這裡的 config 都在線上直接共用 namespace 上的 configmap,就不用自己在做一份
name: my-config
- secretRef:
name: my-secret
ports:
- name: http
containerPort: 7070
livenessProbe:
httpGet:
path: "/is_ready"
port: 7070
initialDelaySeconds: 50
periodSeconds: 50
failureThreshold: 50
readinessProbe:
httpGet:
path: "/is_ready"
port: 7070
periodSeconds: 50
failureThreshold: 50
---
apiVersion: v1
kind: Service
metadata:
name: my-staging-dev-service
namespace: my-staging
labels:
app: my-staging-dev
annotations:
external-dns.alpha.kubernetes.io/hostname: my-dev.xxxxxxx.com
spec:
type: LoadBalancer
selector:
app: my-staging-dev
ports:
- name: http
port: 80
protocol: TCP
targetPort: 7070</pre><p><br /></p><p>可以看到上述 k8s 檔案定義兩個資源: deployment 跟 service,這兩個的目的就是讓線上環境直接可以多跟 my-dev 的 domain,直接拿去替換來做測試。</p><p>這些檔案其實可以共享原有專案的設定 k8s, dockerfile,這裡分出來的原因是因為 skaffold 需要的 docker, k8s manifest 應該不一定是專案上直接使用的那個描述設定檔案,所以獨立寫一份。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Skaffold work with a lot of private repository</span></h2><p><br /></p><p>上述的資源都頻繁使用到 Private Repository 的東西,假設你的 manifest 還有使用到 helm 的資源,甚至也需要登入 helm。</p><p><br /></p><p>這裡有兩個狀況需要說明:</p><p><br /></p><p>1. deploy, push docker image</p><p>這兩個狀況有使用到 aws 上的 ecr, eks 服務,你可以透過下面幾個狀況完成登入:</p><p><br /></p><p> aws eks --region ap-northeast-1 update-kubeconfig --name [your eks name]</p><p><br /></p><p>sudo aws ecr get-login-password \</p><p> --region ap-northeast-1 | sudo docker login \</p><p> --username AWS \</p><p> --password-stdin 000000.dkr.ecr.ap-northeast-1.amazonaws.com</p><p><br /></p><p>這兩個東西能在執行 skaffold 前先設定一次,就不會出狀況。</p><p><br /></p><p>2. build docker image</p><p>build docker image 使用 private repo 的話,應該要在執行 skaffold 之前使用 go mod vendor ,不要在 docker image 裡面做 go mod download 之類的事情。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Skaffold</span></h2><p><br /></p><p>然後,一個簡單的指令就能開始進行開發整合:</p><p>skaffold dev</p><p><br /></p><p>如果你的服務第一次可能會產生 fail 的狀況,被 k8s 自動重啟才會好的話,需要更改下指令方式,原因是 skaffold dev 結束後,會幫你刪除資源,此時不要讓他刪除就好:</p><p><span face="ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace" style="background-color: white; color: #24292f; font-size: 12px; white-space: pre;">skaffold dev --no-prune=true --cleanup=false</span></p><p>做這件事之後,只要重複兩次應該就可以進入你的 pod。</p><p><br /></p><p>或是使用另外一個指令也可以 (似乎只要執行一次就好):</p><p><br /></p><p>skaffold dev --status-check=false --wait-for-connection=true</p><p><br /></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Skaffold Debugger with DLV</span></h2><p><br /></p><p>skaffold 很酷的地方就在於可以幫助你做遠端 debug,他使用的方式是用 dlv,自動幫你跑起來,為了做這個設定,我們需要更改設定描述檔案:</p><p><br /></p><p>skaffold/dev.docekrfile:</p><pre class="brush: javascript;" name="code"># syntax=docker/dockerfile:1
FROM 00000.dkr.ecr.ap-northeast-1.amazonaws.com/priv-golang-build:1.17 as builder
# first (build) stage
# Build Delve <- 安裝 dlv
RUN go install github.com/go-delve/delve/cmd/dlv@latest
WORKDIR /app
COPY . /app
ENV GOTRACEBACK=all
RUN CGO_ENABLED=0 go build -gcflags="all=-N -l" # 設定此行,才不會讓偵錯模式亂掉
# final (target) stage
FROM 0000.dkr.ecr.ap-northeast-1.amazonaws.com/priv-alpine:3.14<br />COPY --from=builder /app/my-program /
EXPOSE 8080 56268
COPY --from=builder /go/bin/dlv / <- 帶入 dlv
# sidecar not work
ENV GOTRACEBACK=all # <- 一定要告知這行在最後一個 docker image build stage
ENTRYPOINT [ "./my-program" ]
</pre><p><br /></p><p>此時,你還需要考慮一個狀況,官方表示如果你的 pod 有 sidecar ,這可能會讓斷點偵錯失敗,所以你需要關閉 sidecar (以下例我關閉 istio)</p><p><br /></p><p>skaffold/k8s-skaffold-dev.yaml:</p><pre class="brush: javascript;" name="code"><br class="Apple-interchange-newline" />---
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-staging-dev
namespace: my-staging
labels:
group: my-staging-dev
spec:
replicas: 1
strategy:
type: Recreate
rollingUpdate: null
selector:
matchLabels:
app: my-staging-dev
template:
metadata:
name: my-staging-dev
labels:
app: my-staging-dev
annotations:
sidecar.istio.io/inject: "false" # <- 關閉它</pre><p><br /></p><p>接著,就直接下指令:</p><p><br /></p><p>skaffold debug --status-check=false --wait-for-connection=false --auto-sync=true --port-forward</p><p><br /></p><p>就等程式自動開啟來,此時 skaffold debug 會替換你的 dockerfile entrypoint 加上 dlv 模式,就可以開始偵錯。</p><p><br /></p><p>偵錯工具是使用 vscode,需要寫一個 launch.json 來處理這件事:</p><p>.vscode/launch.json:</p><pre class="brush: javascript;" name="code">{
"configurations": [
{
"name": "Skaffold Debug Kuberentes",
"type": "go",
"request": "attach",
"mode": "remote",
"host": "localhost",
"port": 56268,
}
]
}</pre><p><br /></p><p>如此用 vscode 打開後,就可以斷點偵錯。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">使用 Skaffold 要考量的事情</span></h2><p><br /></p><p></p><ul style="text-align: left;"><li>如果要做 debug ,那麼 pod 需要單一化, replica 可能只有 1。</li><li>官方有說要關掉 sidecar,像是我使用的是 istio,而 sidecar 如果有一些設定跟你的軟體有關,有可能會被忽略</li><li>可能無法拿來除雲端平台或雲端相關的錯誤</li></ul><p></p><p><br /></p><p>References:</p><p>https://skaffold.dev/</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-4425106384100113012022-01-12T00:02:00.001+08:002022-01-12T21:37:34.143+08:00EKS / Kubernetes: 使用 Helm 做升級管理與 CI/CD (Upgrade deployed container version by Helm and Code Build)<p>本篇紀錄讓 k8s 走入 CI/CD 化流程的過程。<span></span></p><a name='more'></a><p></p><p>佈署 Kubernetes 一陣子之後,對現有的軟體開始有 AWS CodeBuild CI/CD 自動化的需求,目前常見有幾種作法可以應對 CI/CD 的問題,這也可以算是情景分類:</p><p><br /></p><p></p><ul style="text-align: left;"><li>(A) 佈署由應用程式端的專案團隊處理</li><ul><li>真正的意思: kubernets manifest 檔案都跟專案放在一起</li><li>專案端持有的檔案:</li><ul><li>全部的 k8s manifest</li><li>build 的 dockerfile</li></ul><li>DevOps 持有的檔案:</li><ul><li>Ingress 端...等需要 certificate, cloud resource 的服務</li></ul></ul><li>(B) 佈署統一由 DevOps 進行處理</li><ul><li>真正的意思: kubernetes manifest 檔案在 DevOps 手上</li><li>專案端持有的檔案:</li><ul><li>build 的 dockerfile</li></ul><li>DevOps 持有的檔案:</li><ul><li>全部的 k8s manifest</li></ul></ul></ul><div><br /></div><div>如果是 (A) 情境下的自動化佈署,其實十分容易,CodeBuild 直接 apply 就好,請參考 CodeBuild buildspec:</div><div><br /><pre class="brush: javascript;" name="code">version: 0.2
phases:
install:
commands:
# 私人的 Go Library Repository 如果有使用 AWS CodeCommit,需要這段進行前處理, go get, go download 才可以載得到私人倉庫
- git config --global credential.helper '!aws codecommit credential-helper $@'
- git config --global credential.UseHttpPath true
- go env -w GOPRIVATE=git-codecommit.ap-southeast-1.amazonaws.com
# install EKS / kubectl to codebuild
- echo Installing app dependencies...
# other version can check here: https://docs.aws.amazon.com/zh_tw/eks/latest/userguide/install-kubectl.html
- curl -o kubectl https://amazon-eks.s3.us-west-2.amazonaws.com/1.20.4/2020-02-22/bin/linux/amd64/kubectl
- chmod +x ./kubectl
- mv ./kubectl /usr/local/bin/kubectl
pre_build:
commands:
# 登入 AWS ECR 服務
- echo Logging in to Amazon ECR...
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
# 標註 Image 標籤的變數
- IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
# 登入 EKS 服務
- echo Entered the pre_build phase...
- echo Logging in to Amazon EKS...
- aws eks --region $AWS_DEFAULT_REGION update-kubeconfig --name $EKS_CLUSTER_NAME
build:
commands:
# Build Docker image
- go mod download
- go mod vendor # 讓私人倉庫的 mod 先載到 vendor 資料夾,帶入 docker container
- docker build --build-arg AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION --build-arg AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI . -t $IMAGE_REPO_NAME:$IMAGE_TAG -f dockerfiles/your_dockerfile_path.dockerfile
- echo Build docker image completed...
- docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
post_build:
commands:
# push docker to ECR (push version as id)
- echo Push image...
- docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
- echo Push image completed...
# EKS apply can be
- kubectl set image deployment/$DEPLOYMENT_NAME [container name]=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
cache:
paths:
- /go/pkg/**/*
</pre></div><div><br /></div><div>不過我的情境則是 (B),DevOps 了解所有需要佈署的應用程式,所以採用集中管理,Project 端只需要顧好一個可以被正常 Build 的 Docker Image (dockerfile) 和 buildspec 就可以了。</div><div><br /></div><div>如此, Kubernetes manifest 的確是可以選擇要放在專案團隊的 Repo 還是移出來,我選擇了移出來,所以隨之面臨到問題,CodeBuild、 CodeDeploy 要如何幫 Kubernetes Deployment 升版?</div><div><br /></div><div>你可以選擇把有 kubernetes mainfest 的 infrastructure 再把 k8s file 丟到 S3,然後這裡 Build 再拉,但是這個過程似乎造成了一個不即時的問題。</div><div><br /></div><div>於是, Helm 就出來了。</div><div><br /></div><div>Helm 不是只有常見的套件安裝而已,事實上我們也可以自己客製化一個自己的服務模板 (Charts),讓 CodeBuild 從 ECR 讀到這個模板 (跟 K8s mainfest 一樣的東西,差別在這是模板),讀到模板,只要再給一些要設定的變數,專案 CodeBuild 不需要放入 k8s manifest 就可以完成自動升版的需求了。</div><div><br /></div><div>Helm 要做的幾件事很重要:</div><div><ul style="text-align: left;"><li>可以帶變數客製化</li><li>可以管理服務升級</li><li>可以 hotfix 設定變數</li><li>DRY</li></ul><div>為了 Helm 建置,要做的幾件事:</div></div><div><ul style="text-align: left;"><li>建立一個自己服務用的 Helm</li><li>ECR Helm</li><li>DevOps 建立服務專案資料夾</li><li>建立 Manage shell</li><li>建立升版 Script、Buildspec</li></ul><div><br /></div></div><h2 style="text-align: left;"><span style="font-size: x-large;">建立一個自己服務用的 Helm</span></h2><div><br /></div><div>假設你有 10 個近乎相同的應用程式,如果你寫成傳統 Kubernetes manifest,基本上就感到維護困難,使用 Helm 就可以讓你少寫或複製 10 個樣板,統一使用一種相近類型的樣板,套用一些設定就可以了。</div><div><br /></div><div>要建立 Helm 模板,使用指令:</div><div><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;">helm create [你的樣板名稱]</code></pre></div><p>建立之後,就可以進去 Template (建議先全刪掉裡面預設內容),然後把服務變成通用模板,你還可以帶自己的模板變數,詳情可以參考這份專案:</p><p><a href="https://github.com/hpcslag/infrastructure_boilerplate/tree/main/kubernetes/helm/standard-server">https://github.com/hpcslag/infrastructure_boilerplate/tree/main/kubernetes/helm/standard-server</a></p></div><div><br /></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">ECR Helm</span></h2><div><br /></div><div>完成模板建置之後,現在要把 Helm 推上 ECR,Helm 近年已經支援 OCI 協議了,可以直接跟 Docker Repository 放在一起,首先要在 ECR 上面建立一個專屬的 Repo,我這裡稱作 standard-server。</div><div><br /></div><div>建立完成後,本地端在準備推上去之前,要先登入 Helm,跟登入 Dockerhub 或登入 ECR 很像:</div><div><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;">export HELM_EXPERIMENTAL_OCI=1
aws ecr get-login-password \
--region ap-southeast-1 | helm registry login \
--username AWS \
--password-stdin $AWS_ACCOUNT_ID.dkr.ecr.ap-southeast-1.amazonaws.com</code></pre></div><p>*這個步驟要特別注意,之後如果執行 helm 相關指令發生連不上,注意可能是沒有登入。</p><p>登入之後,就可以打包 standard-server 這個目錄,然後把檔案推上 ECR:</p><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;">helm package standard-server
helm push standard-server-0.1.0.tgz oci://$AWS_ACCOUNT_ID.dkr.ecr.ap-southeast-1.amazonaws.com/</code></pre></div><p>這樣應該就可以成功推上去,推完之後可以把 tgz 刪掉。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">DevOps 建立服務專案資料夾</span></h2><p><br /></p><p>Helm 隨時都可以使用,每個相關服務都會用同一個模板,但唯有環境變數是客製化的,所以要為每一個服務都建立專屬 Config (也可以建立在每個應用程式專案端,不過這裡為了統一管理 Key,所以由 DevOps 處理),以下是範例目錄結構:</p><p><br /></p><p></p><ul style="text-align: left;"><li>my-server</li><ul><li>manage.sh (這是一個方便管理這個服務的腳本)</li><li>secret.yaml (這是 secrets, 完全是 k8s 寫法)</li><li>values.yaml (這是 common config, helm 要讀取使用的)</li><li>psql.yaml (如果這個服務自己想 maintain 一個 psql,就自己加在這邊)</li></ul></ul><div><br /></div><div>這邊只有 values.yaml 是要給 helm 讀取變數帶入模板的,一個範例是這樣的:</div><p></p><div><br /><pre class="brush: javascript;" name="code"># Default values for my-server
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
# managed by cloudflare, from cloudflare to aws route 53
reverse_proxy_hostname: xxxxxxxxxxxx.your_domain.com
# route 53 lb auto create record to following dns record
domain_name: xxxxxxxxxxxx.your_domain.com
# aws certificate manager issued ssl
certificate_arn: arn://xxxxxxxxxxxxxxxxxxxxxxxxxxx
# machine setting
replicas: 2
strategy_type:
type: Recreate
rollingUpdate: null
# type: RollingUpdate # careful, if app update will change sql table schema, then you cannot do this
# rollingUpdate:
# maxSurge: 1
# maxUnavailable: 0
# app definition
app_name: my-server
app_container_image: XXXXXXXX.dkr.ecr.ap-southeast-1.amazonaws.com/my-server
app_container_image_version: bf79085
app_port: 8080
app_configs:
DATABASE_NAME: "xxxx"
S3_REGION: "us-east-2"
REDIS_ADDR: "redis-leader" # FQDN: http://redis-leader
REDIS_PASSWORD: "redis"
REDIS_PORT: "6379"
env: production
</pre></div><div><br /></div></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">建立 Manage shell</span></h2><div><br /></div><div><br /></div><div>helm 指令不多,但是如果不傻瓜化就很容易出錯,因此我自己有做自動化的腳本:</div><div><p></p><div><br /><pre class="brush: javascript;" name="code"># 記得執行之前,Helm 一定要先登入,執行上方登入腳本
set -e
export HELM_EXPERIMENTAL_OCI=1
# first parameter is operation
namespace=#自訂要安裝的 namespace, 假設叫做: my-server-production, my-server-staging...
helm_chart_oci_repo=oci://$AWS_ACCOUNT_ID.dkr.ecr.ap-southeast-1.amazonaws.com/standard-server
helm_chart_oci_version=0.1.0 # 這個客製化 Helm Charts 的版本,剛才推上 0.1.0
# usage: ./manage.sh install
if [ "$1" = "install" ]; then # 安裝,安裝失敗最好要手動清掉重來
echo "create namespace: $namespace..."
kubectl create namespace $namespace
echo "create secret file..."
kubectl create -f secret.yaml
echo "create helm charts... (with values.yaml)"
helm install --namespace $namespace $namespace-release $helm_chart_oci_repo --version $helm_chart_oci_version -f values.yaml
echo "installed"
fi;
if [ "$1" = "uninstall" ]; then
echo "uninstall helm charts..." # 解除安裝
helm uninstall --namespace $namespace $namespace-release
echo "deleting secret.yaml..."
kubectl delete -f secret.yaml
echo "deleting namespace: $namespace..."
kubectl delete namespace $namespace
echo "uninstalled"
fi;
if [ "$1" = "upgrade" ]; then
echo "please enter the container image version to upgrade: "
read image_version # 請貼上 ECR 應用程式的版本 tag
echo "upgrade helm charts"
helm upgrade --cleanup-on-fail $namespace-release $helm_chart_oci_repo --version $helm_chart_oci_version --namespace $namespace --reuse-values --set app_container_image_version=$image_version
fi;
if [ "$1" = "value" ]; then
echo "upgrade helm charts only value" # 只會更新 value.yaml
helm upgrade $namespace-release $helm_chart_oci_repo --version $helm_chart_oci_version --namespace $namespace -f values.yaml
fi;</pre></div></div><div><br /></div><div>這是一個通用腳本,基本上 cover 安裝、更新、解除安裝、更新值的事情。</div><div><br /></div><div>這個腳本最終會放在 DevOps 端每一個服務資料夾底下,方便管理就是了。</div><div><br /></div><div><br /></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">建立升版 Script、Buildspec</span></h2><div><br /></div><div>前置佈署準備跟 Helm 都完成了,剩下就是應用程式專案端要處理的一些事務,首先應用程式專案還需要多一個 upgrade_service.sh 這個指令檔,協助升級它的應用程式佈署。</div><div><p></p><div><br /><pre class="brush: javascript;" name="code">set -e
export HELM_EXPERIMENTAL_OCI=1
echo "Login helm..."
aws ecr get-login-password \
--region $AWS_DEFAULT_REGION | helm registry login \
--username AWS \
--password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
echo "Upgrade chart..."
helm_chart_oci_repo=oci://$AWS_ACCOUNT_ID.dkr.ecr.ap-southeast-1.amazonaws.com/standard-server
helm_chart_oci_version=0.1.0
helm upgrade --cleanup-on-fail $1 $helm_chart_oci_repo --version $helm_chart_oci_version --namespace $2 --reuse-values --set app_container_image_version=$3</pre></div></div><div><br /></div><div><br /></div><div>這個腳本也是一個通用腳本,可以放在很多個專案共用,這個腳本被執行之後,最後一段升級指令,就會用這個 Helm Charts 更新,而且有加上 flag: --reuse-value 保留之前的環境變數值,最後 --set 去設定那個 deployment 應用程式的版本,就可以完成升級。</div><div><br /></div><div><br /></div><div>最後一步,就是 buildspec 的建立:</div><div><div><pre class="brush: javascript;" name="code">version: 0.2
phases:
install:
commands:
# 私人的 Go Library Repository 如果有使用 AWS CodeCommit,需要這段進行前處理, go get, go download 才可以載得到私人倉庫
- git config --global credential.helper '!aws codecommit credential-helper $@'
- git config --global credential.UseHttpPath true
- go env -w GOPRIVATE=git-codecommit.ap-southeast-1.amazonaws.com
# 安裝 EKS, Kubectl
- echo Installing app dependencies...
# other version can check here: https://docs.aws.amazon.com/zh_tw/eks/latest/userguide/install-kubectl.html
- curl -o kubectl https://amazon-eks.s3.us-west-2.amazonaws.com/1.20.4/2020-02-22/bin/linux/amd64/kubectl
- chmod +x ./kubectl
- mv ./kubectl /usr/local/bin/kubectl
# 安裝 Helm
- echo Install helm...
- wget https://get.helm.sh/helm-v3.7.2-linux-amd64.tar.gz -O helm.tar.gz; tar -xzf helm.tar.gz
- chmod +x ./linux-amd64/helm
- mv ./linux-amd64/helm /usr/local/bin/helm
- echo "Helm installed"
pre_build:
commands:
- echo Logging in to Amazon ECR...
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
# auto versioning
- IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
# 登入 EKS
- echo Entered the pre_build phase...
- echo Logging in to Amazon EKS...
- aws eks --region $AWS_DEFAULT_REGION update-kubeconfig --name $EKS_CLUSTER_NAME
build:
commands:
# Build Docker image
- go mod download
- go mod vendor # 私人模組做成 vendor, 不然 docker build 裡面無法載 CodeCommit Private Repo
- docker build --build-arg AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION --build-arg AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI . -t $IMAGE_REPO_NAME:$IMAGE_TAG -f dockerfiles/your_docker_file_path.dockerfile
- echo Build docker image completed...
- docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
post_build:
commands:
- echo Build completed on `date`
# push docker to ECR (push version as id)
- echo Push image...
- docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
# 更新 Kubernetes 版本升級
- chmod +x ./upgrade_service.sh
- ./upgrade_service.sh my-server-$GIT_BRANCH-release my-server-$GIT_BRANCH $IMAGE_TAG
cache:
paths:
- /go/pkg/**/*
</pre><div><br /></div><p><br /></p></div><div>這個腳本要注意的是最後一個步驟,我的 my-server-$GIT_BRANCH-release 是因為我的有區分 production, staging deploy,我是在 manage.sh 安裝腳本的 namespace 就訂好了,所以我會出現 my-server-staging 這種稱呼的 namespace,最終 manage.sh 安裝時,會指定 helm 的 release name 自動加一個 -release 在後面,名稱就會變成 my-server-staging-release。</div><div><br /></div><div>這裡要注意的是,要給 CodeBuild 加上 CodeCommit, ECR 訪問的權限,否則會發生撈不到 ECR 的問題。</div><p><br /></p><p>References:</p><p>https://qiita.com/yo24/items/75560c56779e4ce80ace</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script></div>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-67605307112694663692022-01-10T22:28:00.004+08:002022-01-10T22:38:35.475+08:00Golang Private Library in AWS CodeCommit: Access from CodeBuild and using Dockerfile build Golang Application Image<p> 使用 AWS 全家桶的服務 Backend,使用 Goland 引用 Private Library 的問題處理,以及讓 Private Repository 進入 CI/CD 流程。</p><span><a name='more'></a></span><p><br /></p><p>這篇文章是專為了使用 AWS CodeCommit 當作 Git Repository 的情境所記錄的解決方案,Golang 若要引用自家在 AWS CodeCommit 上的 Repositroy ,有幾個必要條件,會在第一段進行說明。</p><p><br /></p><p>從本地端如何建立、使用 CodeCommit Private Repo 開始講起,我的情境是發現有很多 Repo 都有共同 Library 需要拆出來,於是就需要把它們放在一個 utiltity 的 project 中,上到 private repository。</p><p><br /></p><p>你必須在 AWS CodeCommit 先建立 Repo,然後用 ssh clone 那個空 Repo 回本地端,在這個專案中我的 Repo Region 是放在新加坡: ap-southeast-1。</p><p><br /></p><p>首先,對 Utiltity Project 做 go mod 初始化:</p><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;">go mod init </code>git-codecommit.ap-southeast-1.amazonaws.com/v1/repos/my-utiltity.git</pre></div><p>請將 AWS Region 和後面的 [my-utiltity.git] 改成自己的,最後結尾一定要加上 .git,否則會讓 Golang 引用不成功。</p><p><br /></p><p>把 Code 都移動到 Project 之後,做 Commit 然後 Push 上去,接下來是其他專案引用的部分,引用之前通常會做 go get -u,但在這之前,要按照 AWS 2021 新的方式使用 credential_helper 協助取得私人 Repo 訪問權限,需要執行下面的指令:</p><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;"></code>git config --global credential.helper '!aws codecommit credential-helper $@'
git config --global credential.UseHttpPath true
go env -w GOPRIVATE=git-codecommit.ap-southeast-1.amazonaws.com
</pre></div><p>上面的 AWS Region 仍需要換成自己的,此時,進行 go get -u 應該就會成功:</p><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;">go get -u git-codecommit.ap-southeast-1.amazonaws.com/v1/repos/my-utiltity.git</code></pre></div><p>接下來就要讓 Code 直接引用,引用方式也要特別注意,都有後贅 .git 要加上去</p><p><br /></p><pre class="brush: go;" name="code">import (
"fmt"
...
"git-codecommit.ap-southeast-1.amazonaws.com/v1/repos/my-utiltity.git/logservice"
"git-codecommit.ap-southeast-1.amazonaws.com/v1/repos/my-utiltity.git/database"
...
)
</pre><p></p><p><br /></p><p>當每次這樣的 Repository 更新的時候,每一個引用到的專案都要使用 go get -u xxx 重新更新,我時常在更新會遇到類似這樣的錯誤,解決方案是去修復舊版的 import 引用,包含已經不存在的引用,透過 go mod tidy 就可以修正了。</p><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;">go get -u git-codecommit.ap-southeast-1.amazonaws.com/v1/repos/my-utiltity.git
go: downloading git-codecommit.ap-southeast-1.amazonaws.com/v1/repos/my-utiltity.git v0.0.0-20220110033125-2d6e7ce212cf
panic: internal error: can't find reason for requirement on golang.org/x/net@v0.0.0-20210805182204-aaa1db679c0d</code></pre></div><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">CodeBuild 處理步驟</span></h2><p><br /></p><p>這樣的 Library 進到 CI/CD 流程,通常還是會遇到相同的問題,無法做 go get,在 Code Build 要做的事情是先去 Code Build 的 IAM Role,賦予它操作 CodeCommit 的所有權限。</p><p><br /></p><p>賦予權限之後,剩下就是 buildspec.yml 如何寫的問題,請參考下方:</p><p><br /></p><pre class="brush: go;" name="code">version: 0.2
# env:
# git-credential-helper: yes # 這一段可以取代下方 commands 使用 git config 那兩段程式碼
phases:
install:
commands:
- git config --global credential.helper '!aws codecommit credential-helper $@'
- git config --global credential.UseHttpPath true
- go env -w GOPRIVATE=git-codecommit.ap-southeast-1.amazonaws.com
- go mod download
build:
commands:
- go build
cache:
paths:
- /go/pkg/**/*
</pre><p>這一段對一般 Golang Application build 不成問題,但是如果是使用 Build Docker Image 的架構怎麼辦? 所以將進入下一章節,如果沒有使用 Docker Image 的話,就不需要往下參考了。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">CodeBuild Docker Build Image 使用 Golang Private Repository 的方法</span></h2><p><br /></p><p>我嘗試過 Pass AWS Credentials 的方法到 Docker Image,它的具體作法是帶出兩個參數給 dockerfile,讓他的 aws-cli 可以自動幫你處理:</p><pre class="brush: go;" name="code">ARG AWS_DEFAULT_REGION
ARG AWS_CONTAINER_CREDENTIALS_RELATIVE_URI
RUN curl "http://169.254.170.2${AWS_CONTAINER_CREDENTIALS_RELATIVE_URI}"
</pre><p>但是這個方法並不是那麼好用,最好的解決方案是直接使用 vendor mode,反正是在 CodeBuild 上做,也就是使用 go mod vendor 指令,再把資料 pass 到 docker image 一起 build, buildspec.yml 如下:</p><pre class="brush: go;" name="code">version: 0.2
phases:
install:
commands:
# private repo env setup
- git config --global credential.helper '!aws codecommit credential-helper $@'
- git config --global credential.UseHttpPath true
- go env -w GOPRIVATE=git-codecommit.ap-southeast-1.amazonaws.com
pre_build:
commands:
#################
# push docker to ECR
- echo Logging in to Amazon ECR...
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
# auto versioning
- IMAGE_TAG=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)
#################
build:
commands:
# for monolith app, first build then image can be compile
- go build
################
# push docker to ECR
- echo Build docker image
- go mod download
- go mod vendor # 這一個指令可以把 vendor 加進來
- docker build --build-arg AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION --build-arg AWS_CONTAINER_CREDENTIALS_RELATIVE_URI=$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI . -t $IMAGE_REPO_NAME:$IMAGE_TAG -f dockerfiles/my.dockerfile
- echo Build docker image completed...
- docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
#################
post_build:
commands:
- echo Build completed on `date`
#################
# push docker to ECR (push version as id)
- echo Push image ...
- docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
- echo Push image completed...
#################
#- echo "$(aws sts get-caller-identity)" # 除錯檢查 IAM Identity
cache:
paths:
- /go/pkg/**/*
</pre><p>而 Dockerfile 的設定為 (dockerfiles/my.dockerfile):</p><pre class="brush: go;" name="code"># syntax=docker/dockerfile:1
FROM golang:1.17 as builder
# first (build) stage
WORKDIR /app
COPY . /app
RUN CGO_ENABLED=0 go build
# final (target) stage
FROM alpine:3.14
COPY --from=builder /app/project_name /
EXPOSE 8080
CMD [ "./project_name", "serve" ]
</pre><p><br /></p><p>References:</p><p>https://pkg.go.dev/cmd/go@master#hdr-Configuration_for_downloading_non_public_code <br />https://stackoverflow.com/questions/69667155/get-a-private-repository-from-aws-codecommit-using-https-grc#<br /></p><p>https://blog.jwr.io/aws/codebuild/container/iam/role/2019/05/30/iam-role-inside-container-inside-aws-codebuild.html</p><p>https://docs.aws.amazon.com/codebuild/latest/userguide/troubleshooting.html#troubleshooting-versions</p><p>https://stackoverflow.com/questions/67923109/codebuild-failing-to-pull-file-from-s3-in-docker-build</p><p><br /></p><p><br /></p>
<script src="https://cdn.jsdelivr.net/gh/gytisrepecka/brush-go/shBrushGo.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-41499073573090333392022-01-09T16:06:00.008+08:002022-01-09T16:27:46.025+08:00PyTorch: 對矩陣做 Softmax 運算<span><a name='more'></a></span><p><br /></p><div id="ipynb_content">
{
"cells": [
{
"cell_type": "markdown",
"id": "420ffd5f-8871-40e6-97b0-001df7ce78ad",
"metadata": {
"tags": []
},
"source": [
"\n",
"原函數定義: https://pytorch.org/docs/stable/generated/torch.nn.functional.softmax.html\n",
"\n",
"$$ Softmax(x_i) = \\frac{e^{x_i}}{\\sum_{j}{e^{x_j}}} $$\n",
"\n",
"其中 $ x_i $ 的 i 是指某個維度上的陣列每一個元素。\n",
"\n",
"其中 $ x_j $ 的 j 是指沿著指定維度上的每一個值,這會被拿來加總成分母。\n",
"\n",
"假設我們生成一個 2 維, 包含兩個 2x2 隨機陣列向量的值:"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "b9b43aca-e48c-4eaa-a3ed-968e33a287e8",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"tensor([[[ 0.4890, 0.0378],\n",
" [-1.4088, 0.3213]],\n",
"\n",
" [[-0.4598, -0.4771],\n",
" [-1.6266, -0.8554]]])\n"
]
}
],
"source": [
"import torch\n",
"import torch.nn.functional as F\n",
"\n",
"input = torch.randn(2,2,2)\n",
"print(input)"
]
},
{
"cell_type": "markdown",
"id": "5178d221-32e9-46dd-8bbf-8b580141a8fe",
"metadata": {},
"source": [
"那麼矩陣就會像以下:\n",
"\n",
"$$\n",
"\\begin{bmatrix}\n",
"\\begin{bmatrix}\n",
"0.4890 & 0.0378\\\\\n",
"-1.4088 & 0.3213\\\\\n",
"\\end{bmatrix} \\\\\n",
"\\begin{bmatrix} \n",
"-0.4598 & -0.4771\\\\\n",
"-1.6266 & -0.8554\\\\\n",
"\\end{bmatrix}\n",
"\\end{bmatrix}\n",
"$$\n",
"\n",
"使用代數來代表這些數值,則有:\n",
"\n",
"$$\n",
"\\begin{bmatrix}\n",
"\\begin{bmatrix}\n",
"x_1 & x_2\\\\\n",
"x_3 & x_4\\\\\n",
"\\end{bmatrix} \\\\\n",
"\\begin{bmatrix} \n",
"x_5 & x_6\\\\\n",
"x_7 & x_8\\\\\n",
"\\end{bmatrix}\n",
"\\end{bmatrix}\n",
"$$\n",
"\n",
"如果對它們做 `softmax` 計算,選擇對第 0 維度做計算,得到:"
]
},
{
"cell_type": "code",
"execution_count": 23,
"id": "b39c4faf-67fb-42e3-8c27-acabe13ed7bb",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"tensor([[[0.7209, 0.6260],\n",
" [0.5542, 0.7644]],\n",
"\n",
" [[0.2791, 0.3740],\n",
" [0.4458, 0.2356]]])\n"
]
}
],
"source": [
"matrix = F.softmax(input, dim=0)\n",
"print(matrix)"
]
},
{
"cell_type": "markdown",
"id": "c9c43304-507d-4baf-8982-a9692e417fb5",
"metadata": {},
"source": [
"`dim=0` 這個計算方式是目前所在位置上當作分子,對所有維度相同位置的數值進行加總後變成分母,相除的值。\n",
"\n",
"在上面的數值中,得到了這樣的結果:\n",
"\n",
"$$\n",
"\\begin{bmatrix}\n",
"\\begin{bmatrix}\n",
"0.7209 & 0.6260\\\\\n",
"0.5542 & 0.7644\\\\\n",
"\\end{bmatrix} \\\\\n",
"\\begin{bmatrix} \n",
"0.2791 & 0.3740\\\\\n",
"0.4458 & 0.2356\\\\\n",
"\\end{bmatrix}\n",
"\\end{bmatrix}\n",
"$$\n",
"\n",
"為了方便解釋,這樣的結果可以想成套上了 s(x) 這個函數,才得到上述的結果:\n",
"\n",
"$$\n",
"\\begin{bmatrix}\n",
"\\begin{bmatrix}\n",
"s(0.4890) & s(0.0378)\\\\\n",
"s(-1.4088) & s(0.3213)\\\\\n",
"\\end{bmatrix} \\\\\n",
"\\begin{bmatrix} \n",
"s(-0.4598) & s(-0.4771)\\\\\n",
"s(-1.6266) & s(-0.8554)\\\\\n",
"\\end{bmatrix}\n",
"\\end{bmatrix}\n",
"$$\n",
"\n",
"\n",
"該演算如下:\n",
"\n",
"假設要計算 $ s(x_1) $,則為\n",
"\n",
"$$\n",
"Softmax(x_1) = \\frac{e^{x_1}}{\\sum_{j}{e^{x_j}}} = \\frac{e^{x_1}}{e^{x_1} + e^{x_5}} = \\frac{e^{0.4890}}{e^{0.4890} + e^{-0.4598}} = 0.7209\n",
"$$\n",
"\n",
"相同計算手法要從 $x_1$ 算到 $x_8$ 共 8 次。"
]
},
{
"cell_type": "markdown",
"id": "3f6f5215-36b7-4098-bcd6-a8fd8890e765",
"metadata": {},
"source": [
"為什麼分母的加總是 $ e^{x_1} + e^{x_5} $ ? \n",
"\n",
"`softmax` 指定 `dim=0`,就是對第 0 維度做計算,這裡的意思是每一個維度上,每一個相同位置進行 `softmax` 計算,我們已知現在要計算所在第一維度的 $ x_1 $ ,而在第二維度上與 $ x_1 $ 相同位置的,就只有 $ x_5 $。\n",
"\n",
"分母的 $ \\sum_{j}{e^{x_j}} $ 就是加總那個維度上的資訊,所以就是分子除上所有位置的算術平均。\n",
"\n",
"用程式碼來驗證的話,即:"
]
},
{
"cell_type": "code",
"execution_count": 41,
"id": "f8e97c77-fae7-4cb5-a05e-906c7dad8ee7",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0.7208737843071955"
]
},
"execution_count": 41,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import math\n",
"ex1 = math.exp(0.4890)\n",
"ex5 = math.exp(-0.4598)\n",
"\n",
"# s(x1) = 𝑠(0.4890)\n",
"ex1 / (ex1 + ex5)"
]
},
{
"cell_type": "code",
"execution_count": 42,
"id": "e57be527-912c-4e5b-b450-7aef1b139c78",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0.6259544445137329"
]
},
"execution_count": 42,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# s(x2) = 𝑠(0.0378) \n",
"ex2 = math.exp(0.0378)\n",
"ex6 = math.exp(-0.4771)\n",
"\n",
"ex2 / (ex2 + ex6)"
]
},
{
"cell_type": "code",
"execution_count": 43,
"id": "61904b76-1a4b-4e09-abeb-804612ba7da4",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0.23564606371434452"
]
},
"execution_count": 43,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"# s(x8) = 𝑠(−0.8554) \n",
"\n",
"ex8 = math.exp(-0.8554)\n",
"ex4 = math.exp(0.3213)\n",
"\n",
"ex8 / (ex4 + ex8)"
]
},
{
"cell_type": "markdown",
"id": "c94e4dd7-31c1-43be-a91e-9e59844d763b",
"metadata": {},
"source": [
"softmax 處理後的結果會有機率論中的特性,直接將 dim=0 ,也就是相同維度的值相加後 = 1。\n",
"\n",
"比方說:\n",
"\n",
"$$ e^{x_1} + e^{x_5} = 0.7209 + 0.2791 = 1 $$\n",
"\n",
"對 dim=n 維度亦同,只是加總的位置不同而已。\n",
"\n",
"## 計算維度為 1 時\n",
"\n",
"如果對它們做 softmax 計算,選擇對第 1 維度 `dim=1` 做計算,得到:"
]
},
{
"cell_type": "code",
"execution_count": 44,
"id": "2eff857d-ac73-46eb-8d29-1c3967d33b10",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"tensor([[[0.8696, 0.4296],\n",
" [0.1304, 0.5704]],\n",
"\n",
" [[0.7626, 0.5935],\n",
" [0.2374, 0.4065]]])\n"
]
}
],
"source": [
"matrix = F.softmax(input, dim=1)\n",
"print(matrix)"
]
},
{
"cell_type": "markdown",
"id": "d7f2baef-6ad7-4d33-8cc6-164e0559db45",
"metadata": {},
"source": [
"`dim=1` 的計算方式是目前所在維度的 `行(row)` 上進行運算:\n",
"\n",
"假設要計算 $ s(x_1) $,則為\n",
"\n",
"$$\n",
"Softmax(x_1) = \\frac{e^{x_1}}{\\sum_{j}{e^{x_j}}} = \\frac{e^{x_1}}{e^{x_1} + e^{x_3}} = \\frac{e^{0.4890}}{e^{0.4890} + e^{-0.8554}} = 0.7209\n",
"$$\n",
"\n",
"*避免混淆,請記得參考 `input` 變數上的矩陣值,不要誤會到 `dim=0` 計算的值。\n",
"\n",
"*相同計算手法也要從 $x_1$ 算到 $x_8$ 共 8 次。 \n",
"\n",
"用程式碼驗算則為:"
]
},
{
"cell_type": "code",
"execution_count": 45,
"id": "35c1a7b8-b912-41e4-aad9-a77f188f369d",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0.8696423263784095"
]
},
"execution_count": 45,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"ex1 = math.exp(0.4890)\n",
"ex3 = math.exp(-1.4088)\n",
"\n",
"# s(x1) = 𝑠(0.4890)\n",
"ex1 / (ex1 + ex3)"
]
},
{
"cell_type": "code",
"execution_count": 47,
"id": "a6e26832-afb9-42b8-8a36-8ac9d527ae38",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"0.5934630182609121"
]
},
"execution_count": 47,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"ex6 = math.exp(-0.4771)\n",
"ex8 = math.exp(-0.8554)\n",
"\n",
"# s(x6) = 𝑠(−0.4771)\n",
"ex6 / (ex6 + ex8)"
]
},
{
"cell_type": "markdown",
"id": "d08d9e0a-1152-4680-a678-41fefe079fd4",
"metadata": {},
"source": [
"## 計算維度為 2, -1 時"
]
},
{
"cell_type": "code",
"execution_count": 52,
"id": "b29239b0-f767-458f-9696-01e54f8add23",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"tensor([[[0.6109, 0.3891],\n",
" [0.1506, 0.8494]],\n",
"\n",
" [[0.5043, 0.4957],\n",
" [0.3162, 0.6838]]])\n",
"===============================\n",
"tensor([[[0.6109, 0.3891],\n",
" [0.1506, 0.8494]],\n",
"\n",
" [[0.5043, 0.4957],\n",
" [0.3162, 0.6838]]])\n"
]
}
],
"source": [
"matrix = F.softmax(input, dim=2)\n",
"print(matrix)\n",
"\n",
"print(\"===============================\")\n",
"\n",
"matrix = F.softmax(input, dim=-1)\n",
"print(matrix)"
]
},
{
"cell_type": "markdown",
"id": "4bd1a3cd-8cc7-467f-9717-25a466f261c9",
"metadata": {},
"source": [
"另一個計算方式是 `dim=2`, `dim=-1` 的情形,這兩個都屬於一樣的計算方式,是將每個維度的每一個 `列 (column)` 進行運算:\n",
"\n",
"假設要計算 $ s(x_1) $,則為\n",
"\n",
"$$\n",
"Softmax(x_1) = \\frac{e^{x_1}}{\\sum_{j}{e^{x_j}}} = \\frac{e^{x_1}}{e^{x_1} + e^{x_2}} = \\frac{e^{0.4890}}{e^{0.4890} + e^{0.0378}} = 0.6109\n",
"$$\n"
]
},
{
"cell_type": "code",
"execution_count": 64,
"id": "3b5102f0-fc0a-452d-a104-e233df041a15",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"s(x1) = 0.61092450679204\n",
"s(x2) = 0.38907549320796\n"
]
}
],
"source": [
"ex1 = math.exp(0.4890)\n",
"ex2 = math.exp(0.0378)\n",
"\n",
"# s(x1) = 𝑠(0.4890)\n",
"print('s(x1) = ' + str(ex1 / (ex1 + ex2)))\n",
"print('s(x2) = ' + str(ex2 / (ex1 + ex2)))"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "dbc67a44-db60-459c-ba25-c6ebe123e59a",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.7.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
</div>
<link rel="stylesheet" href="https://jsvine.github.io/nbpreview/css/notebook.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.12.0/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/notebookjs@0.6.6/notebook.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.12.0/katex.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.12.0/contrib/auto-render.min.js"></script>
<script src="https://jsvine.github.io/nbpreview/js/vendor/es5-shim.min.js"></script>
<script src="https://jsvine.github.io/nbpreview/js/vendor/marked.min.js"></script>
<script src="https://jsvine.github.io/nbpreview/js/vendor/purify.min.js"></script>
<script src="https://jsvine.github.io/nbpreview/js/vendor/ansi_up.min.js"></script>
<script src="https://jsvine.github.io/nbpreview/js/vendor/prism.min.js"></script>
<link rel="stylesheet" href="https://jsvine.github.io/nbpreview/css/vendor/prism.css">
<link rel="stylesheet" href="https://jsvine.github.io/nbpreview/css/nbpreview.css">
<script type="text/javascript">
console.log("execute", nb)
var notebook = nb.parse(JSON.parse(document.getElementById("ipynb_content").innerText));
var rendered = notebook.render();
document.getElementById("ipynb_content").innerHTML = rendered.innerHTML;
</script>
<style type="text/css">
#ipynb_content{
width: 90%;
margin-left: 10%;
}
</style>
<p></p><p><br /></p><p>Reference:</p><p>https://blog.csdn.net/will_ye/article/details/104994504<br />https://stackoverflow.com/questions/52513802/pytorch-softmax-with-dim</p>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-82202628897028127622021-12-26T22:12:00.002+08:002022-01-09T23:47:37.807+08:00AWS EKS Setup: Create via Terraform and manage the settings<span>本篇記錄如何使用 Terraform 建立一個 AWS EKS 服務並進行相關服務設定管理。<br /><a name='more'></a></span><p><br /></p><p></p><h2 style="text-align: left;"><span style="font-size: x-large;">整體架構</span></h2><ol style="text-align: left;"><li>使用 Terraform Cloudposse 佈署 EKS</li><li>加註 EKS 相關 Policy 設定,讓 EKS 有操作 Route53, ECR, ...etc 相關權限</li><li>本地端完成 EKS, eksctl 設定</li><li>使用 Helm 安裝 dashboard, 並從 kubectl proxy 訪問</li><li>從外部訪問 EKS 的 Ingress, kubectl port-forward</li><li>安裝 AWS Load Balancer Controller, 自動控制 Route53, 可使用 ALB 資源等</li><li>Kubernetes EKS to using LoadBalance helping expose the inbound gateway</li><li>VPC 之間進入 EKS 的方法,使得 EKS 可以存取其他 AWS 內部服務 (在 VPC Peer Connection 也有紀錄)</li></ol><p></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">使用 Clousposse EKS 進行 Deploy</span></h2><p><br /></p><p>自建 EKS Terraform 會遇到許多 config 的問題,在這個狀況下比較適合使用 Cloudposse 寫好的 modules 直接使用,官方範例是: </p><p>https://github.com/cloudposse/terraform-aws-eks-cluster/tree/master/examples/complete</p><p>不過本篇的範例自己有建立一個專屬的模組,有套用到特殊的 Policy,讓 EKS 本身具有以下權限:</p><p></p><ul style="text-align: left;"><li>支援 Elastic Container Repository 操作,在 k8s pull 私人映像倉庫</li><li>支援 AWS Route 53</li><li>支援 AWS Elastic Load Balancer</li></ul><div><br /></div><div>模組請參考這個 Repo: </div><div><br /></div><div>https://github.com/hpcslag/infrastructure_boilerplate/tree/main/terraform/modules/eks_node_group</div><div><br /></div><div>在這個模組中,IAM 權限主要是在 node_instance_role_policy.tpl:<br /><br /></div><div>https://github.com/hpcslag/infrastructure_boilerplate/blob/main/terraform/policies/node_instance_role_policy.tpl</div><div><br /></div><div>這個意思是 EKS 的實體 (EC2) 本身具有的權限,或是說 pod、service, deployment...etc 操作時使用到的權限,如果有安裝一些 kuberentes extensions 要記得把 policy 加在這裡,然後直接 apply 即可。</div><div><br /></div><div>另外需要注意本篇文章自己寫的 Cloudposse EKS Module 裡面的 subnet, vpc 也都是用 cloudposse 的,這類模組通常會讓你失去一些調整空間,會用比較麻煩的方式進行調整,如果碰到狀況請記得自己手動替換掉,直接用 resource (aws_vpc) 手寫它會減少很多 try & error 的時間。</div><div><br /></div><div>其使用方式為:</div><div><br /></div><div><pre class="brush: javascript;" name="code">// to remove it, use: terraform destroy -target=module.my_eks
module "my_eks" {
source = "./modules/eks_node_group"
endpoint_public_access = true
region = "ap-southeast-1"
account_id = "${data.aws_caller_identity.current.account_id}"
availability_zones = ["ap-southeast-1a", "ap-southeast-1b", "ap-southeast-1c"]
enabled_cluster_log_types = ["api", "audit", "authenticator", "controllerManager", "scheduler"]
instance_types = [ "t2.small" ]
kubernetes_version = "1.20"
kubernetes_labels = {
}
desired_size = 2
max_size = 2
min_size = 1
cluster_encryption_config_enabled = false
cluster_encryption_config_kms_key_enable_key_rotation = false
# let codebuild can use credential get into k8s
# follow setting: https://itnext.io/continuous-deployment-to-kubernetes-eks-using-aws-codepipeline-aws-codecommit-and-aws-codebuild-fce7d6c18e83 and https://www.padok.fr/en/blog/codepipeline-eks-helm
// map_additional_iam_users = [
// {
// userarn = something.codebuild_role_arn
// username = something.codebuild_role_name
// groups = [ "system:masters" ]
// }
// ]
}</pre><p><br /></p></div><div>其中最下面的 map_additional_iam_users 是給 CodeBuild 使用的,這個選項會再另一篇文章: EKS / Kubernetes: Upgrade Deployment Container Version by Helm and Code Build 做紀錄使用步驟。</div><div><br /></div><div><br /></div><div>EKS 建置有分為兩種建立 Instance 的策略: Node Group, Worker Group,其中 Node Group 是讓 AWS 自己管理每一個節點,只要你給他數字即可, Worker Group 是讓你自己給他 EC2 機器跟他們的定義,這個設定會很 Detail,本篇文章使用的是 Node Group 的策略。</div><div><br /></div><div>要調整策略,詳情可以看 Cloudposse EKS 文件的說明。</div><div><br /></div><div>使用 Terraform 安裝後,需要等待大概 9 分鐘完成建置,要注意不要在 Terraform EKS Creating 期間刪除 iam role, policy,這樣可能會導致 eks 無法成功建置,需要重新建立。</div><p><br /></p><p>*額外須注意,如果要刪除這個 EKS 資源,記得要先用 terraform destroy -target=module.my_eks 這個指令,再把該段 module 程式碼移除,免得直接砍該段程式碼會發生模組 provider 找不到的問題。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">設定 Kubectl</span></h2><p><br /></p><p>完成建立後,要讓本地電腦的 Kubectl 可以直接控制的話,要做 .kube/config 的設定,指令是:</p><pre class="brush: bash;" name="code" style="-webkit-text-stroke-width: 0px;">aws eks --region [ap-southeast-1/改成自己的] update-kubeconfig --name cluster
</pre><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">安裝 Kubernetes Dashboard</span></h2><p><br /></p><p>完成 Kubectl 設定之後,就可以設定一個 Dashboard,這個 Dashhboard 就可以用網頁來檢查 Monitoring 狀況。</p><p><br /></p><pre class="brush: bash;" name="code" style="-webkit-text-stroke-width: 0px;">kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.5/aio/deploy/recommended.yaml
</pre><p>這裡還需要額外套用 service account 去存取更高階的 monitoring 資源,檔名叫做: sa.yaml:</p><p><br /></p><div><pre class="brush: javascript;" name="code">apiVersion: v1
kind: ServiceAccount
metadata:
name: eks-admin
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: eks-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: eks-admin
namespace: kube-system</pre><p><br /></p></div><p>完成後就直接套用:</p><pre class="brush: bash;" name="code" style="-webkit-text-stroke-width: 0px;">kubectl apply -f sa.yaml
</pre><p>現在已經完成有關 Dashboard 的安裝,接下來要進入 Dashboard,要用 proxy 的方式進去,方式是在本地電腦中使用 kube proxy,打開瀏覽器找:</p><p><a class="unchecked" href="http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/#!/login" rel="noopener noreferrer" style="background-color: white; font-family: "Amazon Ember", "Helvetica Neue", Roboto, Arial, sans-serif; font-size: 16px;" target="_blank">http://localhost:8001/api/v1/namespaces/kubernetes-dashboard/services/https:kubernetes-dashboard:/proxy/#!/login</a></p><p>接著要輸入 Token,相關的 Token 要下指令去產生:</p><p><br /></p><pre class="brush: bash;" name="code" style="-webkit-text-stroke-width: 0px;">kubectl -n kube-system describe secret $(kubectl -n kube-system get secret | grep eks-admin | awk '{print $1}')
</pre><p>複製貼上之後就可以進去 Web UI 了。</p><p><br /></p><p>有關 Dashboard 相關安裝方式,也可以參考官方的文件說明:</p><p>https://docs.aws.amazon.com/eks/latest/userguide/dashboard-tutorial.html</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">EKS 如何將服務對外</span></h2><p><br /></p><p>在 EKS 中只有使用 ELB 相關服務才可以將服務對外公開, ELB 有幾種類型的 Load Balance 可以使用,這裡只介紹 3 種常用的:</p><p></p><ol style="text-align: left;"><li>Classic Load Balancer (aws 已不推薦使用,請盡量不要使用)</li><li>Network Load Balancer (可以使用特定 Port, Protocol,以及 IP Mode)</li><li>Application Load Balancer (只能做 HTTP, API 類的服務)</li></ol><div>基本上 k8s cluster 上的資源只會被 ingress, service 公開, 第一種 CLB (Classic Load Balancer) 的使用方式是:</div><div><br /></div><div><pre class="brush: javascript;" name="code">apiVersion: v1
kind: Service
metadata:
name: nginx-ingress-loadbalancer
namespace: default
spec:
type: LoadBalancer
selector:
app: xxxxxxx
ports:
- protocol: TCP
port: 80 # From URL incomming: 80
targetPort: 8080 # mapping to 8080</pre><p><br /></p></div><div>一但套用這個設定之後,就可以在 kubectl get svc 看到 elb 的 hostname,而且在 AWS 後台的 Load Balancer 服務會看到上面有建立,而且寫 Classic Load Balancer 標註,還會提醒你升級。</div><div><br /></div><div>這個方法請盡量不要使用,建議使用 2, 3。</div><div><br /></div><div>要特別注意的是,2, 3 要建立的話,一定一定要安裝 AWS Load Balancer 這個 k8s extension 到你的 cluster,而且還要做相關設定,以下是 AWS Load Balancer 安裝。</div><div><br /></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">AWS LoadBalance Controller 安裝</span></h2><div><br /></div><div>k8s cluster 要讓 service, ingress 自動建立 Load Balancer 有幾個前置設定條件: </div><div><ol style="text-align: left;"><li>Terraform K8S Label 設定</li><li>有給 IAM Role 權限去設定 elb 相關服務</li></ol><div><br /></div></div><div>1. 指的 k8s label 我已經在我的模組中有標註好了,他在 <a href="https://github.com/hpcslag/infrastructure_boilerplate/blob/main/terraform/modules/eks_node_group/providers.tf">providers.tf</a> 中,每個 eks cluster 的 subnet 中要有這樣的設定:</div><div><br /></div><div><div><pre class="brush: javascript;" name="code">public_subnets_additional_tags = {
"kubernetes.io/role/elb" : 1
}
private_subnets_additional_tags = {
"kubernetes.io/role/internal-elb" : 1
}</pre><p><br /></p></div></div><div>第二個設定也可以參考 <a href="https://github.com/hpcslag/infrastructure_boilerplate/blob/main/terraform/policies/node_instance_role_policy.tpl">node_instance_role_policy.tpl</a> 中的 elasticloadbalancing 相關權限。</div><div><br /></div><div>然後,接著要安裝 cert manager:</div><div><br /></div><div><br /></div><div><pre class="brush: bash;" name="code" style="-webkit-text-stroke-width: 0px;">kubectl apply --validate=false -f https://github.com/jetstack/cert-manager/releases/download/v1.5.3/cert-manager.yaml
</pre></div><div><br /></div><div>再安裝 AWS Load Balancer Controller:</div><div><br /></div><div><div><br /></div><div><pre class="brush: bash;" name="code" style="-webkit-text-stroke-width: 0px;">kubectl apply -f https://github.com/kubernetes-sigs/aws-load-balancer-controller/releases/download/v2.3.1/v2_3_1_full.yaml
</pre></div><div>要稍微注意這個 apply 要是出錯,就再 apply 一次應該就會成功。</div></div><div><br /></div><div>詳情安裝說明也可參考 Kubernetes-sigs: https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.3/deploy/installation/</div><div><br /></div><div>如此一來,只要服務套用到類似的設定,就會自動建立 2, 3 類型的 ELB ,以下以 ingress 為例子:</div><div><br /></div><div><br /></div><div><pre class="brush: javascript;" name="code">apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: xxxxx-ingress
annotations:
kubernetes.io/ingress.class: alb
alb.ingress.kubernetes.io/scheme: internet-facing
external-dns.alpha.kubernetes.io/hostname: xxx.aaaa.com # 可以搭配 route 53 binding 使用,在下一節介紹
# alb.ingress.kubernetes.io/target-type: ip # nlb
# SSL Setting, 下下節介紹
# https://aws.amazon.com/premiumsupport/knowledge-center/terminate-https-traffic-eks-acm/
# https://www.stacksimplify.com/aws-eks/aws-alb-ingress/learn-to-enable-ssl-on-alb-ingress-service-in-kubernetes-on-aws-eks/
# Must issue first on aws
alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}, {"HTTP":80}]'
alb.ingress.kubernetes.io/certificate-arn: [需要填上 certificate-arn]
spec:
rules:
- host: xxxx.bbbb.com # 用來 proxy 用的,可以接當 nginx,從 aaaa.com 進來要用 CNAME 變成 xxxx.bbbb.com 才會被轉到下面,這可以用在 cloudflare
http:
paths:
- path: /*
backend:
serviceName: xxxxx-service
servicePort: 80</pre><p><br /></p></div><div>其餘 NLB-IP Mode 請另外參考文件說明:</div><div><a href="https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.1/guide/service/nlb_ip_mode/">https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.1/guide/service/nlb_ip_mode/</a></div><div><br /></div><div><br /></div><div>套用設定之後,應該就可以在 aws 後台看到 nlb, alb 類型的 load balancer,在 kubectl get svc 裡面也可以看到已經成功被分配地址,如果開 kubectl get svc 顯示 Pendding,表示有地方設定錯誤,可能要檢查看看 kubectl describe svc 看看有沒有權限錯誤,使用 kubectl describe 檢查錯誤是一個好的方法。</div><div><br /></div><div><br /></div><p></p><h2 style="text-align: left;"><span style="font-size: x-large;">AWS: Route 53 自動建立 DNS 紀錄 - 安裝 External DNS</span></h2><p><br /></p><p>這裡可以讓 ingress, service 自動幫你建立指定名稱的 dns 記錄到你的 route 53 上,這個就必須要安裝 External DNS,這類自動設定 DNS 的功能就叫做 External DNS。</p><p>這裡要特別說,如果要讓 EKS 自動處理 Rotue 53 紀錄,那你的網域整個就會被 AWS 綁走,因為你會需要把你的域名 nameserver 改到 AWS,建議自己另外買一個 domain name 來掛給 Route 53,然後在你的主域名用 CNAME 轉過來。</p><p>設定方式還是有點複雜,也很難找到好的說明,所以我在這邊會記錄的較詳盡一些。</p><p></p><ol style="text-align: left;"><li>先建立一組 Policy Name: AmazonEKSClusterPolicy<br /><pre class="brush: javascript;" name="code">{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"route53:ChangeResourceRecordSets"
],
"Resource": [
"arn:aws:route53:::hostedzone/*"
]
},
{
"Effect": "Allow",
"Action": [
"route53:ListHostedZones",
"route53:ListResourceRecordSets"
],
"Resource": [
"*"
]
}
]
}</pre>
</li><li>EKS 建立完之後,對應的 ODIC Policy 才會被建立出來,所以一定要手動做,去 AWS IAM 裡面,找到 Role,選擇建立一個 Role。</li><li>選擇建立 Web Identity</li><li>Provider 選擇 ODIC,選到 EKS 那組 ODIC</li><li>找到剛才建立的那組 AmazonEKSClusterPolicy,把它 attach 進去你這組 role</li><li>Role 名稱這裡取名 AmazonEKSClusterODICRole</li><li>建立後,這個 arn 要複製一下,等下要使用</li></ol><div><br /></div><div>接著,建立一個 external-dns.yaml 檔案,並請對裡面內容進行修改:</div><div><br /></div><div><br /></div><div><pre class="brush: javascript;" name="code">apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
# If you're using Amazon EKS with IAM Roles for Service Accounts, specify the following annotation.
# Otherwise, you may safely omit it.
annotations:
# Substitute your account ID and IAM service role name below.
eks.amazonaws.com/role-arn: arn:aws:iam::[你的帳號 ID]:role/AmazonEKSClusterODICRole
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services","endpoints","pods"]
verbs: ["get","watch","list"]
- apiGroups: ["extensions"]
resources: ["ingresses"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["list","watch"]
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: external-dns-viewer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
spec:
strategy:
type: Recreate
selector:
matchLabels:
app: external-dns
template:
metadata:
labels:
app: external-dns
# If you're using kiam or kube2iam, specify the following annotation.
# Otherwise, you may safely omit it.
annotations:
iam.amazonaws.com/role: arn:aws:iam::[你的帳號 ID]:role/AmazonEKSClusterODICRole<br /> spec:
serviceAccountName: external-dns
containers:
- name: external-dns
image: ghcr.io/kubernetes-sigs/external-dns/external-dns:latest
args:
- --source=service
- --source=ingress
- --provider=aws
- --policy=sync # upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization https://stackoverflow.com/questions/67408554/bitnami-external-dns-does-not-remove-route53
- --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both)
- --registry=txt
- --txt-owner-id=my-hostedzone-identifier
securityContext:
fsGroup: 65534 # For ExternalDNS to be able to read Kubernetes and AWS token files</pre><p><br /></p></div><p></p><p>然後做 kubectl apply -f external-dns.yaml 就可以安裝,然後它就會自己根據你的 ingress 去幫你建立 DNS,每分鐘掃一次。</p><p><br /></p><p>其中模式常見有兩個: sync, upsert-only,分別用途是 sync 會刪除、更新、建立, upsert-only 只會更新、建立不會刪除。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Ingress 有 HTTPS 憑證,使用 AWS 憑證: Certification Manager 服務</span></h2><p><br /></p><p>在 Load Balancer 那一節有看到 alb.ingress.kubernetes.io/certificate-arn 這樣的設定,這是給 load balancer 套用憑證使用的,如果不套用憑證,你的 Domain Name 就會呈現不安全的提示,因此可以使用 AWS Certification Manager 來套用,如果有把 Cloudflare Domain 做 CNAME 轉過來,也可以用來當作 Endpoint SSL。</p><p>由於 AWS Cerification Manger 設定繁複,假設你的網域放在 Route53,設定比較方便,我為此寫了一個腳本,可以發行你的 Domain 的 Sub Domain SSL,並取得 ARN。</p><p>這組 SSL 的 ARN 就是要給 https://alb.ingress.kubernetes.io/certificate-arn 使用的,它可以自動套用 AWS 上的憑證,網頁就不會再不安全。</p><p>基本上你第一件事就是到確定 Route53 上有你的 Domain Name,然後執行下方我寫的 shell script:</p><p><br /></p><div><pre class="brush: javascript;" name="code">set -e
echo "Please Enter the domain name ([sub].YourDomain.com): "
read DOMAIN_NAME
# DOMAIN_NAME=xxxxx.YourDomain.com
echo "Please Enter the original validation domain name (YourDomain.com): "
read VALIDATION_DOMAIN_NAME
# VALIDATION_DOMAIN_NAME=YourDomain.com
echo "Issuing certificate $DOMAIN_NAME..."
CREATED_ARN="$(aws acm request-certificate --domain-name $DOMAIN_NAME --validation-method DNS | jq -r '.CertificateArn')"
echo "Certificate issued: ($CREATED_ARN),
wait 10 sec to get pendding validation info..."
sleep 10
echo "Auto validating..."
PENDDING_VALIDATION_JSON="$(aws acm describe-certificate --certificate-arn $CREATED_ARN | jq '.Certificate | .DomainValidationOptions | .[0].ResourceRecord' )"
RecordName="$(echo $PENDDING_VALIDATION_JSON | jq -r '.Name')"
RecordType="$(echo $PENDDING_VALIDATION_JSON | jq -r '.Type')"
RecordValue="$(echo $PENDDING_VALIDATION_JSON | jq -r '.Value')"
echo "Get your route53 host zone id..."
Route53HostedZoneId="$(aws route53 list-hosted-zones-by-name | jq -r '.HostedZones | .[] | select(.Name="$VALIDATION_DOMAIN_NAME*") | .Id')"
echo "Create route53 validation record..."
aws route53 change-resource-record-sets --hosted-zone-id $Route53HostedZoneId --change-batch "$(echo "{
\"Comment\": \"RECORD FOR VALIDATE CERTIFICATE\",
\"Changes\": [{
\"Action\": \"CREATE\",
\"ResourceRecordSet\": {
\"Name\": \"$RecordName\",
\"Type\": \"$RecordType\",
\"TTL\": 300,
\"ResourceRecords\": [{ \"Value\": \"$RecordValue\" }]
}
}]
}")"
echo "Validation record created..."
echo "Wait for validation done..., 65 is decribe in docs, said that it will check every 60 sec for 250 times"
sleep 65
aws route53 change-resource-record-sets --hosted-zone-id $Route53HostedZoneId --change-batch "$(echo "{
\"Comment\": \"RECORD FOR VALIDATE CERTIFICATE\",
\"Changes\": [{
\"Action\": \"DELETE\",
\"ResourceRecordSet\": {
\"Name\": \"$RecordName\",
\"Type\": \"$RecordType\",
\"TTL\": 300,
\"ResourceRecords\": [{ \"Value\": \"$RecordValue\" }]
}
}]
}")"
echo "Checking Validate status..."
aws acm describe-certificate --certificate-arn $CREATED_ARN | jq '.Certificate | .DomainValidationOptions[0].ValidationStatus'</pre><p><br /></p></div><p>直接執行它,它的第一個問的就是你要簽的 SSL Sub Domain Name (也可以是 Main Domain),第二個問的是 Route 53 上你的 Domain Name 是什麼,然後它就會自己幫你簽,簽一次要等 60 秒左右。</p><p><br /></p><p>把取得到的 arn 放到 k8s yaml file,它就會自己生效 SSL 了。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">把 Pod / Service / Deployment 轉回本地端進行測試</span></h2><p><br /></p><p>雲端上的 Pod, Service, Deployment 會有點難除錯一些服務問題,可以把它直接導入本地環境進行測試,可以使用 kubectl port-forward 來處理,就可以從 localhost:xxxx 訪問到遠端服務。</p><p><br /></p><p>其指令是:</p><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;">kubectl port-forward pods/xxxxxx 28015:27017</code></pre></div><p>一但進行 port-forward 後,就可以從 localhost:28015 存取到遠端 xxxx:27017 的服務,詳情其他 pod, svc, deploy,... 使用方式可以參考文件: <br /><a href="https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/">https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/</a></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">除錯 Kubectl 問題的常用方式</span></h2><p><br /></p><p>使用 Describe, Log 這兩個指令查看權限、pull 失敗是一個非常好的方案,而如果 deployment 的 container 使用 log 那組 pod 太久沒有回應,可能是你的資料庫連不上或其他服務連不上導致你的 application 卡住,然後重啟,重啟就會看不到 log,如果多試幾次剛好在噴 log 的時候查看到,就可以剛好看到它是資料庫連不上。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">使用 VPC Peer Connection 讓 Kubectl 存取到 RDS、其他網路的 EC2 服務</span></h2><p><br /></p><p>文章前面有提到使用了 Cloudposse 的 VPC, Subnet 來建立 EKS 內部的網路,所以其他 EC2, RDS 網路自然都會被區分,而特別要注意的是 Cloudposse VPC 沒法調整 CIDR,你的網段基本上已經被寫死分配了,這個時候要避免想要連線其他的服務的 CIDR 也相同。</p><p>意思是假設 Cloudposse EKS 分配了 172.16.0.0/16 整段 CIDR,那你的 RDS, EC2...etc 服務的 VPC CIDR 一定要錯開 172.16.0.0/16,可以取名 10.100.0.0/16, 192.168.5.0/16,建立 Peer Connection 才可以錯開。</p><p>我的模組會需要讓你自己自訂 RDS 前綴,就是為了避免這個狀況發生。</p><p><br /></p><p>這個 VPC Peer Connection 的具體用例我寫在 peering 模組中,它的範例是:</p><p><br /></p><pre class="brush: javascript;" name="code">module "peering_eks_to_rds" {
source = "./modules/peering"
namespace = "eks2rds_connector"
this_vpc_id = module.my_rds.vpc_id
this_vpc_route_table_ids = [module.my_rds.vpc_route_table_id]
this_vpc_cidr = module.my_rds.vpc_cidr
this_vpc_security_group_id = module.my_rds.db_security_group_id
peer_vpc_id = module.my_eks.eks_vpc_id
peer_vpc_route_table_ids = module.my_eks.vpc_all_route_table_ids
peer_vpc_cidr = module.my_eks.vpc_cidr
peer_vpc_security_group_id = module.my_eks.eks_cluster_security_group_id
depends_on = [
module.my_rds,
module.my_eks
]
}</pre><p><br /></p><p>在這裡建立 Peer Connection 後,通常 kubectl 還是會走 private subnet 去撈,建議連線時給出 public 的資源網址,基本上就可以被 routing table 找到。</p><p><br /></p><p><br /></p><p><br /></p><p>*建議: 沒事不要把 Kubernetes 安裝在地端,要用 Cluster 服務最好還是上雲。</p><p><br /></p><p>References:</p><p>https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/</p><p>https://github.com/kubernetes-sigs/external-dns/blob/master/docs/tutorials/aws.md</p><p>https://www.padok.fr/en/blog/external-dns-route53-eks</p><p>https://docs.aws.amazon.com/eks/latest/userguide/dashboard-tutorial.html</p><p>https://docs.aws.amazon.com/eks/latest/userguide/create-kubeconfig.html</p><p>https://github.com/cloudposse/terraform-aws-eks-cluster</p><p>https://www.cnblogs.com/Star-Haitian/articles/15308758.html</p><p>https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.2/deploy/installation/#iam-permissions</p><p>https://aws.amazon.com/tw/premiumsupport/knowledge-center/eks-api-server-unauthorized-error/</p><p>https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.3/deploy/installation/</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushBash.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-25061998350750535632021-12-24T00:51:00.007+08:002021-12-29T22:43:52.952+08:00AWS VPC Peer Connection helping EKS (Private Gateway) connect bidirectionally to RDS Routing by Terraform<p>這一陣子在處理許多 DevOps 的問題,遇到了要讓 EKS Cluster 存取不同 VPC 服務之間的問題,在 AWS 架構下可以使用 VPC 的 Peer Connection 來讓兩個不同 VPC 的網路對聯,交換 Route Table 地址,這樣彼此就可以發現對方。</p><span><a name='more'></a></span><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">原理</span></h2><p><br /></p><p>除了讓 A, B 對連線外,還會讓 A 服務的 Network Route Table 知道 B 的 Network CIDR Table,這樣 A, B 就可以存取(發現)到不同網段下的電腦,像是 BGP Routing Table 這樣。</p><p><br /></p><p>但是不可以存取跟自己相同 VPC CIDR 的服務,那樣 Route Table 會亂掉,見下方注意事項。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">注意</span></h2><p><br /></p><p>當實施 VPC Peer Connection 架構下,需要特別注意如果還想要存取更多不同 VPC 資源,就一定要注意 VPC CIDR 分配不可以衝突,因此一定要做好分配。</p><p><br /></p><p>服務少的話,使用 172.16.0.0/12 架構來分配 AWS 上的資源應該是沒問題的,比方說 EKS (CIDR: 172.16.1.0/24) Peer Connection CIDR 172.16.20.0/24 下的網路,以此類推 10.0.0.0, 192.168...,其網段數量計算可以使用 subnet mask calculator 來看地址 cover 多少電腦。 (私人網段的 spec 可以參考 RFC 1918。)</p><p><br /></p><p>也許一般來說用 Terraform 因為每個服務都當成 module 複製來複製去,鮮少需要改 VPC CIDR,但如果會發生這樣的狀況,那就一定要注意要手動去修改不同 VPC 的 CIDR,不要讓他們碰撞。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Terraform Module</span></h2><p><br /></p><p>這個功能會直接寫成模組,這個模組最主要的目的是讓兩個 VPC 可以做對連,兩個模組的 VPC ID 要使用最主要的那個 VPC。</p><p>由於本章節要解決的是讓 EKS 的 Terraform 模組可以連線到 RDS 服務上,以此作為範例,EKS 我使用的模組是 cloudposse 的,他在設定上會有點礙手礙腳,但總之要選用 eks 的 main VPC</p><p>對連的 Terraform 設定會碰到一個需要注意的問題,我將這個問題放到下一個小節提及。</p><p><br /></p><p>在這個模組中最主要就是建立一個 aws_vpc_peering_connection、兩個 (對連雙方)aws_route 及 aws_security_group (用於對連連線安全組控制)。</p><p>建立後讓 aws_route 去套用同一個 aws_vpc_peering_connection。</p><p><br /></p><p>而 security_group 則是要讓 eks 或 rds 各自的流量可以互通,我是開 all,但幾於資安考量的話,可以再鎖定到比較細的 security group 規則。</p><p><br /></p><p>檔案結構:</p><p></p><ul style="text-align: left;"><li>main.tf</li><li>var.tf</li></ul><p></p><p><script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script></p><p><br /></p><p>main.tf:</p><pre class="brush: javascript;" name="code">resource "aws_vpc_peering_connection" "PEER_A2B" {
peer_vpc_id = var.peer_vpc_id
vpc_id = var.this_vpc_id # acceptor is us
auto_accept = true
accepter {
allow_remote_vpc_dns_resolution = true
}
requester {
allow_remote_vpc_dns_resolution = true
}
}
# bind to existsed rotuer table
# aws router (e.g rds to eks)
resource "aws_route" "A2B" {
count = length(var.this_vpc_route_table_ids)
# this
route_table_id = var.this_vpc_route_table_ids[count.index]
# to b
destination_cidr_block = var.peer_vpc_cidr
vpc_peering_connection_id = aws_vpc_peering_connection.PEER_A2B.id
}
# aws router (e.g eks to rds)
resource "aws_route" "B2A" {
count = length(var.peer_vpc_route_table_ids)
# peer
route_table_id = var.peer_vpc_route_table_ids[count.index]
# to a
destination_cidr_block = var.this_vpc_cidr
vpc_peering_connection_id = aws_vpc_peering_connection.PEER_A2B.id
}
# These may cause aws_security_group_rule everytime modify needs to apply two time... (WARNING)
resource "aws_security_group_rule" "A" {
description = "Peering Config A (terraform-managed)"
# count = length([var.peer_vpc_cidr]) could be more but only do once now
type = "ingress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [var.peer_vpc_cidr]
security_group_id = var.this_vpc_security_group_id
}
// InvalidPermission.Duplicate: because everyone apply the same rule
resource "aws_security_group_rule" "B" {
description = "Peering Config B (terraform-managed)"
# count = length(var.this_vpc_cidr) could be more but only do once now
type = "ingress"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [var.this_vpc_cidr]
security_group_id = var.peer_vpc_security_group_id
}</pre><p><br class="Apple-interchange-newline" />var.tf:</p><pre class="brush: javascript;" name="code">variable "namespace" {
type = string
}
variable "this_vpc_id" {
type = string
}
variable "this_vpc_security_group_id" {
type = string
}
variable "this_vpc_route_table_ids" {
type = list(string)
}
variable "this_vpc_cidr" {
type = string
}
variable "peer_vpc_id" {
type = string
}
variable "peer_vpc_security_group_id" {
type = string
}
variable "peer_vpc_route_table_ids" {
type = list(string)
}
variable "peer_vpc_cidr" {
type = string
}</pre><p><br /></p><p>叫用 Module:</p><p><br /></p><pre class="brush: javascript;" name="code">module "peering_eks_to_rds" {
source = "./modules/peering"
namespace = "eks2rds_connector"
this_vpc_id = module.my_db_instance.vpc_id
this_vpc_route_table_ids = [module.my_db_instance.vpc_route_table_id]
this_vpc_cidr = module.my_db_instance.vpc_cidr
this_vpc_security_group_id = module.my_db_instance.db_security_group_id
peer_vpc_id = module.my_eks.eks_vpc_id
peer_vpc_route_table_ids = module.my_eks.vpc_all_route_table_ids
peer_vpc_cidr = module.my_eks.vpc_cidr
peer_vpc_security_group_id = module.my_eks.eks_cluster_security_group_id
depends_on = [
module.my_db_instance,
module.my_eks
]
}</pre><p><br /></p><p>以上解釋一下關於 this_vpc_route_table_ids 會是陣列的原因,是因為有可能套用連線的 vpc 有好幾個 route table,就可以一併加上去,但如果只有一個,那就只填一個進陣列。</p><p><br /></p><h2 style="text-align: left;">模組設計與要面臨的問題</h2><p><br /></p><p>我強烈建議先看完這個問題再開始進行 IaaC 調整,導入 VPC Peer Connection 進去你的架構時會碰到好幾個問題:</p><p>1. 你的 A, B 服務的 VPC 都必須存在,才可以建立 VPC Peer Connection,如果你的 A, B 是寫成 terraform module 時,不要把這個程式放到任一個 A, B 模組,要獨立成 C 模組額外套用,像上述用法一樣。 使用這個 VPC Peer Connection 時也要加上 depends_on 等待<br /></p><p><br /></p><p>2. 你的 A, B 服務的 VPC 是否有 CIDR 衝突,或甚至你其他服務有衝突 VPC,當你會開始考慮 VPC Peer Connection 時就表示未來你急有可能其他服務也都要做 Peer Connection ,最好現在就調整所有的 module CIDR。</p><p><br /></p><p>以下提供我的 CIDR 設定:</p><p></p><ul style="text-align: left;"><li>eks 服務的 VPC 使用: 172.16.0.0/16</li><ul><li>(以下都是 cloudposse 給定的)</li><li>public-apse1a: 172.16.96.0/19</li><li>public-apse1b: 172.16.128.0/19</li><li>public-apse1c: 172.16.160.0/19</li><li>private-apse1a: 172.16.0.0/19</li><li>private-apse1b: 172.16.32.0/19</li><li>private-apse1c: 172.16.64.0/19</li></ul><li>rds 的 VPC 使用 10.0.0.0/16</li><ul><li>public subnet: 10.0.1.0/24</li><li>private subnet: 10.0.0.0/24</li></ul><li>mq 的 VPC 使用 10.11.0.0/16</li><ul><li>public subnet: 10.11.1.0/24</li><li>private subnet: 10.11.0.0/24</li></ul></ul><p></p><p>我故意區分 eks 用 172.16 網段,但基本上整個 Class B 已經都被這個服務佔走了,其他服務就要改 Class A 級或 192.168 類型的私人網段,所以我隨便列了兩個服務的網段,特別填了不一樣的 Class A,要注意大多數 Terraform Module 都是複製即用,沒有做好 CIDR 管理就會發生太多的 VPC 都用了相同的 CIDR。</p><p>至少我在 rds, mq 或其他服務上使用了 10.0.0.0/8 級的分配,只要 10.1, 10.2, 10.3.... 這樣分下去,我最多可以有 254 個服務可以配置。</p><p><br /></p><p>3. 你的 Terraform VPC 模組要是一開始就預先填上 route 進去,一定會導致 VPC Peer 模組套用時出現第一次都被刪掉,第二次才成功,第三次套用又被刪掉這種窘境 (Terraform double apply VPC route conflict) ,所以要注意,要抽離那個寫法,以下是範例:</p><pre class="brush: javascript;" name="code"><br class="Apple-interchange-newline" />resource "aws_route_table" "_" {
vpc_id = aws_vpc._.id
tags = {
Name = "${var.namespace}-route-table"
}
// 不可以寫在這裡,這樣每次套用就會有機率衝突規則,導致時好時壞。
//shouldn't write in here, because this will conflict with vpc_peer_connection
/*dynamic "route" {
for_each = local.route
content {
cidr_block = route.value.cidr_block
gateway_id = route.value.gateway_id
instance_id = route.value.instance_id
nat_gateway_id = route.value.nat_gateway_id
}
}*/
}
// 應該多一個這個資源,套用到上面的 aws_route_table._ 這個 resource 來套用子資源,才不會發生衝突
resource "aws_route" "_" {
count = length(local.route)
route_table_id = aws_route_table._.id
destination_cidr_block = local.route[count.index].cidr_block
gateway_id = local.route[count.index].gateway_id
instance_id = local.route[count.index].instance_id
nat_gateway_id = local.route[count.index].nat_gateway_id
}
</pre><p><br /></p><p>補充,這裡連 security_group 的 ingress, engress 都要分開來寫,不可以寫在一起 (指寫在 aws_security_group 裡面),要另外寫在 aws_security_group_rule 裡面去套用,否則每次 apply 都會把 8 竿子打不著的服務重建 security group rule,而且還會打架,影響到 VPC Peer Connection 模組的功能。 記得,這個是指其他模組,不是只有在 VPC Peer Connection 的,建議是分開來寫。</p><p><br /></p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/a/AVvXsEhFUTfJu2OooeIA-Hrs1v-4iNgQLQ7SlLfJF6pimPXak2tE1-abezc9YtzAvY-mX5D2DWH-XqzKvNMJRZAKVtN6s_TdV3PE6INwYFfoEWQBt9l_k2hhPBRstjTxJevbjkD0o-UtVn-qLUJvt6QuUgTXeoezS0-OgRrPn7xHsjtvaZCm9vdpO11OS0LU=s551" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" data-original-height="551" data-original-width="459" height="400" src="https://blogger.googleusercontent.com/img/a/AVvXsEhFUTfJu2OooeIA-Hrs1v-4iNgQLQ7SlLfJF6pimPXak2tE1-abezc9YtzAvY-mX5D2DWH-XqzKvNMJRZAKVtN6s_TdV3PE6INwYFfoEWQBt9l_k2hhPBRstjTxJevbjkD0o-UtVn-qLUJvt6QuUgTXeoezS0-OgRrPn7xHsjtvaZCm9vdpO11OS0LU=w334-h400" width="334" /></a></div><br /><p>你硬要寫在一起,被 VPC PeerConnection 模組引用的套件,每次 apply 功能都一定會這樣...</p><p><br /></p><p>4. 套用完成後,記得去 VPC Peer Connection 服務上檢查上面的 Route Table 全部都出現了 A, B 服務,舉例假設我配 EKS + RDS,我應該要看到有 eks 的 route table,也有 rds 的 route table,而且兩邊的 security group 都有打通互相存取的權限 CIDR。</p><p><br /></p><p>5. eks 要測有沒有連上 rds,要開一個 pods 然後 exec 連進去做測試,可以在裡面安裝個 pg_isready 去測試行不行</p><p><br /></p><p>References:</p><p>https://dev.to/bensooraj/accessing-amazon-rds-from-aws-eks-2pc3<br />https://stackoverflow.com/questions/54596521/terraform-deletes-route-tables-then-adds-them-on-second-run-no-changes-bug-or<br /><br /></p>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-58488770604533775882021-12-15T23:51:00.006+08:002022-01-12T19:07:14.517+08:00應用程式在 Kubernetes 上的傳統基礎設施<p> 本篇紀錄個人 2021 年的 K8S 建置的基礎內容。</p><span><a name='more'></a></span><p><br /></p><h2 style="text-align: left;">基本服務建置</h2><p><br /></p><p>以下內容是一個單位服務的 k8s 基礎設施:</p><p></p><ul style="text-align: left;"><li>configmap 提供服務互相共享設定</li><li>deployment 描述應用程式的 pod 和 deployment</li><li>ingress API 流量進入點</li><li>secrets 小秘密</li><li>service 建立 deployment 服務,可以被自動發現,讓流量流進 Pods,可以是 ClusterIP、LoadBalance、NodePort</li><li>others 其他相依服務: 像是 Redis..., xxx_agent</li><li>namespace 命名空間</li></ul><div><br /></div><div>基礎設施上的關聯性,依照 API 流入進行分層:</div><div><br /></div><div><ul style="text-align: left;"><li>namespace (大家都需要在特定的 namespace)</li><ul><li>ingress (網路流量入口)</li><ul><li>service (流量是透過 service 找到對應的 pod 來分流)</li><ul><li>deployment (應用程式)</li><ul><li>secrets (秘密檔案)</li><li>configmap (一般設定檔案)</li></ul></ul><li>others</li></ul></ul></ul><div><br /></div></div><div>詳情檔案可以參考:</div><div><br /></div><div>https://github.com/hpcslag/infrastructure_boilerplate/tree/main/classical_kubernetes</div><div><br /></div><div><br /></div><div>套用設定直接輸入:</div><div><br /></div><div>kubectl apply -f .</div><div><br /></div><div>就可以把整個目錄的資源套用上去</div><p></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">跨 namespace 存取資源的 FQDN 如何設定,以 Jaeger Agent Endpoint 為例</span></h2><p><br /></p><p>一般就是直接用 service name,k8s 可以自己做服務發現,但如果做成集中式 tracing, logging 日誌服務,則需要透過加一層 DNS Name 來做服務發現。</p><p><br /></p><p>舉例像是都在同一個 namespace 要存取 service, yaml 上的 service name 叫做: jaeger-agent,則同一個 namespace 服務只要輸入 endpoint: udp://jaeger-agent:xxxx 就可以被發現了。</p><p><br /></p><p>但假設為跨 namespace 的服務, endpoint 就會像是: udp://jaeger-agent.<namespace>.svc.cluster.local:xxxx。</p><p><br /></p><p><br /></p><p>References:</p><p>Book - Kubernetes in action</p>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-2432575987052364552021-12-09T12:52:00.001+08:002022-01-12T21:08:26.204+08:00CodeBuild to Elastic Container Repository (ECR), and Let Elastic Kubernetes Service (EKS) Access it.<span><a name='more'></a></span><p>在 Elastic Kubernetes Service 中要使用 Docker Image 還是可以考慮用 Code Pipeline + Code Build 把 Docker image 編譯到 Elastic Container Repository (ECR) 中充當 Docker Hub 使用。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">整體架構</span></h2><p><br /></p><p>先備條件:</p><ol><li>假設你的專案已經有 Code Pipeline (CI/CD, Code Build) 可以自動讀取專案目錄下的 buildspec.yaml</li></ol><p>在這個專案中要實現幾件事情:</p><p></p><ol style="text-align: left;"><li>使用 Terraform 開 ECR</li><li>套用一些權限到 Code Build, EKS 上</li><li>把專案的 Docker Image 自動 Build 到 ECR 上</li><li>讓 EKS 可以存取這個 Image</li></ol><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">專案的 Build Spec</span></h2><div><br /></div><div>由於我的整個專案都在 Code Commit 上,基本上狀況都會簡單一些,程式專案下應該在根目錄要有一個 buildspec.yaml ,然後在 CodeBuild 執行的時候可以讀到這個檔案,自動跑 CI/CD。</div><div><br /></div><div>buildspec.yaml</div><div><br /><script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script>
<pre class="brush: js;" name="code">version: 0.2
phases:
pre_build:
commands:
# push docker to ECR (這裡都是 AWS CodeBuild 上預設的變數,不用改)
- echo Logging in to Amazon ECR...
- aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com
install:
commands:
# for monolith app
- go mod download
build:
commands:
# for monolith app, first build then image can be compile
# - go build (部署單體應用程式才需要用)
# push docker to ECR ($YOUR_REPO_NAME, $IMAGE_TAG 要設定 CodeBuild 的 Tags)
- echo Build docker image...
- docker build . -t $YOUR_REPO_NAME:$IMAGE_TAG -f dockerfiles/你的dockefile.dockerfile
- echo Build docker image completed...
- docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG
post_build:
commands:
- echo Build completed on `date`
# push docker to ECR (有些是預設的,有些要設定到 tag)
- echo Push image...
- docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$YOUR_REPO_NAME:$IMAGE_TAG
- echo Push image completed...
# for monolith app (這是部署單體應用程式才有的)
#artifacts:
# files:
## - xxxxbinary
# - appspec.yml
# - scripts/deploy-clean.sh
# - scripts/deploy-install.sh
# - .env
# name: "go-server-$(date +%Y-%m-%d)"
# discard-paths: yes
# somethime buildspec cache will store the legacy changes
cache:
paths:
- /go/pkg/**/*
<br /></pre></div><p>這個設定檔中有很多變數是 Code Build 中的 Tag,這可能會需要在 Terraform 一開始就給定,現在這個檔案應該會自動 Build 了,但目前還沒有 ECR,要使用 Terraform 建立一個,然後套用相關權限。</p><p>*關於 Code Pipeline, CodeBuild terraform 應該會在另一篇文章提及,本篇即當作已知。</p><p>目錄結構</p><p></p><ul style="text-align: left;"><li>terraform</li><ul><li>modules</li><ul><li>ecr <- 重點</li><ul><li>main.tf</li><li>vars.tf</li></ul><li>pipeline</li><li>eks</li></ul><li>policies</li><ul><li>build_role_policy.tpl</li><li>node_instance_role_policy.tpl</li></ul><li>main.tf</li></ul></ul><div>首先,這是一個 ECR 建立的 Terraform:</div><div><br /></div><div>ecr/main.tf:</div><div><br /><script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script><pre class="brush: js;" name="code">resource "aws_ecr_repository" "_" {
name = "${var.repo_name}"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}</pre></div><p></p><div><div>ecr/vars.tf:</div><div><br /><script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script><pre class="brush: js;" name="code">variable "repo_name" {
type = string
description = "ecr repo name"
}
</pre></div><p></p><div><br /></div></div><div>建立之後,現在要調整已知 pipeline 中,建立一個 iam.tf ,裡面給他套用 build_role_policy.tpl 這個設定:</div><div><div><pre class="brush: js;" name="code">resource "aws_iam_role_policy" "codebuild_role" {
name = "${local.codebuild_role_name}-policy"
role = aws_iam_role.codebuild_role.id # 套用現有的 codebuild_role
policy = templatefile("./policies/build_role_policy.tpl")
}
</pre></div><p></p><div><br /></div></div><div>其中 build_role_policy 檔案的內容是:</div><div><br /></div><div><div><pre class="brush: js;" name="code">{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Resource": [
"${public_bucket}",
"${public_bucket}/*"
],
"Action": [
"s3:*"
]
},
{
"Effect": "Allow",
"Resource": ["*"],
"Action": "cloudfront:CreateInvalidation"
},
{
"Effect": "Allow",
"Resource": ["*"],
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
]
},
{
"Effect": "Allow",
"Resource": [
"arn:aws:s3:::codepipeline-${aws_region}-*",
"${artifact_bucket}/*",
"${artifact_bucket}"
],
"Action": [
"s3:PutObject",
"s3:GetObject",
"s3:GetObjectVersion",
"s3:GetBucketAcl",
"s3:GetBucketLocation"
]
},
{
"Effect": "Allow",
"Action": [
"codebuild:CreateReportGroup",
"codebuild:CreateReport",
"codebuild:UpdateReport",
"codebuild:BatchPutTestCases",
"codebuild:BatchPutCodeCoverages",
"ecr:BatchCheckLayerAvailability", # 這裡以下才是給予 ecr 權限的列表
"ecr:CompleteLayerUpload",
"ecr:GetAuthorizationToken",
"ecr:InitiateLayerUpload",
"ecr:PutImage",
"ecr:UploadLayerPart"
],
"Resource": [
"*"
]
}
]
}
</pre></div><p></p><div>用同一個方法可以對 EKS 做一樣的權限套用,但是要注意,這個 policy 要套用到 EKS 的 worker 或是 node 級的權限 role:</div></div><div><div><div><pre class="brush: js;" name="code">{
"Effect": "Allow",
"Action": [
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:GetAuthorizationToken",
"ecr:*",
"cloudtrail:LookupEvents"
],
"Resource": "*"
}
</pre></div><p></p><div><br /></div></div></div><div><br /></div><p></p><div></div><p>然後,在 k8s 的 deployment 中就可以使用網址直接下載 ECR 的東西:</p><div><pre class="brush: js;" name="code">...
template:
metadata:
name: test
namespace: test
labels:
app: test
spec:
containers:
- image: (你的帳號ID).dkr.ecr.(你的區域ID).amazonaws.com/(你的ECR專案名稱):latest
#imagePullPolicy: Always
...
</pre></div><p><br /></p><p><br /></p><p>References:</p><p>https://docs.aws.amazon.com/codebuild/latest/userguide/sample-ecr.html<br />https://docs.aws.amazon.com/AmazonECR/latest/userguide/ECR_on_EKS.html</p>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-91431880160021324992021-12-06T12:50:00.011+08:002022-01-10T00:18:26.795+08:00前端專案 CI/CD 策略 (Terraform): 使用 AWS CodeCommit CodeBuild, WebPipeline CI/CD.<p> 使用 AWS Code Pipeline 進行前端專案的 CI/CD。<span></span></p><a name='more'></a><br /><p></p><p>純前端專案,如果只是要 Build 成靜態檔案,其實可以直接把專案放在 Code Commit 上當 Git Repo,然後建置 Code Build, Code Deploy 來把專案自動 Build 到 S3,再啟動 CloudFront 來當前端 CDN,如此一來很快就可以讓前端專案串完自動化。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">具體流程</span></h2><p><br /></p><p></p><ol style="text-align: left;"><li>手動建立一個 S3,手動把網站 Policy 調整成靜態網站的樣子</li><li>使用 Terraform 建立 Code Deploy, Code Build, Code Pipeline, CloudFront</li><li>使用 Code Commit 作為 git repo,並開出兩個分支 production, staging, Code Pipeline 觸發條件則是這兩個 branch 發生變化,就會自動進行 CI/CD。</li></ol><div><br /></div><div><br /></div><p></p><p>Terraform 模組是使用本篇文章自己手做的模組包,歡迎參考:</p><p><a href="https://github.com/hpcslag/infrastructure_boilerplate/tree/main/terraform">https://github.com/hpcslag/infrastructure_boilerplate/tree/main/terraform</a></p><p><br /></p><p>我的模組中已經有自動處理 CloudFront 的部分,因此不需要手動進行調整。</p><p><br /></p><p>main.tf:</p><p><br /></p><pre class="brush: javascript;" name="code">locals {
env = var.env
project_name = var.project_name
}
resource "aws_codedeploy_app" "app" {
compute_platform = "Server"
name = var.project_name
}
module "deployment_group" {
source = "./modules/deployment_group"
count = length(var.environments)
deployment_group_name = var.environments[count.index]
app_name = aws_codedeploy_app.app.name
}
##########################################
# S3 網站 Staging, Production 及 Pipeline
#########################################
module "web" {
source = "./modules/web"
count = length(var.environments)
env = var.environments[count.index] # local.env
project_name = local.project_name
domain_name = var.environments[count.index] == "production" ? var.production_domain_name : "${var.environments[count.index]}.${var.production_domain_name}"
}
module "web_pipeline" {
source = "./modules/pipeline"
count = length(var.environments)
name = "web"
env = var.environments[count.index] # local.env
project_name = "${local.project_name}-web-${var.environments[count.index]}"
aws_region = var.aws_region
repository_name = var.codecommit_website # Use custom name
git_branch = var.environments[count.index]
# staging_web is "staging_web" module pointer
cloudfront_distribution = module.web[count.index].cloudfront_distribution
s3_bucket = module.web[count.index].s3_bucket
# each enviroment has their own build command, setup for frontend build task.
deploy_script_env = "deploy:${var.environments[count.index]}"
}</pre><p></p><p><br /></p><p>這裡使用的 S3 Bucket 名稱就直接使用 domain name,也就是 bbb.AAA.com 網域,就有 bbb.AAA.com 的 S3 Bucket,這是設定在 s3_bucket 上的參數。</p><p><br /></p><p>而這裡也會自動 count 所有環境,一般只有 production, staging。</p><p><br /></p><p>vars.tf:</p><p><br /></p><pre class="brush: javascript;" name="code">variable "project_name" {
type = string
}
variable "env" {
type = string
}
variable "aws_region" {
type = string
}
variable "environments" {
type = list(string)
}
variable production_domain_name {
type = string
}
variable "cloudflare_email" {
type = string
}
variable "cloudflare_api_key" {
type = string
}
variable "cloudflare_zone_id" {
type = string
}</pre><p><br /></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p><br /></p><p>vars/common.yaml:</p><p><br /></p><pre class="brush: javascript;" name="code">project_name: # project_name
aws_region: # us-east-2
aws_profile: default # profile is in ~/.aws/config: [profile] xxxx
env: staging
# for s3 websites
environments: ["production", "staging"]
production_domain_name: "bbb.AAA.com"</pre><p><br /></p><p>其中 project_name 是 Code Commit 的 Project Name。</p><p><br /></p><p>要套用設定,要用以下指令:</p><p>terraform init</p><p>terraform plan -var-file=vars/common.yaml</p><p>terraform apply</p><p><br /></p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script>
Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-9525416910282382782021-11-30T23:53:00.001+08:002022-01-10T23:08:03.915+08:00IPFS setup with Nginx: deployment issue 解方<p> 不使用 IPFS 原先的 URL,要使用 nginx 代理的作法。</p><span><a name='more'></a></span><p>為什麼要代理 IPFS? 這是因為 IPFS 基本上 Deployment 都可以對外,誰都可以對你的 IPFS 做 Deployment,所以需要上鎖白名單,只允許內部可以訪問 IPFS,加強安全性。</p><p><br /></p><p>它的 Nginx config 是這樣寫的:</p><pre class="brush: go;" name="code"># create command: graph create --node http://host_name/ipfs/ XXXXX
# for IPFS
location /ipfs {
proxy_pass http://localhost:8020;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
allow all;
}
# deploy command: graph deploy --node http://host_name/ipfs/ --ipfs http://host_name/ipfs-deploy/ XXXXXX
# for IPFS
# http://localhost:5001/api/v0/add <- should use the previous path form /v0 let nginx can allow any argument
location /ipfs-deploy/api/v0 {
proxy_pass http://localhost:5001/api/v0;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
allow all;
}
</pre><p><br /></p><p>這兩段 Router 應該要區分成不同網址,不要都放在 /ipfs 下,一個是給 create 指令使用的,另一個是給 deployment 使用的。</p><p><br /></p><p><br /></p>
<script src="https://cdn.jsdelivr.net/gh/gytisrepecka/brush-go/shBrushGo.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-15546682061959788572021-11-27T23:56:00.131+08:002022-01-11T21:24:31.662+08:00Terraform, Ansible, AWS CodePipeline 的 EC2 單體應用程式佈署策略<span>EC2 單體應用程式佈署策略,使用 AWS Code Pipeline、 Code Build 以及 Terraform、 Ansible 建立 staging、production 伺服器環境,為單體應用程式 Deploy 建立一套 Code Pipeline 流程,從建置到佈署。<a name='more'></a></span><p><br /></p><h2 style="text-align: left;"> 整體架構</h2><p></p><ol style="text-align: left;"><li>使用 Terraform 開啟 EC2, Pipeline 服務 (terraform 開出 s3 artifacts)</li><li>使用 Ansible service role 自動化安裝服務</li><li>安裝 Codedeploy-agent</li><li>寫單體應用程式佈署腳本, scripts, service 檔案</li><li>寫 CodeBuild, Deploy 使用的腳本 appspec.yml, buildspec.yml</li></ol><div><br /></div><div>這一篇是想要利用 CodePipeline 服務達到: 把 Code 推到 staging or production 分支,就自動跑 Build,然後自動 Deploy 上線服務。</div><div><br /></div><div>除此之外,EC2 的啟動、環境安裝都會透過 Terraform, Anaible 進行。</div><div><br /></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">準備 Terraform - 模組</span></h2><div><br /></div><div>這裡使用的模組是本篇文章自己寫的 Terraform Module,模組請參考這個 Repo: </div><div><br /></div><div>https://github.com/hpcslag/infrastructure_boilerplate/tree/main/terraform/modules/server</div><div><br /></div><div><pre class="brush: go;" name="code">locals {
env = var.env
project_name = var.project_name
}
resource "aws_codedeploy_app" "app" {
compute_platform = "Server"
name = var.project_name
}
module "my_deployment_group" {
source = "./modules/deployment_group"
deployment_group_name = "my_deployment_group"
app_name = aws_codedeploy_app.app.name
}
module "my_server" {
source = "./modules/server"
count = length(var.env) # ["staging", "production"]
aws_region = var.aws_region
key_name = aws_key_pair.deployer.key_name
instance_type = length(regexall(".*production.*", var.env[count.index])) > 0 ? "t2.medium" : "t2.small"
volume_size = 20 # 20 GB
namespace = "my_server_${var.official_api_env[count.index]}"
deployment_group_name = "my_deployment_group"
}
</pre><p>要稍微注意,這裡啟動機器是用 list, ["staging", "production"],千萬不要莫名其妙減少一個值,這裡都是按照順序建立 server,很可能會因為增減搞壞,如果有個別處理需求,建議分開寫,不要加到 list 中。</p><p><br /></p><p>服務寫好之後,直接啟動就會得到 CodeBuild 和 Pipeline,但還沒有辦法直接使用,要使用 Ansible 去機器安裝,但在 Ansible 安裝之前,要把機器資訊導出給 Ansible ,也就是要導出 ssh.config 和 hosts 檔案,而且要自動產生,避免太多人工。</p></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">寫一個 output.sh 方便處理</span></h2><div><br /></div><div>在寫一個 output.sh 之前,需要有一個 output.tf 的 output 定義,才能撈出資料: </div><div><br /></div><div>https://github.com/hpcslag/infrastructure_boilerplate/tree/main/terraform/modules/server</div><div><br /></div><div><pre class="brush: go;" name="code">output "my_server" {
value = flatten([
for data in module.my_server : {
public_ip = data.public_ip
namespace = data.namespace
}
])
}
</pre></div><div>這個寫法的意思是 my_server 是一個 counting 的 terraform modules,它就會把每一個建立出來的 EC2 自動填到這個 list 裡面。</div><div><br /></div><div><br /></div><div>因此你就可以建立一個腳本 output.sh 處理它,自動放到 ansible 目錄底下:</div><div><br /></div><div><pre class="brush: go;" name="code">terraform output -json my_server | jq -r '.[] | "Host \(.namespace)
Hostname \(.public_ip)
User ubuntu
IdentityFile ~/.ssh/id_rsa"' >> ssh.config
echo "[my_server]" >> hosts
terraform output -json my_server | jq -r '.[] | "\(.namespace)"' >> hosts
</pre></div><div><br /></div><div>執行之前,可能需要改一下權限,使用 sudo chmod +x output.sh 可以得到執行它的權限。</div><div><br /></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">準備 Ansible 模組</span></h2><div><br /></div><div>在 ansible 目錄下,建立一個 roles 的資料夾,可以放入各種模組,在那之前我們需要先把一個模組拉回來,就是 codedeploy-agent,ec2 機器上一定要安裝這個模組, CodeDeploy 才會動,否則就無法佈署。</div><div><br /></div><div>在這裡使用的是:</div><div><br /></div><div><a href="https://github.com/diodonfrost/ansible-role-amazon-codedeploy">https://github.com/diodonfrost/ansible-role-amazon-codedeploy</a></div><div><br /></div><div>這個 role,安裝方式是直接把 git 目錄拉到 roles 底下,如果是用 ansible-galaxy 安裝,安裝後會拿到一個安裝位置,把那個安裝位置的資料夾直接 cp 或是 mv 過去。</div><div><br /></div><div>我的 ansible roles 目錄就會像這樣:</div><div><br /></div><div><div>roles</div><div>├── andrewrothstein.ipfs</div><div>│ ├── defaults</div><div>│ ├── handlers</div><div>│ ├── meta</div><div>│ ├── tasks</div><div>│ ├── tests</div><div>│ └── vars</div><div>├── andrewrothstein.unarchive-deps</div><div>│ ├── defaults</div><div>│ ├── meta</div><div>│ ├── tasks</div><div>│ └── vars</div><div>├── common</div><div>│ ├── defaults</div><div>│ └── tasks</div><div>├── diodonfrost.amazon_codedeploy</div><div>│ ├── defaults</div><div>│ ├── handlers</div><div>│ ├── meta</div><div>│ ├── molecule</div><div>│ │ ├── default</div><div>│ │ └── windows</div><div>│ ├── tasks</div><div>│ ├── tests</div><div>│ └── vars</div><div>├── fubarhouse.rust</div></div><div>...</div><div><br /></div><div>主要目錄裡面有一個 ansible.cfg:</div><div><br /></div><div><pre class="brush: go;" name="code">[defaults]
inventory = ./hosts
#vault_password_file = vault.key
ansible_managed = Ansible managed, any changes you make here will be overwritten
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=15m -F ssh.config -q
scp_if_ssh = True
</pre></div><div>還有一個 hosts, ssh.config ,這兩個都是剛才 outputs.sh 產生出來的。</div><div><br /></div><div>接著要寫一個 site.yml 當作 ansible playbook:</div><div><br /></div><div><pre class="brush: go;" name="code">---
# This playbook is intended to install all the necessary dependencies of the
# application and set the remote server up for development
- name: Create user accounts for deployment and execution
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- users
- install
roles:
- users
- name: Install CodeDeploy agent
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- codedeploy
- install
roles:
- diodonfrost.amazon_codedeploy
- name: Tune journald settings
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- journal
- install
roles:
- stuvusit.systemd-journald
- name: Install software
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- deps # ansible-playbook -v site.yml --tags deps can make install to target machine
- install
roles:
- common
- name: Prepare Golang Service
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- deps # ansible-playbook -v site.yml --tags deps can make install to target machine
- install
roles:
- role: gantsign.golang
golang_gopath: '$HOME/workspace-go'
golang_version: '1.17'
</pre></div><div><br /></div><div>完成後,就可以直接執行安裝腳本指令:</div><div class="highlight" style="background-clip: border-box; background-color: white; border-radius: 0.25rem; border: 1px solid rgba(0, 0, 0, 0.125); box-sizing: border-box; color: #222222; display: flex; flex-direction: column; font-family: "open sans", -apple-system, BlinkMacSystemFont, "segoe ui", Roboto, "helvetica neue", Arial, sans-serif, "apple color emoji", "segoe ui emoji", "segoe ui symbol"; font-size: 16px; margin: 2rem 0px; min-width: 0px; overflow-wrap: break-word; padding: 0px; position: relative;"><pre style="background-color: #f8f8f8; box-sizing: border-box; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; font-size: 14px; margin-bottom: 0px; margin-top: 0px; overflow-wrap: normal; overflow: auto; padding: 1rem; tab-size: 4;" tabindex="0"><code class="language-shell" data-lang="shell" style="background-color: inherit; border: 0px; box-sizing: border-box; color: inherit; font-family: SFMono-Regular, Menlo, Monaco, Consolas, "liberation mono", "courier new", monospace; margin: 0px; overflow-wrap: break-word; padding: 0px; word-break: normal;">ansible-playbook -v site.yml --tags install</code></pre></div><p>就可以完成安裝。</p><p><br /></p><p>以上所使用的 roles 腳本,都可以在:</p><p>https://github.com/hpcslag/infrastructure_boilerplate/tree/main/ansible</p><p>這個專案中看到範例。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">準備 Service 模板和 Nginx 設定等</span></h2><div><br /></div><div>上一節執行後,機器已經安裝好基本應用程式,但還需要安裝 Infrastructure 的部分,總共有幾個部分需要設定,Nginx 和自訂應用程式的 Service 檔案,以下 yml 是完整的設定:</div><div><br /></div><div><div><br /></div><div><pre class="brush: go;" name="code">- name: Setup Nginx
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- install
- Nginx
roles:
- role: geerlingguy.nginx
nginx_service_state: started
nginx_service_enabled: true
nginx_vhosts:
- listen: "80 default_server"
filename: "my_project.vhost.conf"
server_name: "YOUR_DOMAIN.com"
state: present
extra_parameters: |
location / {
proxy_pass http://localhost:8080;
}
- name: Setup My Application
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- services
- install
roles:
- role: services # 使用自訂服務,在 roles/services 下
vars:
app_type: MyApplication
# this requires aws configure to same region
- name: Localhost trigger code pipeline
hosts: all
tags:
- install
- pipeline
tasks:
- name: Trigger AWS Build
local_action: raw aws codepipeline start-pipeline-execution --name my-app-build-pipeline
</pre></div><div><br /></div></div><div>上面的腳本都還無法執行,在這裡缺少了第二段 Setup My Application 的自訂服務建立,這個服務會自動幫我們安裝好應用程式的 Systemctl 模板。</div><div><br /></div><div>此段請參考這個專案的幾個目錄: </div><div><br /></div><div>https://github.com/hpcslag/infrastructure_boilerplate/tree/main/ansible</div><div><br /></div><div><ul><li>templates</li><ul><li>my-application.j2</li></ul><li>services</li></ul></div><div>基本上 services/task/service-my-application.yml 這個檔案就定義要使用 templates 資料夾中的 my-application.j2 當作 systemctl 模板,它將會把這個檔案複製過去。</div><div><br /></div><div>裡面 j2 有許多模板變數可以被替換,只要根據需求更改 service-my-application.yml 就可以了。</div><div><br /></div><div>第三段 yml 執行是 aws codepipeline,這是為了觸發將你的應用程式開始進行編譯,預期編譯完可以把程式放到你的機器上,這些在 terraform 中早已經有定義。</div><div><br /></div><div><div><pre class="brush: go;" name="code">- name: Setup Nginx
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- install
- Nginx
roles:
- role: geerlingguy.nginx
nginx_service_state: started
nginx_service_enabled: true
nginx_vhosts:
- listen: "80 default_server"
filename: "my_project.vhost.conf"
server_name: "YOUR_DOMAIN.com"
state: present
extra_parameters: |
location / {
proxy_pass http://localhost:8080;
}
- name: Setup My Application
hosts: all
remote_user: "{{ user_name }}"
become: true
tags:
- services
- install
roles:
- role: services # 使用自訂服務,在 roles/services 下
vars:
app_type: MyApplication
# this requires aws configure to same region
- name: Localhost trigger code pipeline
hosts: all
tags:
- install
- pipeline
tasks:
- name: Trigger AWS Build
local_action: raw aws codepipeline start-pipeline-execution --name my-app-build-pipeline
</pre></div><div><br /></div></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">撰寫 CodeBuild 的 buildspec.yml</span></h2><div><br /></div><div>預設 CodeBuild 就會吃你 Repo 專案底下的 buildspec.yml 檔案進行處理,這裡的範例是:</div><div><br /></div><div><div><div><pre class="brush: go;" name="code">version: 0.2
phases:
install:
commands:
- go mod download
build:
commands:
- go build
artifacts:
files:
- [YOUR APPLICATION BINARY FILE NAME]
- appspec.yml
- scripts/deploy-clean.sh
- scripts/deploy-install.sh
- .env
name: "go-server-$(date +%Y-%m-%d)"
discard-paths: yes
cache:
paths:
- /go/pkg/**/*
</pre></div><div>可以注意到上方 artifacts 是把某些檔案帶到下一個 Pipeline: CodeDeploy, scripts 目錄下的內容在這個資料夾中,需要把它放進專案 </div></div></div><div><br /></div><div>https://github.com/hpcslag/infrastructure_boilerplate/tree/main/scripts</div><div><br /></div><div>它會依照 systemctl 的名稱進行應用程式重啟跟安裝,而且會保留 5 個版本。 這個腳本基本上就代表著更新應用程式。</div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">撰寫 CodeDeploy 腳本 appspec.yml</span></h2><div><br /></div><div>當前面 buildspec.yml 完成建置之後,就會把剛才那些 artifacts 檔案帶到 appspec.yml,這裡就可以直接執行剛才帶來的檔案,完成最後佈署 + 更新。</div><div><br /></div><div>範例指令檔案如下:</div><div><div><br /></div><div><pre class="brush: go;" name="code">version: 0.0
os: linux
runas: deploy
files:
- source: /
destination: /tmp/go-deploy
hooks:
BeforeInstall:
- location: deploy-clean.sh
AfterInstall:
- location: deploy-install.sh
</pre></div></div><div><br /></div><div>如此,這樣就算完成整個單體應用程式佈署流程了。</div><div><br /></div><div><br /></div><h2 style="text-align: left;"><span style="font-size: x-large;">偵錯 CodeDeploy, CodeBuild</span></h2><div><br /></div><div>以下是一些我碰過的 CodeDeploy 錯誤問題跟可能找出問題的方式:</div><div><br /></div><div><ol style="text-align: left;"><li> CodeDeploy 連 step 1 都進不去就掛掉,而且錯誤訊息是空的</li><ol><li>這表示你可能沒有在你的機器上安裝 CodeDeploy Agent</li><li>查看 journalctl -xefu codedeploy-agent 的 log</li><li>查看佈署錯誤紀錄: tail /var/log/aws/codedeploy-agent/codedeploy-agent.log</li></ol><li>執行到下載階段出錯</li><ol><li>你沿用之前的 build,而且過很久 artifacts 都消失了,需要重新 build</li><li>可能沒有 IAM 權限,試著在 buildspec, appspec 中打印 aws sts get-caller-identity</li></ol><li>檢查權限的方式</li><ol><li>aws sts get-caller-identity</li><li>curl http://169.254.169.254/latest/meta-data/iam/security-credentials/<相關 Role Name> 這段可以檢查目前的 Role 有沒有你指定的權限</li></ol></ol></div><div><br /></div><p></p><p>References:</p><p>https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html<br />https://github.com/hashicorp/packer/issues/7142</p>
<script src="https://cdn.jsdelivr.net/gh/gytisrepecka/brush-go/shBrushGo.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-46843189997237075002021-10-28T21:37:00.005+08:002021-12-08T22:56:50.047+08:00ETL: Some hint for Full Table Data Migration between legacy and modern<p> 前一些時間,我正在進行資料系統遷移到新系統的作業,並對此簡單做一些方法的紀錄。<span><br /></span></p><span><a name='more'></a></span><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">任務執行規劃</span></h3><p><br /></p><p>如果需要做到舊系統轉新系統,一開始一定要評估舊系統停機損失,假設如果對方只能承受 3 個小時的損失,那麼就必須盡早開始在工程面思考是否有可能將 Migration 在 1.5 小時內完成 (一半的時間),因為一但開始做遷移,舊系統理論上不應該再有新的一筆資料出現。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">工欲善其事,必先利其器</span></h3><p><br /></p><p>在這項任務中使用了多項工具來達成這個需求:</p><p></p><ul style="text-align: left;"><li>MySQL Workbench (開發 SQL 執行流程、主要工作 SQL IDE,最後被玩到 UI 爛掉)</li><li>DBeaver (研究資料欄位、研究整個系統邏輯)</li><li>SSMS (用於匯出 mdf, log 資料的 Database 到 xls 再轉出到 csv)</li><li>Table Plus (用於整批匯入 csv 進 Table 中,速度絕佳快)</li></ul><p></p><p><br /></p><p>電腦開發端所安裝的 MySQL 一定要和資料庫版本相同,特別是 Mysqldump 工具一定要與資料庫版本相同, 5.7, 8 互相用 mysqldump 是會出問題的!</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">整理 Legacy Data 的方式</span></h3><p><br /></p><p>兩個系統本身並不互通,你可能會需要匯出整批資料出來,並且整理這些資料,你可以把資料按照 CSV 格式倒入新系統的資料庫的某個欄位:</p><p><br /></p><p>LegacyBookstoreBooks</p><p>LegacyBookstoreMemberships</p><p>...</p><p><br /></p><p>諸如此類的表,裡面先開好欄位定義,都是粗略開啟即可。</p><p><br /></p><p><br /></p><h3><span style="font-size: x-large;">清洗資料、日期、格式</span></h3><p><br /></p><p>當你準備匯入時,你會開始發現你的資料甚至出了很大的問題,有些欄位是 '\N',有些欄位是 '',有些欄位是 NULL,有些欄位多了某個詭異的字元,又或是日期格式錯誤,甚至是整個 CSV 就出了狀況。</p><p><br /></p><p>理想情況是少數資料就人工修復,如果有一點多,你可能需要寫正規表達式來解決它。</p><p><br /></p><p>於是,你可能會用盡你所想的: 用 Node.js 寫腳本,處理這些 CSV,替換錯誤資料、非法字元,你可能會針對很多個案去研究到底這個 CSV 是不是正確的。</p><p><br /></p><p>使用 RegExp 可以用 $1, $2 針對搜尋結果做 regex replace string,是一個還蠻不錯的解決方案。</p><p><br /></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">增加額外必要欄位用來對應</span></h3><p><br /></p><p>有的時候,假設書店在全台有很多店面,每個店面都是使用獨立資料匯入,因此你甚至不會知道這個 csv 是屬於哪家書店,此時你應該在匯入時幫這些資料在 SQL 欄位中,加上 StoreLocation: TPE, KHH, RMTP....etc 各種店名。</p><p><br /></p><p>除了這種情況外,你可能也要對那些新系統的表格標記一些 isLegacy 字樣的欄位,甚至是 legacyDataCategory,這些都有助於讓你在匯入這些資料後,特別標記資料以便處理。</p><p><br /></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">匯入方式</span></h3><p><br /></p><p>匯入 CSV 有幾種方案,一種是透過 Node.js 讀取 csv 一行一行匯入,轉型,這麼做的話,也許你第一個碰到的就是資料庫編碼問題,你可能要試圖更改 Table Collection 或 DB Collection,像是原本是 utf8_general_ci, utf8mb4_general_ci 兩種的替換,尤其是使用 utf8mb4_general_ci 也可能遇到 Table 中 Column 欄位資料大小上限的問題。</p><p><br /></p><p>再來,你也許會使用 Batch 匯入,這必須考慮到每次匯入的 Batch Size 多少合適,因為一筆一筆匯入,就等於一筆一筆 INSERT,這會讓速度變很慢,所以都會一次匯入很多,此時就必須研究一次匯入多少合適,以及最終也要考慮一批 Transaction 可以接收的大小,你可能也必須分批做 Transaction 匯入。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">了解邏輯,盡早發現資料錯誤</span></h3><p><br /></p><p>匯入這些資料後,就開始研究 Column Name 與其他相關聯欄位的 Colum Name 是什麼,你必須從【舊的系統】開始了解,你可以從【匯入的邊界需求】開始定義塑形,你一定要很清楚你需要匯出哪一些資料,作為整個遷移任務的基礎,否則你將會滿無目的的處理資料。</p><p><br /></p><p>了解完需求再開始尋找舊系統欄位中的關聯性 (Relations) 可能會是面對龐大舊型系統的一個出發點。</p><p><br /></p><p>當你了解這個邏輯之後,你需要嘗試做出幾個測試的 SELECT 和 JOIN SQL 腳本來證明這些邏輯是對的,例如分期刷卡清單、會員期間資料,舊需要好好的與主管或客戶進行研判。</p><p><br /></p><p>總之,越早發現錯誤是越好的!</p><p><br /></p><h3 style="text-align: left;"><br /></h3><h3 style="text-align: left;"><span style="font-size: x-large;">建立 INDEX</span></h3><p><br /></p><p>匯入完資料後,你可能嘗試幾種 SELECT 都會出狀況,你會發現你瘋狂引發 Full-Table Scan,使用字串來搜尋的話,通常都是使用 Full-Text Searching 去做,尤其是 LIKE 語法。</p><p>最經典的例子就是我需要對應發票號碼所使用的付款紀錄,此時兩張 Table 就完全只有 String - String 的對應關係,那會使得整個 JOIN 變得有夠夭壽慢。</p><p>解方就是對這些舊資料加上必要的 INDEX,如果我只有發票要對應,我則只要分別在這兩張表的 ReceiptNo 加上 Index:</p><p><br /></p><pre class="brush: sql;" name="code">CREATE INDEX idx_receipts_recepitno ON Receipts (RecepitNo) USING BTREE KEY_BLOCK_SIZE = 65535;
CREATE INDEX idx_payments_receiptno ON Payments (ReceiptNo) USING BTREE KEY_BLOCK_SIZE = 65535;
</pre><p><br /></p><p>Key block size 在這裡可能不能開太小,這些號碼對應通常都是很大量的,尤其如果發票是特別的欄位:</p><p>RecepitPrefix, ReceiptNo,像是: "RX", "1020120120" 這種就會更麻煩,因為你每一次查詢時可能會寫成:</p><p><br /></p><pre class="brush: sql;" name="code">SELECT payment.id FROM Payments payment
LEFT JOIN Receipts receipt ON payment.ReceiptNo = CONCAT(receipt.ReceiptPrefix, receipt.ReceiptNo);
</pre><p><br /></p><p></p><p>你要是不設定 Index 為:</p><p><br /></p><pre class="brush: sql;" name="code">CREATE INDEX idx_receipts_recepitno_receiptprefix ON Receipts (ReceiptPrefix, RecepitNo) USING BTREE KEY_BLOCK_SIZE = 65535;
</pre><p><br /></p><p></p><p>你可能真的會直接跑不出結果,那樣的 JOIN 量會蠻大的。</p><p><br /></p><pre class="brush: sql;" name="code">CREATE INDEX idx_ordeNo_location ON Orders (OrderNo, location) USING BTREE KEY_BLOCK_SIZE = 2048; -- better up to 65535
CREATE INDEX idx_ordeNo_location ON Payments (OrderNo, location) USING BTREE KEY_BLOCK_SIZE = 2048; -- better up to 65535
</pre>
<div><br /></div><div>我自己跑過使用 65535 的 b-tree key block size 就會很正常,使用 2048, 4096 都太小了。</div><div><br /></div><br /><p></p><h3 style="text-align: left;"><span style="font-size: x-large;">交集映射優化</span></h3><p><br /></p><p>要處理各種 JOIN 出錯的著手點,一定就是要看 Explain Tool, MySQL Workbench 可以直接快速查詢,還有圖形化介面:</p><p><br /></p><div style="text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhgmV37Bio-LDbyPj5Lh19sxiRRVy2THzjBVAEQczdSI_VbnR_LucQ1BV5Konlu0LYwVCe9p572HppQ3i4M1VcuVstH3LVNIu8tRGIOTvgdKDzzBwgl2IPT9sV6h1z2Y9LCsDxkstFhd7U/s469/2.png"><img border="0" data-original-height="74" data-original-width="469" height="100" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhgmV37Bio-LDbyPj5Lh19sxiRRVy2THzjBVAEQczdSI_VbnR_LucQ1BV5Konlu0LYwVCe9p572HppQ3i4M1VcuVstH3LVNIu8tRGIOTvgdKDzzBwgl2IPT9sV6h1z2Y9LCsDxkstFhd7U/w640-h100/2.png" width="640" /></a></div><br /><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgFdlBIPQYDqjubkoV3fMYcOF9BC8L22TQ0Ex1j27xWsADqNYSMlQWok53BM2nbIpZnKGYjA72wdcnBQQJYjwsQu7hZ0jMB9qh25miruJUq_DxYTug4mpAAt2kWl77rhw2EmDjxQlAyieQ/s747/1.png" style="margin-left: 1em; margin-right: 1em;"><img border="0" data-original-height="124" data-original-width="747" height="106" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgFdlBIPQYDqjubkoV3fMYcOF9BC8L22TQ0Ex1j27xWsADqNYSMlQWok53BM2nbIpZnKGYjA72wdcnBQQJYjwsQu7hZ0jMB9qh25miruJUq_DxYTug4mpAAt2kWl77rhw2EmDjxQlAyieQ/w640-h106/1.png" width="640" /></a></div><br /><p>沒有 Tool 也沒關係,只要在任何 SQL 語法前面加上 EXPLAIN 就可以看這段查詢的效能、使用預設索引。</p><p><br /></p><p>從交集取映射時,我幾乎沒有用過 JOIN ON ... OR 這個 OR 的語法,使用 OR 可能隨時都會引發全表掃描 (Full-table scan),詳情觸發機制其實有蠻多人寫過的,可以上網找找。</p><p><br /></p><p>基本上 JOIN 篩選率到 100,基本上就是需要調整查詢效能了,否則未來會很痛苦。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">建立映射表優化 Mapping Table (Many-To-Many Table)</span></h3><p><br /></p><p>假設不慎,你處理的資料不管怎麼優化都需要跑很久,甚至要用到 Batch (透過 Offset, Limit 來一批一批找),此時可以借助 Many-to-many table 的概念,預先建立一張 Mapping Table,來建立你想要得到的資訊表,比方說我想要拿這張表去對應發票付款紀錄:</p><p><br /></p><p><br /></p><pre class="brush: sql;" name="code">CREATE TABLE ReceiptsToPayments (
ReceiptId INT NOT NULL PRIMARY KEY
PaymentId INT NOT NULL
);</pre><p></p><p></p><p></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"><br /></p><p>也許可以再加一個 Index,不過此處用途是只篩選出一些 Receipts 與 Payments 找出來的表:</p><p><br /></p><pre class="brush: sql;" name="code">INSERT INTO ReceiptsToPayments (ReceiptId, PaymentId)
SELECT r.id, p.id FROM Receipts r
INNER JOIN Payments p ON p.ReceiptNo = r.ReceiptNo;</pre><p style="-webkit-text-stroke-width: 0px;">或是</p><p style="-webkit-text-stroke-width: 0px;"><br /></p><pre class="brush: sql;" name="code">INSERT INTO ReceiptsToPayments (ReceiptId, PaymentId)
SELECT r.id, p.id FROM Receipts r, Payments p
WHERE r.ReceiptNo = p.ReceiptNo;</pre><p style="-webkit-text-stroke-width: 0px;"><br /></p><p>假設真的要跑很久,就這麼做吧,當你的 Mapping 表做出來之後,之後很多的對應就會非常方便,不過在這小節一直沒有提到,Mapping 表是根據需求而做的,假設你很頻繁需要用到這種方式從 Receipts 找到 Payments,你就必須要建立,因為它可以省下你很多時間。</p><p><br /></p><p>那如果你無法 JOIN 出來怎麼辦?</p><p>請見下一節尾。</p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">不 JOIN 情況下挑選擇集合元素</span></h3><p><br /></p><p>很多時候你可能只是想篩選 A 資料,只是需要借助 B 資料來處理,我打造一個情境,我只要沒有經過折讓或作廢的發票的付款紀錄,我的本體是紀錄,不是要發票,有的時候會寫成 INNER JOIN 的形式,也許這不一定有好處,這也可能會引發索引以及 JOIN 效能問題。</p><p><br /></p><pre class="brush: sql;" name="code">SELECT p.id, p.BookstoreLocation FROM Receipts r
LEFT JOIN Payments p ON p.ReceiptNo = r.ReceiptNo AND r.BookstoreLocation = p.BookstoreLocation
WHERE r.isVoid = FALSE AND r.isAllowance = FALSE; -- 沒有折讓以及作廢</pre><p><br /></p><p>此時,你也可以採取不同的方案進行: 不要 JOIN 的挑選元素,而是直接挑元素。</p><p><br /></p><pre class="brush: sql;" name="code">SELECT p.id, p.BookstoreLocation FROM Payments p
WHERE (p.ReceiptNo, p.BookstoreLocation) IN (SELECT r.ReceiptNo, r.BookstoreLocation FROM Receipts r WHERE r.isVoid = FALSE AND r.isAllowance = FALSE); </pre><p><br /></p><p>使用多個 WHERE IN,位置對齊後直接查出付款紀錄,效果更快。</p><p><br /></p><p>那麼回到上一節說的,想要做 Many-to-many mapping table 可是透過 JOIN 就是無法跑出結果,怎麼辦?</p><p><br /></p><p>下面這個例子,就是使用這一節的方法,你可以這麼看 A, B 範例,A 要加入 B 的多項資訊整合的表,但是無法用 JOIN 把資料帶出來,所以做了部分 INSERT,再 UPDATE,首先這是表的格式:</p><p><br /></p><pre class="brush: sql;" name="code">CREATE TABLE A_B_WAIT_TO_JOIN_TO_C(
A_ID
A_NAME
B_ID
B_RECEIPT_NO
)</pre><p style="-webkit-text-stroke-width: 0px;"><br /></p><p style="-webkit-text-stroke-width: 0px;">因為 join 不出資料,我們可以使用挑選法,先挑出 A 的資料。</p><p style="-webkit-text-stroke-width: 0px;"><br /></p><pre class="brush: sql;" name="code">INSERT INTO A_B_WAIT_TO_JOIN_TO_C
SELECT ID, NAME FROM A a WHERE (a.a1, a.a2) IN (SELECT b.a1, b.a2 FROM B b)</pre><p><br /></p><p style="-webkit-text-stroke-width: 0px;">此時 INTO A_B_WAIT_TO_JOIN_TO_C 這張表應該就只有 A 的資料,那麼你再透過 Update 的方式帶進去,就完成了:</p><p style="-webkit-text-stroke-width: 0px;"><br /></p><pre class="brush: sql;" name="code">UPDATE A_B_WAIT_TO_JOIN_TO_C abc
INNER JOIN B b ON abc.A_ID = b.ID
SET abc.B_ID = b.ID, abc.B_RECEIPT_NO = b.RecepitNo;</pre><p><br /></p><p style="-webkit-text-stroke-width: 0px;">這樣下來,就用 INSERT 加上 UPDATE 補完整張 Many-To-Many 的 Mapping 表。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">JOIN 原則問題</span></h3><p><br /></p><p>LEFT JOIN 如果在隔壁資料沒有的狀況下,也會帶出一筆空的資料 NULL 一整排,有些功能可能就是想知道隔壁是不是空的,讓我們撇除掉這種必要的狀況,在一般情況下是否需要使用 LEFT JOIN,對我來說原則已經下好,就應該是使用 INNER JOIN,<span style="color: red;">只有很多 JOIN 的情況下,你要去 Debug 尋找哪一個 INNER JOIN 開始找不到人時,可以用 LEFT JOIN 去除錯</span>。</p><p><br /></p><p>使用 INNER JOIN 對系統的好處是保證有資料,沒有資料就不會進去,而工程師應該做的是確保 INNER JOIN 不到的東西錯誤的原因,要一一排除。</p><p><br /></p><p>也許使用 LEFT JOIN 手動檢查完之後,改成 INNER JOIN 可能會好一些。</p><p><br /></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">要不要使用 Temporary Table ,還是用一般 Table</span></h3><p><br /></p><p>有的時候在做這系列 Migrations 時會希望有一些欄位紀錄,像是上面提到的 Mapping Table,是否建立時就直接採用 Temporary Table 就好?</p><p>其實是不建議的,因為 Temporary 無法重複 Join Table,而且通常做 Migrations 時很容易 Deadlock 或整個資料庫重開機,那麼你的整個 Temp Table 就會隨之消失,除非你要使用 Procedure 並用於紀錄簡單資料 Array,那或許是可以直接當一種 Array 變數使用。</p><p>總之,使用一般 Table 建立出來才是上策。</p><p><br /></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">區分環境的必要性</span></h3><p><br /></p><p>當整理完目標需要匯入的資料表 A, B, C 之後,我當初正想著先開一個 A_ready, B_ready, C_ready,這三個資料表先做預匯入,最後再一次倒入,但其實這是錯誤的。</p><p>在一個強關聯性的 RDBMS,所有的 Foreign Key 都必須在 Insert 當下建完,雖然改 Auto_Increment 也可以做到,但我完全建議直接照著原本資料表匯入就好了。</p><p>真實環境就是最好的演練,不要刻意區分前後環境,要善用 Transaction, Savepoint 這兩個好工具,事務處理會幫助你的。</p><p>在匯入時使用的事務隔離級別直接使用 Serializable 來處理,直接 ROLLBACK, COMMIT 就可以做。</p><p><br /></p><pre class="brush: sql;" name="code">START TRANSACTION; BEGIN;
UPDATE A_B_WAIT_TO_JOIN_TO_C abc
INNER JOIN B b ON abc.A_ID = b.ID
SET abc.B_ID = b.ID, abc.B_RECEIPT_NO = b.RecepitNo;
SAVEPOINT update_all_list;
DELETE FROM A_B_WAIT_TO_JOIN_TO_C;
ROLLBACK update_all_list; -- 直接退回到剛 UPDATE 的狀態
-- COMMIT; 這段可以直接 COMMIT 資料
</pre><p><br /></p><p>如果你是要執行一大堆腳本,最好先調整整個 GLOBAL 的交易方式,你可以先查詢 GLOBAL 的事務處理級別是什麼: </p><p><br /></p><pre class="brush: sql;" name="code">SELECT @@GLOBAL.transaction_isolation, @@GLOBAL.transaction_read_only;
SELECT @@SESSION.transaction_isolation, @@SESSION.transaction_read_only;
</pre><p>通常不會是 Serializable,要手動改它,也可以順便將以下設定一併調整:</p><p><br /></p><pre class="brush: sql;" name="code">set sql_safe_updates=0; -- 可以做全表更新
set @@autocommit = false; -- 不要自動 COMMIT,改手動 COMMIT
SET GLOBAL TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- 把全域事務處理級別改 SERIALIZABLE
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- 把連線者的事務處理級別改 SERIALIZABLE<br />
</pre><p><br /></p><p>使用 Transaction 的好處就是你可以操作 CRUD,操作完可以一直在連線狀態做 SELECT 查詢看資料對不對,但缺點就是只能在連線端看,當你打開應用程式連上這個資料庫,是不會看到這些資料的,只能肉眼檢驗正確性,好處是不會污染資料庫,你可以隨時 ROLLBACK 回去,甚至斷線資料也不會 COMMIT 上去。</p><p><br /></p><p>不過仍要注意 Transaction 不支援某些語法,只要你一輸入就會被立刻 COMMIT 污染資料庫:</p><p>DROP, CREATE ... (CREATE TABLE, CREATE PROCEDURE, CREATE TEMORARY...etc), ALTER ...</p><p>這系列語法稱作 DDL,MySQL 是不支援 DDL 的交易處理的。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">使用 Procedure, Function</span></h3><p><br /></p><p>當你的手續一多的時候,將重複的任務包成 Procedure, Function 有助於加速開發,但還是需要注意不要過度使用 Procedure, Function,兩者差異在於 Function 會有回傳值,Procedure 不會有,較適合用來處理一系列的任務。</p><p><br /></p><p>特別要注意,在 Procedure, Function 內部使用 DDL (Create...) 一樣會造成交易 Transaction 直接被 Commit,而且 Create Procedure, Create Function 時也要在最前面先做,不要在做了一堆 INSERT, UPDATE 之後才開始 Create Procedure,那樣會直接讓前面資料污染,被 Commit。</p><p><br /></p><p>以下示範 Procedure 執行迴圈讀取:</p><p><br /></p><pre class="brush: sql;" name="code">DROP PROCEDURE IF EXISTS DO_PROCEDURE;
DELIMITER //
CREATE PROCEDURE DO_PROCEDURE()
BEGIN
-- 迴圈參數
DECLARE j INT DEFAULT 0;
DECLARE total_count INT DEFAULT 0;
-- Cursor 需要的變數事先宣告
DECLARE id INT;
DECLARE orderId INT;
DECLARE orderNo VARCHAR(255);
-- 宣告一個 Cursor
DECLARE myCursor CURSOR FOR
SELECT
a.id,
a.orderNo,
b.orderId
FROM A a
LEFT JOIN B b ON b.OrderId = a.id
ORDER BY a.createdAt ASC;
-- 從這邊之後就不可以再宣告變數了
OPEN ctCursor;
-- 事先知道整個迴圈的大小,幾乎用跟上面 cursor 一樣的語法
SELECT COUNT(*) AS total_count
FROM A a
LEFT JOIN B b ON b.OrderId = a.id
INTO total_count; -- SELECT 完之後 INTO total_count 這個變數。
-- 秀出來看看
SELECT total_count;
-- 使用倒數的方式來做迴圈
SOMELOOP: LOOP-- 這裡可以為 Loop 命名
-- 如果減到 0 就跳出迴圈
IF total_count = 0 THEN
LEAVE SOMELOOP;
END IF;
SET total_count = total_count - 1;
-- 每次迭代一次 Cursor 下一筆 row,丟到預先 Declare 的變數上。
FETCH myCursor INTO id, orderNo, orderId;
IF id <> IS NULL THEN
SET j = 1;
SET temp_order_id = orderId;
END IF;
INSERT INTO C (A, B, C)
VALUE (orderId, j, orderNo);
-- 內部計數變數,這是正序新增的
SET j = j + 1;
END LOOP SOMELOOP;
-- 最後關閉 cursor
CLOSE myCursor;
END
//
DELIMITER ;
</pre><p><br /></p><p>要使用時,直接寫 CALL DO_PROCEDURE() 就可以執行了。</p><p><br /></p><p>這裡特別注意 //, DELIMITER, 的意思是要分隔每一行,在某些 SQL Editor 要寫 Procedure 要加上這串,如果你的 IDE 不會出錯,則可以自己拿掉。</p><p><br /></p><p>而 Function 的建立也不會很複雜,範例如下:</p><p><br /></p><pre class="brush: sql;" name="code">DROP FUNCTION IF EXISTS `CHECK_ORDER_EXISTS`;
CREATE FUNCTION `CHECK_ORDER_EXISTS`(given_orderNo VARCHAR(255))<br />RETURNS BOOL NOT DETERMINISTIC NO SQL SQL SECURITY DEFINER
BEGIN
DECLARE _isExists BOOLEAN DEFAULT FALSE;
SELECT o.id IS NOT NULL INTO _isExists FROM Orders o WHERE o.orderNo = given_orderNo;<br />
RETURN _isExists;
END;<br />
</pre><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">二級索引建立方法與 Procedure 二級索引建立法</span></h3><p><br /></p><p>想像以下的情境,你的關聯性資料庫中有兩張表,付款紀錄 Payments,付款項目 PaymentItems,付款項目裡面會有商品子 id 順序,也有原來的 Payments 紀錄,於是你將在匯入資料時碰到如何建立二級索引的問題。</p><p><br /></p><p>你有三個辦法可以解決:</p><p><br /></p><p>1. 使用查詢內變數建構 (適合用在 MySQL 5.7 的做法)</p><p><br /></p><pre class="brush: sql;" name="code">SELECT IF(@last_payment_id=p.id, @row_idx := @row_idx + 1, @row_idx:=0) AS `index`, /* 這個設定一定要在最後面 */ @last_payment_id:=p.id AS PaymentId, '假商品名稱', tpi.price ...
FROM Payments p
INNER JOIN TestPaymentItem tpi ON tpi.PaymentId = p.id, -- 逗號就會分隔查詢,接下來宣告內變數
(SELECT @row_idx := 0) AS row_idx, (SELECT @last_payment_id := 0) AS last_payment_id
ORDER BY p.PaymentId DESC; /*一定要 order by*/
</pre><p><br /></p><p>這個做法的詳情可以參考 [1]。</p><p><br /></p><p>2. 使用查詢內變數建構 (適合用在 MySQL 8 的做法)</p><p>直接使用 ROW_NUMBER() OVER PARTITION BY 的語法,詳情可以參考 [1]</p><p><br /></p><p>3. 使用 Procedure 迭代變數</p><p>參考上面的 使用 Procedure, Function 章節,我在變數中有寫到 SET j,你可以使用這個代替索引。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">注意接收 VARCHAR 的編碼問題</span></h3><p><br /></p><p>無論是執行 SQL 匯入,或是用 Node.js 把 CSV 一行一行匯入,你可能都會碰到編碼問題,這個時候你可能需要去變更資料庫的編碼格式:</p><p><br /></p><pre class="brush: sql;" name="code">ALTER SCHEMA your_db_name DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;
</pre><p><br /></p><p>注意在 Procedure 的接收參數上,你可以這麼改接收參數設定編碼:</p><p><br /></p><pre class="brush: sql;" name="code">CREATE PROCEDURE `DO_SOMETHING`(IN Region CHAR(10) CHARSET utf8mb4)
</pre><p><br /></p><p>從這邊可以嘗試去接收 utf8mb4 的字元。</p><p><br /></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">建立還原資料手續的 Shell Scripts</span></h3><p><br /></p><p>如果這是一連串很複雜的工作,我會建議要建立一個 Shell Scripts 代替你處理各種工作,能自動化的就全部自動化,而且要時常跑自動化看看有沒有問題。</p><p><br /></p><p>假設你要讓機器重開機在繼續做下去,你可以讓 Shell Scripts 做這幾行:</p><p>sshpass -p rootroot ssh root@xxx.xxx.xxx.xxx reboot &> /dev/null</p><p>sleep 15</p><p>這幾行可以讓機器等待 15 秒後再繼續進行。</p><p><br /></p><p>你可以新增一個 bash 檔案,填入:</p><p>#!/bin/sh</p><p>set -e</p><p>read -p "準備好了嗎?: (Y/n)" any_variable_here</p><p>... 填入各種指令手續</p><p><br /></p><p>基本上 read 就是在等待使用者的回應,你可以利用 read 來卡住每一個需要人工步驟的行為。</p><p><br /></p><p>另一個 tip 是,你要在這一系列行為中,最好可以多次備份資料庫,安插這樣的指令下去:</p><p>mysqldump --routines -h xxx.xxx.xxx.xxx -u admin -p預先填入的密碼 your_db_name > ./xxx.sql</p><p><br /></p><p>以及如果你會碰到需要輸入交互式命令的程式,像是 python,你可以讓 shell 自動輸入執行,再跳出:</p><p>echo "print("From Python")\nexit()\n" | python</p><p>基本上, \n 就等同於按下 Enter 的意思。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">死結 Deadlock</span></h3><p><br /></p><p>在這邊因為我沒有遇到絕對的死結問題,只要 SELECT, UPDATE, 一卡住,通常我就會直接重開機,然後把 SQL 腳本分開,斷在那個會死結的行數那邊,然後先讓前面 COMMIT 後重新開機,再接下去執行,就不會有問題。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">執行 SQL 使用的 character set</span></h3><p><br /></p><p>上面有隱約提到編碼的問題,假設你要使用 mysql < xxx.sql 的方式執行 SQL,請務必注意要把編碼指定好:</p><p><br /></p><p>cat "migration.sql" | mysql -h localhost -u admin -p your_db_name --default-character-set=utf8</p><p><br /></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">匯入匯出</span></h3><p><br /></p><p>使用 mysqldump 一定要注意 mysql 版本跟你裝的 mysqldump 版本有沒有一致,不一致絕對會發生不相容的狀況,請務必注意。</p><p><br /></p><p>這是一個匯出的指令:</p><p>mysqldump --routines -h xxx.xxx.xxx.xxx -u admin -p預先填入的密碼 your_db_name > ./xxx.sql</p><p><br /></p><p>這是一個匯入的指令:</p><p>mysql -h xxx.xxx.xxx.xxx -u admin -p預先填入的密碼 your_db_name < xxx.sql</p><p><br /></p><p>如果要進行匯入,請小心翼翼的 DROP DATABASE 再 CREATE DATABASE 一次;</p><p><br /></p><p>以及如果你是從遠端匯出,你可以用 scp 傳回來傳過去。</p><p><br /></p><p>**額外提醒,請記得如果你的資料庫有 FUNCTION, VIEW, PROCEDURE,請事先備份下來,有的時候 mysqldump 不會備份到這些東西,很有可能就會直接消失。</p><p><br /></p><p>要等到匯入之後再手動自行加回去。</p><p><br /></p><p>References:</p><p>1. https://stackoverflow.com/questions/54046541/mysql-how-to-generate-row-index-rank-in-select</p><p><br /></p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushSql.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-53932358406821657872021-09-19T17:39:00.003+08:002021-09-19T17:39:25.597+08:00pfSense (0) - 安裝設定<p>下載好 pfSense 的 Images 之後會進入安裝介面,安裝完後要在 pfsense 機器上設定一些參數,以下是步驟。</p><p><br /></p><span><a name='more'></a></span><p>以下是初次設定機器可以先提共內部 DHCP ,再透過自己的電腦瀏覽器連上 pfSense 機器的初次設定方法:</p><p><br /></p><p>步驟一,Assign Interface:</p><div class="separator" style="clear: both; text-align: center;"><div class="separator" style="clear: both; text-align: center;"><br /></div><br /><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj0trT4H02E_VRQ60lZnbQBoS7ml1OlRF5VlnxXXDh6-1Jo6nHdyjdyAk_tqZlGeMNkYPzH0uD0rOnFIHjYvt7Oi-2lHMAKhRaRk8cE7jTcIZ4HbYkSsZL5GnASNQBAOYAV2zWxI86_ITU/s1280/1.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" data-original-height="960" data-original-width="1280" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj0trT4H02E_VRQ60lZnbQBoS7ml1OlRF5VlnxXXDh6-1Jo6nHdyjdyAk_tqZlGeMNkYPzH0uD0rOnFIHjYvt7Oi-2lHMAKhRaRk8cE7jTcIZ4HbYkSsZL5GnASNQBAOYAV2zWxI86_ITU/w640-h480/1.jpg" width="640" /></a></div><br /><p>步驟二,只要先設定 LAN 就好,選 ID 為 2 的卡:</p><div class="separator" style="clear: both; text-align: center;"><div class="separator" style="clear: both; text-align: center;"><br /></div><br /><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg7LOTn_PqRCMESMh7KQ2YrqwUqkHGpAFAy8tkhyExDKITjaAthftmbQFTBBqzP_r2hhw55K0YdbNXJ6fR44sT5ZmpNJJyCoE8DE4kj7KMl6h91S1VFyzPsS49YVJKpvwgQcTzPbcbgVCE/s1280/2.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" data-original-height="960" data-original-width="1280" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg7LOTn_PqRCMESMh7KQ2YrqwUqkHGpAFAy8tkhyExDKITjaAthftmbQFTBBqzP_r2hhw55K0YdbNXJ6fR44sT5ZmpNJJyCoE8DE4kj7KMl6h91S1VFyzPsS49YVJKpvwgQcTzPbcbgVCE/w640-h480/2.jpg" width="640" /></a></div><br /><p>步驟三,設定這個介面的 subnet:</p><p><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjQwN7iw8uF3RSwwfzxm0VNj_s2RzMf2Ff-WBHEH5-lGMXofoWNBgmEo3R1NMx5y0iISl4m2uIz3LDeTA1Su-rOIiFMUI01DLpRi9mBqPFgKe973sxcWxtVMa-bgysoIa-gn6BTcXN6SG8/s1280/3.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em; text-align: center;"><img border="0" data-original-height="960" data-original-width="1280" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjQwN7iw8uF3RSwwfzxm0VNj_s2RzMf2Ff-WBHEH5-lGMXofoWNBgmEo3R1NMx5y0iISl4m2uIz3LDeTA1Su-rOIiFMUI01DLpRi9mBqPFgKe973sxcWxtVMa-bgysoIa-gn6BTcXN6SG8/w640-h480/3.jpg" width="640" /></a></p><p>步驟四,跳過這個設定,沒有上層的 Gateway。</p><p><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjhvE8DgB7_wOzupq7kfcur9vwSAY5iGw77ld898O-Ug1zOHs9yK6XGoTQ1KOkxBaTNJAH7_0rwQcnmEINkvaUyD3sB1n2xaFvZvGovoIL12WJbvHAZ5zegf50tiHFf9S8dWCfRIMemTzY/s1280/4.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em; text-align: center;"><img border="0" data-original-height="960" data-original-width="1280" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjhvE8DgB7_wOzupq7kfcur9vwSAY5iGw77ld898O-Ug1zOHs9yK6XGoTQ1KOkxBaTNJAH7_0rwQcnmEINkvaUyD3sB1n2xaFvZvGovoIL12WJbvHAZ5zegf50tiHFf9S8dWCfRIMemTzY/w640-h480/4.jpg" width="640" /></a></p><p><br /></p><p>步驟五,跳過這個設定,原因跟上面一樣。</p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEihVq0KbTewWA5LFz0nXG6P28SdnbMb7G3LXwxMJpMnBxIXrW4Vv1xj6gRJTZlFXtzaBM0jhDFUyPrSZRbmrZL856NSFQsmT8oRulE8FXStwdxrmwBk89AtVuzmzbSPURhhrd9fls_z-r4/s1280/5.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" data-original-height="960" data-original-width="1280" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEihVq0KbTewWA5LFz0nXG6P28SdnbMb7G3LXwxMJpMnBxIXrW4Vv1xj6gRJTZlFXtzaBM0jhDFUyPrSZRbmrZL856NSFQsmT8oRulE8FXStwdxrmwBk89AtVuzmzbSPURhhrd9fls_z-r4/w640-h480/5.jpg" width="640" /></a></div><br /><p>步驟六,打開 DHCP。</p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjN5Z_TXFhlcnPcdpAQ_CmCB_UeEPCJqTwdQgyV9Gap1AJuo9BNG-kFKQg8NtPBX5goCC3qqv11bk7SSmwRYZ_BJKHuPWoBOx9A1IgeJGTpINZpc4RKj5tvHu3GcL7DqphzBElIyIL0IJk/s1280/6.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" data-original-height="960" data-original-width="1280" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjN5Z_TXFhlcnPcdpAQ_CmCB_UeEPCJqTwdQgyV9Gap1AJuo9BNG-kFKQg8NtPBX5goCC3qqv11bk7SSmwRYZ_BJKHuPWoBOx9A1IgeJGTpINZpc4RKj5tvHu3GcL7DqphzBElIyIL0IJk/w640-h480/6.jpg" width="640" /></a></div><br /><p>步驟七,設定本張介面卡配發的網路範圍:</p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhARsJxH78YACehB0Gmva_rV8WpwT467yaEBqx_M1gza4zpct8jN_-3NL2CHSabCVAqzz_0x3Ev9vG5BQUw-iASv8HzwgrWi3XSDF-Qnu08ke4xAvuVNgfuhwSyO8JkRDG4JpCY-ZeyxKw/s1280/7.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" data-original-height="960" data-original-width="1280" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhARsJxH78YACehB0Gmva_rV8WpwT467yaEBqx_M1gza4zpct8jN_-3NL2CHSabCVAqzz_0x3Ev9vG5BQUw-iASv8HzwgrWi3XSDF-Qnu08ke4xAvuVNgfuhwSyO8JkRDG4JpCY-ZeyxKw/w640-h480/7.jpg" width="640" /></a></div><br /><p>步驟八,設定本張介面卡的結束範圍:</p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEis1WzxIoZ6vIvGcb5RgOQuJlD-8SjCwQnxLG5_oVlZY1LybhLDi1cCu925c9kOVapDlE6r4whJ-K7rxOJ7cpip072FVZn81mittSKAPGkVh5pkSV9zTYkjMejGzZRpgxQEFPlliXfWlyw/s1280/8.jpg" imageanchor="1" style="margin-left: 1em; margin-right: 1em;"><img border="0" data-original-height="960" data-original-width="1280" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEis1WzxIoZ6vIvGcb5RgOQuJlD-8SjCwQnxLG5_oVlZY1LybhLDi1cCu925c9kOVapDlE6r4whJ-K7rxOJ7cpip072FVZn81mittSKAPGkVh5pkSV9zTYkjMejGzZRpgxQEFPlliXfWlyw/w640-h480/8.jpg" width="640" /></a></div><p><br /></p><p><br /></p>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-9005263780494099782021-09-03T14:46:00.001+08:002021-11-13T14:58:51.553+08:00Setup Logstash in production server with logstash-input-journald<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushBash.js" type="text/javascript"></script>
<p>近月來我都是使用部屬單體應用程式居多,每次都要連上 Remote Server 開 journalctl 看 log 錯誤,為了求方便及可以做 BI,所以就希望把 journalctl 資料撈回來,其中以不影響 code 為主要目的。</p><p>之所以用 journalctl 是因為原本程式就沒有設計要做 logging, monitoring,所以希望用外掛的方式來達成。</p><p><span></span></p><a name='more'></a><p></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">HAProxy for receive inbound log</span></h3><p><br /></p><p>首先,本地端你應該有準備好 ELK 了,而且可以開一個 Port 在 IP 上接收傳來的資訊,提供我個人的設定方式,從外部 log 進入內部網路,我是使用 HAProxy 來處理 Load-Balance,所以只要在設定中加入一個 backend 就可以實現從 domain name 傳資訊回來。</p><p>(/etc/haproxy/haproxy.cfg)</p><pre class="brush: bash;" name="code">global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
stats socket /run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
user haproxy
group haproxy
daemon
# Default SSL material locations
ca-base /etc/ssl/certs
crt-base /etc/ssl/private
# See: https://ssl-config.mozilla.org/#server=haproxy&server-version=2.0.3&config=intermediate
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
defaults
log global
mode http
option httplog
option dontlognull
timeout connect 5000
timeout client 50000
timeout server 50000
errorfile 400 /etc/haproxy/errors/400.http
errorfile 403 /etc/haproxy/errors/403.http
errorfile 408 /etc/haproxy/errors/408.http
errorfile 500 /etc/haproxy/errors/500.http
errorfile 502 /etc/haproxy/errors/502.http
errorfile 503 /etc/haproxy/errors/503.http
errorfile 504 /etc/haproxy/errors/504.http
frontend http-in
bind *:80
# define incomming connection to variable (for elasticsearch elk)
acl ACL_elk hdr_beg(host) -i elk.XXXXXXXX.com
# define redirect when matching to url
use_backend elk if ACL_elk
backend elk
option forwardfor
server elk 10.6.1.51:9200 check (指向 ELK Server)</pre><p></p><p><br /></p><p>完成修改之後,需要重啟 sudo systemctl restart haproxy.service。</p><p><br /></p><h3><span style="font-size: x-large;">遠端 Server 安裝 Logstash</span></h3><p><br /></p><p>遠端伺服器也需要裝 Logstash 而且讓他跑在 daemon ,才會送資料回來,安裝方式是:</p><p><br /></p><pre class="brush: bash;" name="code">wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
sudo apt-get install apt-transport-https
echo "deb https://artifacts.elastic.co/packages/7.x/apt stable main" | sudo tee -a /etc/apt/sources.list.d/elastic-7.x.list
sudo apt-get update && sudo apt-get install logstash</pre><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">Logstash Journalctl Input Plugin</span></h3><p><br /></p><p>Logstash 並不認識 journalctl 也不知道怎樣把他的資料當作 input,所以需要透過外掛達成,外掛安裝方式是:</p><p><br /></p><pre class="brush: bash;" name="code">git clone https://github.com/logstash-plugins/logstash-input-journald.git
cd logstash-input-journald
gem build logstash-input-journald.gemspec
sudo /usr/share/logstash/bin/logstash-plugin install /home/deploy/logstash-input-journald/logstash-input-journald-*.gem</pre><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">設定及開啟 Logstash </span></h3><p><br /></p><p>讓遠端 Server 開啟 Logstash 是為了要讓資料傳回來,不是要開 Logstash Server,所以只需要設定完 Logstash 並給 Data Folder 設定權限,就可以開啟了:</p><p><br /></p><pre class="brush: bash;" name="code">chown -R logstash.lostash /usr/share/logstash
chown -R /usr/share/logstash
sudo chmod 777 /usr/share/logstash/data
sudo chmod -R 777 /usr/share/logstash
# 開啟
sudo systemctl start logstash
sudo systemctl enable logstash</pre><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">撰寫 Journalctl Logstash Config</span></h3><p><br /></p><p>完成設定開啟 Logstash 後,要寫一份設定檔案,讓 Log 可以按照你要的方式傳回去,則可參考以下寫法:</p><p>(journalctl.logstash)</p><pre class="brush: bash;" name="code">input {
journald {
lowercase => true
seekto => "head"
thisboot => true
type => "[你程式 journalctl 的 syslog-identify]"
tags => [ "自訂 TAG 名稱" ]
sincedb_path => "/home/deploy/.sincedb_journal"
}
}
filter {
# 要加入 filter,否則它會把所有 log 回傳,會造成 CPU 100%
if([syslog_identifier] !~ "[你程式 journalctl 的 syslog-identify]") {
drop { }
}
}
output {
elasticsearch {
hosts => ["https://elk.XXXXXXX.COM:443"]
ssl => true
index => "[自訂的 ELK Index 名稱]-%{+YYYY.MM.dd}"
}
stdout {codec => rubydebug}
}</pre><p><br /></p><p>在撰寫上,要特別注意 journalctl logstash 會把所有系統的 log 回傳,很容易造成 CPU 100%,加上這個 filter 過濾掉只要你要的資料,像是對照 journalctl -xefu [你程式 journalctl 的 syslog-identify] 這個做法只列出你要的資料, CPU 基本上就會被降到非常非常低,我自己看的結果是只有 4% 。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">套用設定</span></h3><p><br /></p><p>寫完設定檔之後,只需要把這個檔案套用到 logstash 就可以在本地端看見結果了。</p><p><br /></p><p><span style="font-family: monospace;"><span style="white-space: pre;">sudo /usr/share/logstash/bin/logstash -f journal.logstash --path.data ./temp_data </span></span></p><p></p>
Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-63121094539374836132021-08-21T20:15:00.003+08:002021-09-19T17:24:48.581+08:00Application deployment with systemctl<p> 我們在佈署應用程式時,通常有幾種佈署方式,像是 Node.js 生態可能首選就會找 Forever, PM2, 之類的工具,對於更普遍的應用應用程式來說,可能就會考慮 Launchd, Systemctl, Nohup,本文章實作了 systemctl 的方式來作應用程式佈署的選項。</p><span><a name='more'></a></span><p><br /></p><p>以往,我都是使用 nohup 來對應用程式進行佈署,特別是使用 golang 的應用程式,每一次做應用程式佈署,我就會用到這些指令:</p><p><br /></p><p></p><ol style="text-align: left;"><li>go build</li><li>先 check port usage 然後把 pid 刪掉: sudo lsof -i -P -n | grep LISTEN</li><li> sudo kill -15 <pid></li><li>然後再做應用程式佈署 nohup ./xxxxx &</li></ol><div><br /></div><div>可是每次進行這樣的步驟,會有很大的風險,基本上 downtime 控制的不是很好,寫成腳本可能也跟這個 proejct directory 挷在一起。</div><div><br /></div><div>寫成腳本感覺是好了一些,但是關鍵點就是只使用 nohup,他的 log 是存在 file 中,其實是蠻陽春的 deamon 工具。</div><div><br /></div><div>我們其實可以善用 Linux 系統的 process 管理工具,把 app deploy 的更 general 一些。</div><p></p><p><br /></p><p>以下是必要了解的方針:</p><p></p><ol style="text-align: left;"><li>寫一個 .service 檔案,讓 systemctl 可以跑</li><li>寫一個 deploy.sh 抽換檔案,給每次做 build 的時候執行 (CI/CD) 只需要執行這個腳本即可<br /><br />*remark: 正式的 production deploy 程序可能會有 build.sh, deploy.sh 兩個檔案, build.sh 檔案會做 git checkout -- . 以及 git pull 然後進去目錄 build,而 deploy.sh 檔案則是會把 build 出來的 artifacts 檔案放到 system lib 目錄,而且做 release 版本分隔,最後重新啟動 systemctl service。<br /><br /></li><li>使用 journalctl -xefu <app_name> 來做 log 監看</li></ol><br />首先,假設你已經有一個專案,叫做 test-gin:<p></p><p></p><pre class="brush: go;" name="code">package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
})
r.Run() // listen and serve on 0.0.0.0:8080 (for windows "localhost:8080")
}
</pre><p><br /></p><p>在 go 端,這個專案是直接用 go mod 來跑,先取名專案叫做 test-gin,所以要先把這個專案放到 go path:</p><p></p><ol style="text-align: left;"><li> 放到 ~/go/src/xxx.com/xxxuser/test-gin 目錄</li><li> 進去目錄,做 go mod init</li><li> 執行 deps 處理: go mod tidy</li><li> 做 go build 編譯看看</li><li> 直接跑 ./test-gin 看看</li></ol><p></p><p><br /></p><p>go 測試專案完成了,要開始進入佈署階段了,現在的終端機應該是要切到 golang 專案目錄下,繼續做這些事情。</p><p><br /></p><p>現在要寫一個 systemctl 讀取使用的 .service 檔,叫做 test-gin.service:</p><p><br /></p><p></p><pre class="brush: go;" name="code">[Unit]
Description=Test Gin
After=local-fs.target network.target
[Service]
Type=simple
User=<你的帳號> # e.g: root
Group=<group 或你的帳號> # e.g: root
LimitNOFILE=65535
Restart=always
SyslogIdentifier=test-gin # 系統 log 辨別的關鍵字
WorkingDirectory=/home/<你的帳號>/go/src/exp.com/<帳號>/test-gin # go path 的專案目錄
ExecStart=/home/<你的帳號>/go/src/exp.com/<帳號>/test-gin/test-gin # go 執行程式的位置
Envoronment=PORT=443
[Install]
WantedBy=multi-user.target
</pre><p><br /></p><p>範例:</p><p><br />
</p><pre class="brush: go;" name="code">[Unit]
Description=Test Gin
After=local-fs.target network.target
[Service]
Type=simple
User=hpcslag
Group=hpcslag
LimitNOFILE=65535
Restart=always
SyslogIdentifier=test-gin
WorkingDirectory=/home/hpcslag/go/src/exp.com/hpcslag/test-gin
ExecStart=/home/hpcslag/go/src/exp.com/hpcslag/test-gin/test-gin
# 可以設定多個環境變數,程式可以直接吃到
Envoronment=PORT=443
# Envoronment=HTTPPORT=80
# Envoronment=GCPServiceAccountCredentials=~/xxxx.json
[Install]
WantedBy=multi-user.target
<br /></pre><p></p><p>然後,把佈署這個 .service 腳本的整個命令寫成腳本,讓它可以自動在改完 .service 的時候,自動更新原來的 .service 檔案,方便除錯 .service:<br /></p><p><br /></p><p></p><p></p><p></p><pre class="brush: go;" name="code" style="-webkit-text-stroke-width: 0px; color: black; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;">sudo rm -rf /usr/lib/systemd/system/test-gin.service
sudo cp test-gin.service /lib/systemd/system/.
sudo chmod 755 /lib/systemd/system/test-gin.service
sudo systemctl enable test-gin.service
systemctl start test-gin
systemctl status test-gin
<br /></pre><p>現在,只要直接執行 sudo sh ./apply_new_service.sh,就會看到現在執行這個應用程式是否成功,不成功則要修正。</p><p><br /></p><p></p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhELJ-9D1A1PlcjEwA71yiWjyGmuW9TtP0TBOrVA5SCD73aB5PwuidYrqMo8aqv1slayn2Xn816_xuEIcGWvpt9KNKbmNDFYPKjJZJuQxdznfpbvLR0mMgmppAee4xLYfXtunVRIJZFFKY/" style="margin-left: 1em; margin-right: 1em;"><img data-original-height="460" data-original-width="1331" height="222" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhELJ-9D1A1PlcjEwA71yiWjyGmuW9TtP0TBOrVA5SCD73aB5PwuidYrqMo8aqv1slayn2Xn816_xuEIcGWvpt9KNKbmNDFYPKjJZJuQxdznfpbvLR0mMgmppAee4xLYfXtunVRIJZFFKY/w640-h222/image.png" width="640" /></a></div><br /><br /><p></p><p>我的檔案叫做 ./deply.sh 是誤會,事實上應該改名叫做 apply_new_service.sh。</p><p><br /></p><p>現在只要做 go build 之後,做 systemctl restart test-gin 就可以重啟。</p><p><br /></p><p>然後,使用 journalctl -xefu test-gin 在另一個終端機監控 systemctl restart test-gin 這個指令執行,就可以看到變化。</p><p><br /></p><p>但是,如果要變得更正式,恐怕把 runtime binary 放在這個專案目錄不太好,我們可以改用更正式的作法,區分 current 和 past release 檔案們。</p><p><br /></p><p>現在,我希望分出 runtime 目錄還有留存一些歷史紀錄的 runtime 資料夾, runtime 目錄稱為 current,歷史 runtime 叫做 releases,裡面的目錄應該都是用 timestamp 來命名,反正可以依照日期新舊排序。</p><p><br /></p><p>如果要這麼做,那就要一次連,專案佈署流程都一起做完,打造一條龍的服務。</p><p><br /></p><p>現在,要直接改掉剛才的 service 檔案,因為如果新的方法套用上去,不能讓 systemctl 去跑 golang 專案目錄的 binary 檔案,而是要讀系統目錄的檔案。</p><p><br /></p><p>要把剛才的 test-gin.service 改動為:</p><p></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p></p><pre class="brush: go;" name="code" style="-webkit-text-stroke-width: 0px; color: black; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px;">[Unit]
Description=Test Gin
After=local-fs.target network.target
[Service]
Type=simple
User=hpcslag
Group=hpcslag
LimitNOFILE=65535
Restart=always
SyslogIdentifier=test-gin
# 目錄要改成放到 /usr/local/lib/________ 下
WorkingDirectory=/usr/local/lib/test-gin/current
ExecStart=/usr/local/lib/test-gin/current/test-gin
# 如果想要在執行程式的一開始先做 migrations:
# ExecStartPre=/usr/local/lib/test-gin/current/test-gin migrate\
# 做完 migrations 再 start
# ExecStart=/usr/local/lib/test-gin/current/test-gin start
Envoronment=PORT=443
[Install]
WantedBy=multi-user.target
<br /></pre><p>然後,要寫一個 deploy.sh 檔案,可以把這個專案目錄的 build 檔案全部移動到 /usr/local/lib 然後重新啟動 systemctl 的 shell 檔案:</p><p></p><p style="-webkit-text-stroke-width: 0px;"></p><p></p><pre class="brush: go;" name="code" style="-webkit-text-stroke-width: 0px;">#!/usr/bin/env bash
# 換專案只要改這裡即可
export ENV=production # 設定環境變數是 Production
APP_NAME=test-gin # systemctl 名稱,要跟剛才 .service 一致
DEPLOY_USER=_______ # 設定 deploy 的名稱 (範例: 目前帳號或 root)
APP_GROUP=_______ # 設定 app group 名稱 (範例:目前帳號或 root)
ARTIFACT_ROOT="$PWD" # 要佈署的 build 目錄 (目前預設是專案目前這個目錄)
# 結束自訂區域
DESTDIR="/usr/local/lib/$APP_NAME/"
CURRENT_LINK="${DESTDIR}current"
MAX_PAST_RELEASES=2
# Exit on errors
set -e
# set -o errexit -o xtrace
. ~/.bashrc
cd $ARTIFACT_ROOT
CURDIR="$PWD"
TIMESTAMP=$(date +%Y%m%d%H%M%S)
RELEASE_DIR="${DESTDIR}releases/${TIMESTAMP}"
mkdir -p "$RELEASE_DIR"
cp -r "${ARTIFACT_ROOT}/." "${RELEASE_DIR}"
sudo chown -R ${DEPLOY_USER}:${APP_GROUP} "${RELEASE_DIR}"
sudo chmod -R 777 ${RELEASE_DIR} # given permission for application folder
echo "Linking new release to $CURRENT_LINK"
if [[ -L "$CURRENT_LINK" ]]; then
rm "$CURRENT_LINK"
fi
ln -s "$RELEASE_DIR" "$CURRENT_LINK"
# Ensure that app OS user can use group permissions to execute files in releases
echo "Setting permissions for release executables"
sudo chown -R $DEPLOY_USER:$APP_GROUP "$CURRENT_LINK"
find -H $CURRENT_LINK -executable -type f -exec chmod g+x {} \;
sudo /bin/systemctl restart "$APP_NAME"
# Remove old releases, leaving only the most recent
echo "Removing past releases"
find ${DESTDIR}releases/ -maxdepth 1 -mindepth 1 -type d | sort -n | head -n -$MAX_PAST_RELEASES | xargs rm -rf
echo "Deployment successful"
exit 0
</pre><p>完成後,使用 sudo sh ./deploy.sh 執行,就會看到整個程式幫你移動到 current 去,而且,每次執行時,都會把上一個 deploy 好的版本,移動到 releases 目錄下,用 timestamp 分類,而現在的程式則是跑在 current 這個目錄下。</p><p><br /></p><p>可以搭配 journalctl -xefu test-gin 跟執行 deploy.sh 為兩個不同的 terminal 視窗,執行 deploy.sh 就會看到隔壁視窗的佈署訊息改變了。</p><p><br /></p><p>Remark 2021/09/19:</p><p>Vultr 的 Server 會有 SELinux 的問題導致你的程式開不起來,可以試著把它關閉。</p><p><br /></p><p>以下的流程可以針對設定一個 Application 到新的主機上的 install.sh 腳本:</p><p><br /></p><p></p><p></p><p></p><pre class="brush: go;" name="code" style="-webkit-text-stroke-width: 0px;">#!/bin/sh
set -e
# stop selinux https://blog.yowko.com/linux-service-status-203/
getenforce
setenforce 0 && sed -i 's/SELINUX=enforcing/SELINUX=disabled/g' /etc/selinux/config
sudo rm -rf /usr/lib/systemd/system/test-gin.service
sudo cp test-gin.service /lib/systemd/system/.
sudo chmod 755 /lib/systemd/system/test-gin.service
systemctl daemon-reload # 更新 .service 檔案就要這麼做
sudo systemctl enable test-gin.service
systemctl start test-gin
systemctl status test-gin
</pre><p><br /></p><p>然後可以在程式目錄下寫一個 Makefile:</p><p>deploy:</p><p><span> go build</span><br /></p><p><span> systemctl restart test-gin</span></p><p><span> journalctl -xefu test-gin</span></p><p><br /></p>
<script src="https://cdn.jsdelivr.net/gh/gytisrepecka/brush-go/shBrushGo.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-15904964715826136532021-08-11T15:00:00.000+08:002021-08-22T15:00:53.895+08:00Ethereum Smart Contract and ERC Spec working with IPFS, Project Base with Hardhat and Ethers.js<p> 乙太坊的智能合約逐漸引入了各式標準,第三方應用透過制式標準的介面 (Interface) 打造通用的代幣使用體驗,像是只要符合 ERC 標準的智能合約,Metamask 就可以引入該代幣合約的錢包。</p><p><br /></p><p>本文章用於介紹 ERC Solidity 實作自己的代幣合約,在不同種的代幣合約應用的方式,以及將 IPFS 檔案 Token 與 ERC 合約綁定; 最後在終端使用者的 Web 專案中,可以使用 Remix 外的 Hardhat 做合約專案及編譯流,以及 Ethers.js 在前端讀取合約,呼叫 Metamask 進行交易。</p><span><a name='more'></a></span><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">大量的內容事前準備</span></h2><p><br /></p><p>這個文章會涉及大量的去脈絡化概念,可能會直接迷失在資訊海中,因此在此先做好各項解釋。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">智能合約, Solidity, Fe, Vyper...</span></h3><p><br /></p><p>智能合約,簡單的說就是 Ethereum 的 Blockchain 本身的特性可以允許在 block 上寫入更多的資料,進而衍生出智能合約的協議,合約本身可以用任何語言寫 (目前熱門的舉例是: Solidity, Fe, Vyper ...etc),合約會編譯成 Bytecode,而可以跟合約本身互動做應用的是 ABI (Application Binary Interface), ABI 就是合約編譯成 Bytecode 的形式,通常要做合約執行、讀取都會吃 abi 這個格式。</p><p><br /></p><p>合約寫完之後要寫到鏈上,此時需要付一點點的 ETH Gas 作為手續費,寫到鏈上之後,你會獲得這個合約的 address 地址,之後就是要跟這個地址的合約進行互動。</p><p><br /></p><p>使用者本身可以對這個合約上含有 payable 的 function 進行付款,付款之後可以決定合約要做什麼事,以及誰可以提款 (佈署合約時可以在 constructor 指定佈署合約的人可以提款)。</p><p><br /></p><p>延伸閱讀: <a href="https://www.youtube.com/watch?v=RxL_1AfV7N4" target="_blank">EVM: From Solidity to byte code, memory and storage</a></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">代幣合約 / 自行發幣 / 山寨幣, ERC, OpenZeppelin</span></h3><p><br /></p><p>當我們說自己發幣、發代幣、山寨幣,其實都是同一個東西,總之就是在 ETH 或相關的幣上,直接發行自己的代幣,這個代幣可以是有價無價,例如用 ETH 發行數張電影票,電影票就是代幣; 或是用 ETH 發行【母湯 Bear】代幣,讓玩家可以用這個代幣去樂園玩實體遊樂設施。</p><p><br /></p><p>在乙太方中,你可以自己寫一個完全脫離公制標準的代幣合約,可以完全不按照標準進行,只是會缺少很多第三方應用支援,而且你在做的事情基本上就是在重新造輪子。</p><p><br /></p><p>因此,代幣公制合約出現了,最典型的有幾個合約: ERC20, ERC721, ERC1155,以下會介紹這幾個合約的簡單說。</p><p><br /></p><p><b><u>ERC 20</u></b></p><p>簡單銀行,對於單幣種發行適用,比方說單一發行美金,單一發行日幣,單一發行歐元...etc。</p><p>最重要的概念是,ERC 20 適用任何量級對所有人來說價值是一致的,比方說我的 1 美元的價值完全等於你手上的 1 美元的價值。</p><p><br /></p><p>如果要無中生有一堆幣給別人,可以自行鑄幣 (Mint, Creating Supply)。</p><p><br /></p><p><u><b>ERC 721</b></u></p><p>獨一無二形式的契約,適用於房地契、世界僅有一個的契約證明等,想發行一個獨特合約,就必須先鑄幣,這裡鑄幣的意思是發行一個獨一無二的契約。</p><p><br /></p><p>發行時可以帶一些 URL 資訊或額外資訊在上面。</p><p><br /></p><p><b><u>ERC 1155</u></b></p><p>混和形式的 Token 發幣標準,適用於遊戲中的倉庫物件、物品、也可以包含獨一無二的物品。</p><p>可以一開始就把需要的數量鑄幣好,做成有限個數的量然後發行,獨一無二的物品只需要發行一個即可達到這樣的概念。</p><p><br /></p><p><b><u>共同概念</u></b></p><p>這些幣的共同概念,就是轉帳,鑄幣 (Mint),如果要預先製造好所有的物品,那可以把所有物品一開始的擁有者歸在發行合約者身上,最後要取用、轉移則是透過合約發行者去轉帳,這個角色也可以是自動化程式執行者。</p><p><br /></p><p>鑄幣、轉帳都可以決定先轉給誰,也可以先轉給自己,之後再轉給別人,不管怎樣,執行鑄幣都要收手續費,你可以一次鑄好 (Batch),甚至 ERC1155 也可以 Batch 發送轉帳給別人。</p><p><br /></p><p><b><u>合約與公版</u></b></p><p>這些合約事實上就已經是 .sol 檔案了,基礎函式、操作都已經有了,甚至不需要做任何事就可以直接發行代幣,唯一要做的就是去下載合約,然後上鏈。</p><p><br /></p><p>Open Zeppelin 甚至提供了合約建立精靈,用 UI 就可以加上想要的功能:</p><p><a href="https://docs.openzeppelin.com/contracts/4.x/">https://docs.openzeppelin.com/contracts/4.x/</a></p><p></p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgegok-cFaIKBXp4j8Uvbr7xgNlteF6Ra9eRDpulSsGehhgbz1LPX_eddIIGRhCiKuCrvdZg9V_NrmuNNVfIWLF_hmCPdZa-5DILxo_ZprbcMDv15WIg2nTbjXfUpdTcO_Yg4V6SYVhD-s/" style="margin-left: 1em; margin-right: 1em;"><img data-original-height="879" data-original-width="981" height="573" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgegok-cFaIKBXp4j8Uvbr7xgNlteF6Ra9eRDpulSsGehhgbz1LPX_eddIIGRhCiKuCrvdZg9V_NrmuNNVfIWLF_hmCPdZa-5DILxo_ZprbcMDv15WIg2nTbjXfUpdTcO_Yg4V6SYVhD-s/w640-h573/image.png" width="640" /></a></div><br /><br /><p></p><p>下載後直接上鏈或是可以到 Remix 做測試。</p><p><br /></p><p>Open Zeppelin 提供了 ERC 系列的解釋、用法,這是一份值得詳細讀完的文件: </p><p><a href="https://docs.openzeppelin.com/contracts/4.x/">https://docs.openzeppelin.com/contracts/4.x/</a></p><p>(記得要切到最新版看, Google 都會搜尋到舊版的)</p><p><br /></p><p><b><u>對價標準</u></b></p><p>這是涉及通貨、經濟學的概念,你需要考慮的是,你所有發行的貨幣是有限個數,還是無限個數的,數量量級都會影響代幣與有價貨幣的對價關係,引發通貨膨脹、通貨緊縮的問題。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">IPFS, Ethereum, Token</span></h3><p>IPFS 是一個區塊鍊的分散式檔案架構的服務,記帳權是持有量證明,用的幣是檔案幣,檔案上傳後,你會得到一組 token,取得的方式是去找 IPFS Gateway 列表 <span style="font-size: xx-small;"><a href="https://docs.ipfs.io/concepts/ipfs-gateway/#gateway-providers" target="_blank">[2]</a></span>,隨便找一個還活著的 Gateway 使用 token 作為 url 參數下載:</p><p>https://ipfs.github.io/public-gateway-checker/</p><p><br /></p><p>選定一個 Gateway 之後,網址後面加上 /ipfs/:token_hash 就可以下載了,範例:</p><p>https://xxxxxxxxxxxxxxxxxxxx.com/ipfs/3NK21N3N3K10NBS90</p><p><br /></p><p>這串網址就可以作為 token,把資訊強加在 ERC 合約上綁著一起賣,反正這就表示該張合約賣的東西跟 IPFS 所述相同。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">Mainnet, Testnet 與 Infura 代理速度變快</span></h3><p>所有的鏈互動,都可以用預設 Official 提供的節點伺服器位置,甚至有些服務為了要加快你的應用程式的存取速度,提供你他們的節點伺服器位置讓你填寫,然後使用者每個月付費就可以享有這些好處。</p><p>像是 Infura 就提供 IPFS, Ethereum ...etc 的 net 可以串,註冊帳號後,可以拿到 Infura 提共的 Mainnet, Testnet 地址, Mainnet 是正式網路,需要付給真實的錢,Testnet 是測試網路,可以透過水龍頭服務給你帳號發點錢來測試。</p><p><br /></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">Hardhat, Remix</span></h3><p>Remix 是一個線上 IDE,你可以自己寫一寫在線上測試,或是真的佈署到鏈上,可是本地專案也會需要一個這樣的佈署流程,雖然沒 Remix IDE 來的方便,但是卻是專案結構變大時會需要的流程建立,而本文章使用的工具是 Hardhat ,Hardhat 可以幫你編譯 .sol 檔案,還可以提共你本地測試用的 RPC Server,預設給你 10 組 100ETH 的錢包可以用,但是要記得每次重新開啟 Hardhat 時,綁定錢包服務的 address 要重新匯入新的。</p><p><br /></p><p>Hardhat 還可以幫你佈署合約到目的網路,可以是 Mainnet 也可以是 localhost 也可以是 Testnet。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">Ethers.js, Web3.js</span></h3><p>Web3.js, Ethers.js 都是在網頁上操作合約互動的工具,終端的互動就是要呼叫 metamask 這個瀏覽器外掛起來交易,所以你必須先安裝好 Metamask 在 chrome 之類的瀏覽器中,還要設定好你的錢包 (錢包可以用 localhost 或 Mainnet 或 Testnet)。</p><p><br /></p><p>合約執行 Function 時,就會用 Ethers.js 觸發,甚至佈署合約時也要透過 Ethers.js 給 constructor 丟資料,佈署等等。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">Etherscan, Indexing APIs</span></h3><p>所有對 Blockchain 的交易,都可以在 Etherscan 上找到,包含合約佈署、合約 ABI (可轉為程式碼) ,甚至不只 Etherscan,也有其他服務會提供 Indexing API 去處理,像是 The Graph 就提供 GraphQL 查詢的方式給終端。</p><p>最基礎的 Ether API 大概會提供你查詢這個 block 的狀態、block 交易、block 證明或被 mining 的量、手續費等等。</p><p><br /></p><p>如果你要發的代幣要通用很多自己商業生態的服務,那麼你需要自己再寫一層 API Wrapper ,提供廠商服務,不過在這塊需要考量的大概就是分頁問題 (pagnition)。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">進行合約開發</span></h2><p><br /></p><p>上面針對了許多去脈絡化的概念做了一些精簡的解釋,現在要開始寫合約來做發幣了。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">Wizard 與寫一個 ERC20 代幣合約</span></h3><p><br /></p><p>其實你什麼都不用做,只要去 <a href="https://docs.openzeppelin.com/contracts/4.x/wizard" target="_blank">Wizard</a> 勾好選項去下載就好,在這邊對 Wizard 一些項目做精簡的解釋:</p><p>ERC20</p><p></p><ul style="text-align: left;"><li>Settings</li><ul><li>Name: 合約名稱 (代幣完整名稱)</li><li>Symbol: 代幣符號 (通常是縮寫: 如 ETH, BTC)</li><li>Premint: ERC20 初始需要製幣數量</li></ul><li>Features</li><ul><li>Mintable: 可再鑄幣</li><li>Burnable: 可燒毀指定數量的幣以維持平衡</li><li>Pausable: 可停止交易</li><li>Permit: 終端使用者不須要支付手續費(gas),取代之就是合約佈署者要代為支付</li><li>Votes: 終端使用者可以擁有投票權 (公司、事務決策,類似董事會持股高的人可以有部分投票決策權)</li><li>Flash Minting: 借貸、貸款功能</li></ul><li>Access Control: 存取控制</li><ul><li>Ownable: 只有建立合約的人才有權限操作合約內容 (根據限制而定)</li><li>Roles: 可以建立浮動機制,讓一張合約有很多人可以擁有權限</li></ul><li>Upgradeability: 合約升級的方法</li><ul><li>UUPS</li><li>Transparent</li></ul></ul><div><br /></div><p></p><p></p><pre class="brush: javascript;" name="code">// contracts/GLDToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract GLDToken is ERC20 {
constructor(uint256 initialSupply) ERC20("Gold", "GLD") {
_mint(msg.sender, initialSupply);
}
}</pre><p></p><p><br /></p><p>在 Remix 上操作代幣合約</p><p>選好之後,按下 Open in Remix,對檔案儲存一下,在 Compile 區域,可以選擇你的合約,然後做 Compile:</p><p></p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjOtUB9ylmAxAutJxUMPVbncndLiTo_OWTBIs8ughvYqEPWvQF77jsq-IfD_Vfn2MzCv5DCxERBzX8rHvaP3V-MGbOlrlI15xyKw0lJbVz58EuNhQ6lVJ6tB_tjnkM_iBDUiuqCc1kGPbQ/" style="margin-left: 1em; margin-right: 1em;"><img data-original-height="790" data-original-width="752" height="640" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjOtUB9ylmAxAutJxUMPVbncndLiTo_OWTBIs8ughvYqEPWvQF77jsq-IfD_Vfn2MzCv5DCxERBzX8rHvaP3V-MGbOlrlI15xyKw0lJbVz58EuNhQ6lVJ6tB_tjnkM_iBDUiuqCc1kGPbQ/w608-h640/image.png" width="608" /></a></div><br /><br /><p></p><p>然後,在 Deploy 欄位直接選擇剛才的合約, Deploy 上去。</p><p></p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj3wK1V2sxe0wmtjLMgcH7vN0fpnD0XYzWebKgS9pjHFfVAb22JNUx3hM1ftUcVbMsDSO-XYnbu6i2MAf4YSbY-9jls1Dnp-sbALqn5Mwvpt0Qz9ToxRv2v871AMc0YDrNRRHST4OWN7jM/" style="margin-left: 1em; margin-right: 1em;"><img alt="" data-original-height="564" data-original-width="557" height="480" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj3wK1V2sxe0wmtjLMgcH7vN0fpnD0XYzWebKgS9pjHFfVAb22JNUx3hM1ftUcVbMsDSO-XYnbu6i2MAf4YSbY-9jls1Dnp-sbALqn5Mwvpt0Qz9ToxRv2v871AMc0YDrNRRHST4OWN7jM/" width="474" /></a></div><br />佈署上去後,可以在任意下方執行 Function:<p></p><p><br /></p><p>只要輸入參數後,按下按鈕就可以執行 Function。</p><p></p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjfATOgpHsYao2s-ZlT8ItR2jq14xzMYFFOd2BXnEECK9HMiZIZOs5oPJrUfyF_SuAO1J5HzSyR0wQN2Z4xvCnt6cll4GbwKcuGVVQ6-0b1ssZRFeykwbqSEr7hv6n3gpe3Cy2rHyr8Rgk/" style="margin-left: 1em; margin-right: 1em;"><img data-original-height="724" data-original-width="372" height="640" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjfATOgpHsYao2s-ZlT8ItR2jq14xzMYFFOd2BXnEECK9HMiZIZOs5oPJrUfyF_SuAO1J5HzSyR0wQN2Z4xvCnt6cll4GbwKcuGVVQ6-0b1ssZRFeykwbqSEr7hv6n3gpe3Cy2rHyr8Rgk/w328-h640/image.png" width="328" /></a></div><br />對於自製任何需要付費的 payable function,要在上方的 value 輸入你要付的錢數量,才去點紅色的按鈕開始交易:<p></p><p></p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEho2_R4B5E099JwTOhIi_XGxuV4midb9zivh3c3gtMgL2iW1PP2LjmbTruUk_RLn5Dz4lVwZbv5PksF3VsPyqUEFUZoUPZJvFzfqhiRfBNYgHT1Q7cf6Y_Kchn1aki5OpQDob5tu2BN2T8/" style="margin-left: 1em; margin-right: 1em;"><img data-original-height="929" data-original-width="829" height="640" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEho2_R4B5E099JwTOhIi_XGxuV4midb9zivh3c3gtMgL2iW1PP2LjmbTruUk_RLn5Dz4lVwZbv5PksF3VsPyqUEFUZoUPZJvFzfqhiRfBNYgHT1Q7cf6Y_Kchn1aki5OpQDob5tu2BN2T8/w571-h640/image.png" width="571" /></a></div><br /><br /><p></p><p>如果想判斷到使用者做了某些事情之後,要取消交易怎麼辦?</p><p>可以在 solidity 使用 assert, require 之類斷言的方法判斷,如果判斷錯誤,就會終止交易。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">寫一個 ERC721 合約</span></h3><p><br /></p><div>ERC721</div><div><ul style="text-align: left;"><li>Settings</li><ul><li>Name</li><li>Symbol</li><li>Base URI: 綁定此合約而外的資訊參數,這裡就可以放已經上 IPFS 鏈的網址檔案</li></ul><li>Features</li><ul><li>Mintable 有沒有鑄幣功能</li><ul><li>Auto Increment Ids (每個 ID 都是獨特的,每次鑄幣之後要幫您增加 ID 嗎?)</li></ul><li>URI Storage 每個 Ids 合約底下的獨特物件,都可以綁一個 URL 帶有額外參數,這裡也可以放已經上 IPFS 鏈的網址檔案</li></ul><li>Access Control</li><li>Upgradeability</li></ul></div><p><br /></p><p>在 Remix 上操作代幣合約</p><p>可先參考 721 文件: <a href="https://docs.openzeppelin.com/contracts/4.x/erc721">https://docs.openzeppelin.com/contracts/4.x/erc721</a></p><p>我複製了文件中的 awardItem 出來改,可以看到合約 function 吃了兩個參數,第一個是 player,第二個是 tokenURI,這個 tokenURI 就是專門放網址,或是 IPFS Token 專用的,第一個放 player,是希望在鑄幣之後,直接把這個 721 Token 賦予給這個人 (轉帳)。</p><p></p><div class="separator" style="clear: both; text-align: center;"><a href="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhPxdgXQ6BLgfhBamd5PZTqX1zdonohZiEVxei_ZBuYufcfLw0xwU-iTalJkvAhXPglOZ_U42hyw0kfeCc5flSR09vZFbKwPkgALTdKvh6Pi_Qeohrpkar41WD0_6A1XC3BzlTMrxczuLI/" style="margin-left: 1em; margin-right: 1em;"><img data-original-height="802" data-original-width="1006" height="510" src="https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhPxdgXQ6BLgfhBamd5PZTqX1zdonohZiEVxei_ZBuYufcfLw0xwU-iTalJkvAhXPglOZ_U42hyw0kfeCc5flSR09vZFbKwPkgALTdKvh6Pi_Qeohrpkar41WD0_6A1XC3BzlTMrxczuLI/w640-h510/image.png" width="640" /></a></div><br /><br /><p></p><p>在這些合約中,要善用 balanceOf 的方式檢查餘額 (對 1155 來說,甚至該物品餘額),或是在 721 情境下檢查此人是否擁有此 id token 物件。</p><p>並可以善用 transferFrom 相關函數來做轉帳,使用 mint 相關關鍵字函數來進行鑄幣。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">建立一個 Hardhat 專案</span></h3><p><br /></p><p>相關流程請直接跑:<a href="https://hardhat.org/tutorial/" target="_blank"> https://hardhat.org/tutorial/</a> 這份教學,這裡只閘述要做什麼事,首先是 hardhat 專案的結構:</p><p></p><ul style="text-align: left;"><li>contracts: 所有 .sol 放在這裡</li><li>artifacts: 編譯完的 .sol 會放在這裡</li><li>scripts: 執行用腳本</li><ul><li>deploy.js: 佈署腳本</li></ul></ul><div><br /></div><div>首先,將寫完的 .sol 放在 contracts 目錄,就可以執行 hardhat compile 進行編譯,編譯後檔案就會出現在 artifacts。</div><div><br /></div><div>接下來,要針對編譯的 artifacts 做佈署腳本,開一個檔案叫做 deploy.js 放在 scripts 目錄,寫道:</div><div><br /></div><p></p><pre class="brush: javascript;" name="code">const hardhat = require("hardhat");
const fs = require('fs');
async function main() {
// getContractFactory 會去 artifacts 找出你剛才編譯完的合約,名稱要跟 Solidity 一致才會被找到
const MyTokenContract = await hardhat.ethers.getContractFactory("MyToken");
// deploy() 等同於 .sol 中的 constructor,可以放建構子需要的參數,比方說某人的 address
// 例如 const mytoken = await MyTokenContract.deploy(XXXContract.address); // 放入其他已上鏈合約的 address
// 也可以是多個參數,取決於 solidity constructor 如何定
const mytoken = await MyTokenContract.deploy();
// 將此合約進行佈署
await mytoken.deployed();
console.log('deployed address', mytoken.address);
}
main()
.then(() => process.exit(0))
.catch(error => {
console.error(error);
process.exit(1);
});
</pre><div><br /></div><div><p></p><p>寫完之後,我們要佈署這個合約,如果有 testnet 可以直接跳過這關,如果想用 localhost,則需要開啟本地端 ETH RPC, Hardhat 有提供這項功能,直接執行指令:</p><p><br /></p><p>npx hardhat node</p><p><br /></p><p>就會開啟了。</p><p><br /></p><p>此時,要佈署合約,只需要使用指令:</p><p><br /></p><p>npx hardhat run ./scripts/deploy.js --network localhost</p><p><br /></p><p>他就會把合約佈署在第一個錢包上。</p></div><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">一個簡單的 js function, 上傳圖片到 IPFS</span></h3><p><br /></p><p>我們會直接使用 IPFS 測試鏈,infura 有提供這樣的測試鏈:</p><p><br /></p><pre class="brush: javascript;" name="code">import { create as ipfsHttpClient } from "ipfs-http-client";
const client = ipfsHttpClient("https://ipfs.infura.io:5001/api/v0");
async function uploadFileToIPFS(e) {
const file = e.target.files[0];
const ipfs_response = await client.add(file, {});
const url_with_token = `https://ipfs.infura.io/ipfs/${ipfs_response.path}`;
// 現在 IPFS 測試檔案已經在 IPFS 中,可以把它當作 TokenURI 送出了
}
</pre><div><br /></div><p>執行已上鏈合約的 Function,然後綁定 IPFS Token 到 ERC721</p><p><br /></p><pre class="brush: javascript;" name="code">import { ethers } from "ethers";
// 從編譯完的 artifacts 中引用合約資料
import MyToken from "../artifacts/contracts/MyToken.sol/MyToken.json";
const web3Modal = new Web3Modal();
const connection = await web3Modal.connect();
const provider = new ethers.providers.Web3Provider(connection);
const signer = provider.getSigner();
// 使用 ethers.js, 第一個要填已上鏈的合約地址,然後從 artifacts 取得合約的 ABI
let contract = new ethers.Contract("已上鏈的合約地址", MyToken.abi, signer);
// contract.createToken 是合約中的 function, 可以自行呼叫合約的 function
// playerAddress 是玩家的 address, url_with_token 是剛才的 IPFS 檔案位置,可以直接夾帶寫入執行 function
let transaction = await contract.awardItem(playerAddress, url_with_token);
// 此時,網頁外掛 metamask 會開始執行交易
let tx = await transaction.wait();
// 交易完成
</pre><div><br /></div><p><br /></p><p><br /></p><p>References:</p><p>https://www.quicknode.com/guides/solidity/an-overview-of-how-smart-contracts-work-on-ethereum</p><p>https://docs.ipfs.io/concepts/ipfs-gateway/#gateway-providers</p><p>https://docs.openzeppelin.com/contracts/4.x/erc721</p><p>https://eips.ethereum.org/EIPS/eip-2612</p><p>https://github.com/dapphub/ds-dach</p><p>https://github.com/graphprotocol/graph-node</p><p>https://medium.com/@austin_48503/tl-dr-scaffold-eth-ipfs-20fa35b11c35</p><p>https://etherscan.io/apis</p><p>https://github.com/ethereum/eips/issues/1155</p><p>https://eips.ethereum.org/EIPS/eip-3386</p><p>https://www.abmedia.io/what-is-erc-1155</p><p>https://nftschool.dev/tutorial/end-to-end-experience/#how-minting-works</p><p>https://ethereum.org/zh-tw/developers/docs/standards/tokens/erc-721/</p><p>https://dev.to/dabit3/building-scalable-full-stack-apps-on-ethereum-with-polygon-2cfb</p><p><br /></p><p><br /></p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushJScript.js" type="text/javascript"></script>
Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-4310337238955107412021-07-17T15:13:00.001+08:002021-07-17T15:13:10.815+08:00Elixir - Gen Server Hot Reloading<p>本篇記錄關於 Elixir 使用 Gen Server 做 Code Hot Reloading 的作法。<span></span></p><a name='more'></a><p></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Hot Reloading 在本章要表達的意義 ?</span> </h2><p><br /></p><p>對於本篇文章想表達的,就是在執行程式中途做程式碼變換,直接抽換 Function,這會是一個 Hot Patch 的行為,而且抽換時不會改變值或狀態、或執行緒本身。</p><p><br /></p><p>這個概念在許久以前 Erlang 就已經是這樣的特性了,可以看這個 Erlang Movie</p><p><a href="https://www.youtube.com/watch?v=xrIjfIjssLE">https://www.youtube.com/watch?v=xrIjfIjssLE</a></p><p><br /></p><p>影片中是製作一個電話的伺服器,而且可以在電話通話中、撥號中去做熱更新,而且不會讓人家斷線,基本上就達到了 Zero Downtime。</p><p><br /></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Gen Server / OTP Server</span></h2><p><br /></p><p>OTP 本身是具有 Gen Server 的<b>行為</b>模組,而 Gen Server 本身是管控 Process, Thread 的一個大型模組,Phoenix 和各種 Elixir 程式可能都會選擇使用 Gen Server 來維持應用程式常駐 Deamon。</p><p>甚至可以透過 Supervisor 來幫你維持 Gen Server Process 的生命週期,比方說死掉時幫你復活,但這個行為本身不會是同一個 PID 或原來的 Process 狀態。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">完成 Gen Server 實作</span></h2><p><br /></p><p>要使用 Gen Server,必須要實作一些函數,使得你的模組 (defmodule) 滿足 Gen Server 必要行為。</p><p><br /></p><p>這個操作可以額外地 (optional),在模組內宣告 @behaviour GenServer 來表示你會完成 GenServer 必要實作,這個就會很像是 implements Interface, 或是繼承 Interface, Abstract 等說法。</p><p><br /></p><p>本文章要實作的功能,是一個撥號伺服器,僅做撥號功能,而且是內線分機,假設我們的分機只會有三碼,所以在傳遞資料本身就用 a, b, c 的 map 元素表達。</p><p><br /></p><p></p><pre class="brush: bash;" name="code">defmodule InterphoneService do
# 表示本模組保證會實作 Gen Server 所有要素
@behaviour GenServer
# start_link 本身會讓 Supervisor 連結到這個模組
def start_link, do: GenServer.start_link(__MODULE__, [])
# 這個是表示初始狀態是一個 %{} 的空 MAP
# %{} is `state` init state
def init(_opts), do: {:ok, %{}}
# 表示被 GenServer 呼叫,且 Pattern Matching 到 {:dial, %{ :a=>x1, :b=>x2, :c=>x3 }} 而且是 map 的時候會執行
# input dial number or call out
def handle_call({:dial, digit_map}, __sender, state) when is_map(digit_map) do
# 這裡有三個回傳的內容
# 1. gen server 狀態, 2. 回傳的值, 3. state 要被更新的內容 (會直接更新 state)
{:reply, :dailing, digit_map}
end
# 如果上面那個 handle_call 沒有被 match 到,就回傳錯誤的號碼
# input dial number or call out
def handle_call({:dial, digit}, __sender, state) do
{:reply, :invalid_digits, %{}}
end
# 被呼叫 :cancel 的時候,就重設整個狀態
# reset dial call
def handle_call(:cancel, from, state) do
{:reply, %{}, %{}}
end
# 被 :reset 的時候,不需要回傳任何狀態,但會設定執行緒狀態為 %{}
def handle_cast(:reset, _state) do
{:noreply, %{}}
end
# 被停止的時候,直接讓整個 state 清空
def handle_cast(:stop, _state) do
{:stop, :normal, nil}
end
end
</pre><div><br /></div><div>直接放到 iex 執行它,你就有這個 defmodule 了,接著,還需要呼叫它出來看看:</div><div><br /></div><pre class="brush: bash;" name="code">{:ok, pid} = InterphoneService.start_link()
GenServer.call(pid,{:dial, %{ :a=>1, :b=>2, :c=>3}})
</pre><div><br /></div><div>現在,它應該會回傳 :dailing 告訴你撥號了。</div><div><br /></div><div>其中做法是先 start_link,然後讓 GenServer 用 call 這個方法,給出兩個參數:</div><div><ol style="text-align: left;"><li>告知是哪個 pid</li><li>告訴要用哪個 pattern matching 參數傳到 handle_call </li></ol></div><div><br /></div><div>其中,與 state 有關的變數,就是這個 Gen Server 被開出 Process 時的狀態變數 (它會存在記憶體的一個區域當作狀態) , 上述 handle_call 的三個內容,最後一個就會控制這個狀態變數要被更新成什麼。</div><div><br /></div><div><br /></div><div>而模組 defmodule 本身會放在 Heap。</div><div><br /></div><div><br /></div><div><br /></div><div><b><u>關於 Map Matching 小註解</u></b></div><div><br /></div><div><br /></div><div>在這裡的 %{ :a=>1 }, 使用 :a 是因為 map 的元素是 atom, 稍微在這裡做比較:</div><div><br /></div><div><ol style="text-align: left;"><li> %{ :a => 1 }</li><li> %{ a: 1 }</li><li> %{ "a": 1} *warning, 使用 : 是轉成 atom 的做法,盡量不要用字串</li><li> % { "a" => 1 }</li></ol><div>這裡有四個看起來很像的 map 作法,只有 4 完全不等價 (無法 matching) 到其他 1,2,3 的寫法。</div></div><div><br /></div><div>這是因為 1~3 都是在指你的 map key 為 atom 的狀況,使用 2,3 的冒號寫法,他則會將你的 key 自動轉成 :a 的形式出現。</div><div><br /></div><div>4 則是你的 key 為 string type,因此與 atom 不一樣。</div><div><br /></div><div><br /></div><div><br /></div><div><br /></div><p></p><h2 style="text-align: left;"><span style="font-size: x-large;">開始實作熱抽換 Hot Reloading #不暫停</span></h2><p><br /></p><p>我們想竄改一下剛才的 Hot Reloading ,讓被呼叫撥號時,可以顯示一些資訊出來,但目前撥號 Process 正在進行,有辦法做到嗎?</p><p><br /></p><p>現在,可以直接在同一個 iex 貼上同一個程式碼,就可以立即抽換 Function 了。</p><p><br /></p><p>抽換後,直接呼叫 call 同一個 pid,pid 也不會變,而且現在 pid 被呼叫的方法,變數 (state) 依然會存在。</p><p><br /></p><p>由於現在的目的是要顯示收到了什麼使用者傳來的值,所以直接在 handle_call 回傳前,多一個 IO.inspect 顯示值:</p><p><br /></p><pre class="brush: bash;" name="code">defmodule InterphoneService do
# 表示本模組保證會實作 Gen Server 所有要素
@behaviour GenServer
# start_link 本身會讓 Supervisor 連結到這個模組
def start_link, do: GenServer.start_link(__MODULE__, [])
# 這個是表示初始狀態是一個 %{} 的空 MAP
# %{} is `state` init state
def init(_opts), do: {:ok, %{}}
# 表示被 GenServer 呼叫,且 Pattern Matching 到 {:dial, %{ :a=>x1, :b=>x2, :c=>x3 }} 而且是 map 的時候會執行
# input dial number or call out
def handle_call({:dial, digit_map}, __sender, state) when is_map(digit_map) do
IO.inspect("----------------------------")
IO.inspect(state)
IO.inspect("----------------------------")
# 這裡有三個回傳的內容
# 1. gen server 狀態, 2. 回傳的值, 3. state 要被更新的內容 (會直接更新 state)
{:reply, :dailing, digit_map}
end
# 如果上面那個 handle_call 沒有被 match 到,就回傳錯誤的號碼
# input dial number or call out
def handle_call({:dial, digit}, __sender, state) do
{:reply, :invalid_digits, %{}}
end
# 被呼叫 :cancel 的時候,就重設整個狀態
# reset dial call
def handle_call(:cancel, from, state) do
{:reply, %{}, %{}}
end
# 被 :reset 的時候,不需要回傳任何狀態,但會設定執行緒狀態為 %{}
def handle_cast(:reset, _state) do
{:noreply, %{}}
end
# 被停止的時候,直接讓整個 state 清空
def handle_cast(:stop, _state) do
{:stop, :normal, nil}
end
end
</pre><div>以上是直接在 handle_call 加一個精美的 plot 顯示出東西,還會有 ------------ 夾在上下提示。</div><div><br /></div><div>直接拿到同一個 iex 執行後,直接呼叫:</div><div><br /></div><pre class="brush: bash;" name="code">GenServer.call(pid,{:dial, %{ :a=>1, :b=>2, :c=>3}})
</pre><div><br /></div><div>就會直接顯示出剛才想要顯示在終端機的變數。</div><div><br /></div><div><br /></div><p><br /></p><h2><span style="font-size: x-large;">開始實作熱抽換 Hot Reloading #暫停</span></h2><p><br /></p><p>很明顯的剛才這個撥號程式太廢了,用 map 存有敘的號碼也太反資料結構,而且也很反人類,此時此刻,你的 pid 上的 state 就算是被 dialing 後,還是存著 %{ a: 1, b: 2, c:3 } 這個詭異的結構在記憶體血脈中。</p><p><br /></p><p>如果想要將它直接熱抽換成使用陣列加減作法,勢必會直接出錯,因為 state 是 map,沒辦法直接適應 array。</p><p><br /></p><p>此時,Erlang 對熱抽換有 migration 的作法,可以讓你的 state 經過變遷,而你的 state 值會是用你轉型的結構處理。</p><p><br /></p><p>這個方法是在 defmodule 裡面多實作一個 code_change,所以,以下直接實作全部使用陣列、也有 code_change 的程式:</p><p><br /></p><pre class="brush: bash;" name="code">defmodule InterphoneService do
@behaviour GenServer
def start_link, do: GenServer.start_link(__MODULE__, [])
# [] is `state` init state
def init(_opts), do: {:ok, []}
# input dial number or call out
def handle_call({:dial, digit}, __sender, state) when is_integer(digit) do
IO.inspect(state)
digits = state ++ [digit]
if length(digits) != 3 do
{:reply, digits, digits}
else
{:reply, :dailing, []}
end
end
# input dial number or call out
def handle_call({:dial, digit}, __sender, state) do
{:reply, :invalid_digits, []}
end
# reset dial call
def handle_call(:cancel, from, state) do
{:reply, [], []}
end
def handle_cast(:reset, _state) do
{:noreply, []}
end
def handle_cast(:stop, _state) do
{:stop, :normal, nil}
end
# https://medium.com/blackode/how-to-perform-hot-code-swapping-in-elixir-afc824860012
# migrate from %{:a=>1, :b=>2, :c=>3} from :[] keyword-list to array
# %{ :a=>1, :b=>2, :c=>3} will be same as %{ a: 1, b: 2, c: 3}
def code_change(_old_vsn, %{ a: a, b: b, c: c} = old_state, _extra) do
IO.inspect("===========================")
{:ok, [a,b,c]}
end
end
</pre><p><br /></p><p>從這裡,可以看到每一個 %{} 都換成空 [],而且 handle_call 已經採用連續呼叫制,每次呼叫就傳一個要撥號內線的分機號碼順序。</p><p><br /></p><p>還有一個 code_change,它是一個 pattern_matching,而且它的參數是:</p><p></p><ol style="text-align: left;"><li>old_vsn <- 舊的版本號,如果有指定,那就會讓你配對到指定要的舊版本號字串;沒指定基本上就是對所有人都替換<br /><br /></li><li>old_state <- 該舊版本的狀態傳入,這也是一個 matching 指定樣板<br /><br /></li><li>extra <- 看是否有需要在轉換時多加一些參考值,讓開發者自行實作</li></ol><p></p><p><br /></p><p>我們可能會認為 code_change 預設觸發時機,就是抽換當下,但其實不是這樣的,當你貼上 iex 時這段 code_change 也不會被執行,除非你呼叫 :sys 底下的功能幫你對特定 pid 做這件事。</p><p><br /></p><p>而且,這個 pid 必須要被暫停,暫停不等於終結,狀態還是會存在,暫停的目的是避免繼續被傳入任何參數,導致之後 code_change 的 migrated 也跟有做沒做都一樣。</p><div><br /></div><pre class="brush: bash;" name="code">:sys.suspend pid
</pre><div><br /></div><div>現在,剛才這個 pid 被暫停了。</div><div><br /></div><div>接著,可以把剛才的程式碼整段貼上 iex ,然後呼叫 code_change。</div><div><br /></div><div><br /></div><div><pre class="brush: bash;" name="code">:sys.change_code pid, InterphoneService, "version-4", nil
</pre><div><br /></div></div><div>注意,這個 change_code 帶有 4 個參數:</div><div><ol style="text-align: left;"><li> 指定的 pid</li><li> 你剛才抽換的模組名稱</li><li> 你想給這個版本叫做什麼 (字串) ,不管的話就隨便給</li><li> extra 參考用資訊</li></ol></div><div><br /></div><div>一旦呼叫後,change_code 就會把 state 的 map 通通轉成 array [],然後,要把這個 pid 恢復才能用。</div><div><br /></div><div><div><br /></div><div><pre class="brush: bash;" name="code">:sys.resume pid
</pre><div><br /></div></div></div><div>恢復後,可以檢查一下 pid state:</div><div><br /></div><div><div><pre class="brush: bash;" name="code">:sys.get_state pid
</pre><div><br /></div></div></div><div>此時就會看到剛才的狀態已經被轉換了。</div><div><br /></div><div><br /></div><div>此時,打電話的方法就跟第一個不一樣了,如果你要撥內線 213 ,你就需要:</div><div><br /></div><div><br /></div><div><br /></div><div><pre class="brush: bash;" name="code">GenServer.call(pid,{:dial, 2})
GenServer.call(pid,{:dial, 1})
GenServer.call(pid,{:dial, 3})
</pre><div><br /></div></div><p>且第三個就會得到撥號的狀態了。</p><p><br /></p><p><br /></p><p><br /></p><p>References:</p><p>https://elixirschool.com/en/lessons/advanced/otp-concurrency/</p><p>https://erlang.org/doc/man/gen_server.html</p><p>http://erlang.org/pipermail/erlang-questions/2008-June/036243.html</p><p>https://blog.appsignal.com/2021/07/13/building-aggregates-in-elixir-and-postgresql.html</p><p>https://medium.com/blackode/how-to-perform-hot-code-swapping-in-elixir-afc824860012</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushBash.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/cfeduke/shBrushElixir/shBrushElixir.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-15841778329473273742021-06-27T17:27:00.003+08:002021-06-28T16:13:43.201+08:00Elixir Phoenix M | V -> C, Controller Pattern Matching, Repo written, Model relations and migrations<span><a name='more'></a></span><p>本章紀錄關於 Phoenix, Ecto 建立的架構,關於 Router 與 Controller 的 Pattern Matching 以及建立資料流的方式。</p><p><br /></p><p>注意,Phoenix 相關的架構都會有一定的時效性,本篇文章是在 2021 用 1.5.9 版,很有可能會遇到不同的寫法,但大致上核心理念是差不多的。</p><p><br /></p><p>Phoenix 這個角色本身就是一個 MVC 框架,然而它本身就自帶一些指令,可以幫助我們快速的建立好 CRUD API + HTML,而且建立的同時,會連帶建立出 Ecto 專用的資料定義。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">建立 Model with Relations</span></h3><p><br /></p><p>建立資料三大常用指令:</p><p>1. gen.html: 會建立出 CRUD HTML + Controller + Ecto CRUD Model API 以及 Ecto 資料定義</p><p>2. gen.context 只會建立出 Ecto CRUD Model API 以及 Ecto 資料定義</p><p>3. gen_schema 只會建立出 Ecto 資料定義 </p><p><br /></p><p>視情況看需不需要增加 View, Controller 內容來決定是否要用這三者其中哪者。</p><p><br /></p><p>首先,這篇文章要透過建立一個只有標題、內容的文章部落格,以及對每篇文章 (Post) 建立一些附屬的屬性來了解 Data Relations,藉由此 Examples 來變成一個規劃藍圖。</p><p><br /></p><p>關聯性規劃是這樣的:</p><p>*是 [關聯方式] + table 名稱</p><p>裡面唯有 belongs_to 會是 DB 資料定義 (foriegn_key),其餘的都會在程式中透過 Ecto 自動幫助你關聯這些資料 (join_through 就會拿自己的 id 去 many-to-many table 比較另外一個 data), has_many, has_one 則也是會拿自己的 id 去對應的 belongs_to 搜尋,只要透過 Repo.Preload 方法呼叫即可。</p><p></p><ul style="text-align: left;"><li>語言 Languages (一種語言) *languages</li><ul><li>有很多文章 *Has-many: posts</li></ul><li>文章 Posts (一篇文章) *posts</li><ul><li>有很多個類別 Categories *Many-To-Many join_through: post_categories</li><li>屬於一種語言 *belongs_to: language</li></ul><li>類別 Categories (一個類別) *categories</li><ul><li>有很多個文章 Posts *Many-To-Many join_through: posts_categories</li></ul><li>很多文章對應很多類別,很多類別對應很多文章 * Many-To-Many: posts_categories</li></ul><p></p><p><br /></p><h4 style="text-align: left;"><span style="font-size: large;">建立 Language</span></h4><p><br /></p><pre class="brush: bash;" name="code">mix phx.gen.html Languages Language languages name:string
</pre><p><br /></p><p>關於指令的欄位看法,有兩種記憶方式:</p><p>1. mix phx.gen.html [ecto api models 名稱] [struct 資料定義名稱] [資料庫 table 名稱] .....[各種定義]</p><p></p><p>2. mix phx.gen.html [複數] [單數] [資料表名稱(s複數)] .....[各種定義]</p><p><br /></p><p>優先建立 Language ,是因為 Post 會需要依賴這個 Language 當作 reference ,省下一點時間另外建立 Relations。</p><p><br /></p><h4 style="text-align: left;"><span style="font-size: large;">建立 Post</span></h4><p><br /></p><p>接著,要建立一個文章,文章建立雖然還沒有類別,但可以先把 reference 語言加上去。</p><p><br /></p><p></p><pre class="brush: bash;" name="code">mix phx.gen.html Posts Post posts title:string content:string language_id:references:languages
</pre><p></p><p><br /></p><p>修改 Posts 與 Languages 關聯性</p><p>依照剛才規劃的關聯性表,在 /lib/hello/posts/post.ex,修改成這樣的欄位:</p><p></p><pre class="brush: bash;" name="code">defmodule Hello.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "post" do
field :content, :string
field :title, :string
# field :language, :id # 替換成下方的方式
# 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
belongs_to :language, Hello.Languages.Language, foreign_key: :language_id
timestamps()
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :content]) # 如果有那些欄位要從 controller 或 ecto model api 加進去,比方說 language_id,那就要在這裡多寫 -> cast(attrs, [:title, :content, :language_id]) 不然他不會新增進去資料庫喔
|> validate_required([:title, :content]) # 這裡是新增時必要的欄位,如果不再欄位或是資料有少就會噴錯誤告訴你此欄位不能留空
end
end
</pre><div><br /></div><div>Post 已經有建立關聯了,也要把 Language 對應關聯性加上去:</div><div><br /></div><p>/lib/hello/languages/language.ex:</p><p></p><pre class="brush: bash;" name="code">defmodule Hello.Languages.Language do
use Ecto.Schema
import Ecto.Changeset
schema "languages" do
field :name, :string
# 注意這裡是複數個文章 + s,後面帶入 Post 的 struct 結構
has_many :posts, Hello.Posts.Post
timestamps()
end
@doc false
def changeset(language, attrs) do
language
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
</pre><div><br /></div><div>如此一來,我們就建立了 Language 與 Post 關聯性,使用 has_many, belongs_to 的方式進行,但在此我們也可以了解一下 migrations 的資料長怎樣:</div><div><br /></div><div>hello/priv/repo/migrations/20210626154632_create_post.exs:</div><p></p><pre class="brush: bash;" name="code">defmodule Hello.Repo.Migrations.CreatePost do
use Ecto.Migration
def change do
create table(:post) do
add :title, :string
add :content, :string
# 列為參考 foreign_key 定義
add :language_id, references(:languages, on_delete: :nothing)
timestamps()
end
create index(:post, [:language_id]) # foreign_key 的 index 要放進來
end
end
</pre><div><br /></div><h4 style="text-align: left;"><span style="font-size: large;">建立 Category</span></h4><p><br /></p><p>一篇文章有對應很多個 Category,因此在這裡就會碰到一個 Many-To-Many 關聯性的需求出現,設計 many-to-many 的步驟是:</p><p></p><ol style="text-align: left;"><li>多一張表,存放兩個資料之間的 id,表的名稱會是: [資料1 複數 s]_[資料 2 複數 s]<br />像是這個 chapter 的例子就是: categories_posts。</li><li>手動建立 ecto migration ,自己產生一個表 (不含在任何定義中)</li><li>兩個資料雖然沒有 foreign_key 但是可以寫 associations 的 many_to_many 關聯性定義</li></ol><p></p><p><br /></p><p>所以,現在要先建立 category,再來說 many_to_many 要怎樣建立。</p><p><br /></p><pre class="brush: bash;" name="code">mix phx.gen.html Categories Category categories name:string
</pre><p><br /></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p>這裡只是很單純的建立一個 category,什麼關聯性 references 都不用寫,因為不需要。</p><p><br /></p><p>接著,要開始設計 Many To Many 表了,要先用 ecto 的指令建立出 migration 的腳本,然後自己編輯新增:</p><p><br /></p><pre class="brush: bash;" name="code">mix ecto.gen.migration create_categories_posts</pre><p><br /></p><p>編輯生產出來的 migration 腳本 hello/priv/repo/migrations/20210626160659_create_categories_posts.exs:</p><p></p><pre class="brush: bash;" name="code">defmodule Hello.Repo.Migrations.CreateCategoriesPosts do
use Ecto.Migration
def change do
# 雙複數,建立一張表
create table(:categories_posts) do
add :category_id, references(:categories) # 注意這裡 references 到的是複數 s
add :post_id, references(:posts) # 注意這裡 references 到的是複數 s
end
# 建立單獨的 index, 可 unique 是因為一個文章不會重疊相同的 category 超過 1 次,e.g: #美食 #美食 #美食
create unique_index(:categories_posts, [:category_id, :post_id])
end
end
<br /></pre><div>然後,再回到 lib 中看看這兩個表之間的定義要怎樣加入關聯性定義描述。</div><div><br /></div><div>/lib/hello/posts/post.ex:</div><p></p><pre class="brush: bash;" name="code">defmodule Hello.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :content, :string
field :title, :string
# 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
belongs_to :language, Hello.Languages.Language, foreign_key: :language_id
# 新增多對多關聯性定義
# 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
many_to_many :categories, Hello.Categories.Category, join_through: "categories_posts"
timestamps()
end
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :content])
|> validate_required([:title, :content])
end
end
<br /></pre><div>再看看 category :</div><div>/lib/categories/category.ex:</div><p></p><pre class="brush: bash;" name="code">defmodule Hello.Categories.Category do
use Ecto.Schema
import Ecto.Changeset
schema "categories" do
field :name, :string
# 新增多對多關聯性定義
# 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
many_to_many :posts, Hello.Posts.Post, join_through: "categories_posts"
timestamps()
end
@doc false
def changeset(category, attrs) do
category
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
</pre><div><br /></div><div>所以由此就知道,many to many 兩個要加入的關聯性一模一樣,只是互相用的人不同。</div><div><br /></div><div><br /></div><div>要針對這個 Many To Many 做簡易測試,則是用 SQL 搭配 iex 互動命令確認剛才定義的 struct relation 中,在 iex 是否有 preload 出來資料。</div><div><br /></div><div>測試 SQL (還沒有講到程式建立的部分,於是手動建立):</div><div>INSERT INTO categories(name, inserted_at, updated_at) VALUES('Programming', NOW(), NOW()); </div><div>INSERT INTO categories(name, inserted_at, updated_at) VALUES('Kitchen', NOW(), NOW());</div><div>INSERT INTO posts(title, content,inserted_at, updated_at) VALUES('This is a Programming book', 'Book content', NOW(), NOW());</div><div>INSERT INTO posts(title, content,inserted_at, updated_at) VALUES('This is a Kitchen book', 'Book content', NOW(), NOW());</div><div>INSERT INTO categories_posts(category_id, post_id) VALUES(2, 1);</div><div>INSERT INTO categories_posts(category_id, post_id) VALUES(3, 1);</div><div><br /></div><div><br /></div><div>建立好後,用 iex -S mix 測試:</div><p></p><pre class="brush: bash;" name="code">alias Hello.Categories
alias Hello.Repo
Categories.get_category!(2) # 發現 post 寫 not loaded
Categories.get_category!(2) |> Repo.preload([:posts]) # 發現 posts 都被讀到了
</pre><div><br /></div><div>於是,實際 many to many 的建立方式並不複雜,僅此而已。</div><div><br /></div><p><br /></p><h4 style="text-align: left;"><span style="font-size: large;">Ecto Model API 層的操作法</span></h4><p><br /></p><p>什麼是 Ecto Model API? 在 Phoenix 專案架構中,可以透過以下結構說明來理解 lib 有什麼:</p><p></p><ul style="text-align: left;"><li>lib</li><ul><li>hello <- 很單純的資料定義、資料操作,資料操作就是 Ecto Model API</li><ul><li>(目錄) categories</li><ul><li>category.ex <- 資料定義 (資料庫、changeset、插入資料要檢查、轉型)</li></ul><li>(目錄) languages</li><ul><li>language.ex <- 資料定義</li></ul><li>(目錄) posts</li><ul><li>post.ex <- 資料定義</li></ul><li>(檔案) categories <- Ecto Model API: 包含 CRUD 操作、自訂操作</li><li>(檔案) languages <- Ecto Model API</li><li>(檔案) posts <- Ecto Model API</li><li>....略</li></ul><li>hello_web <- Controller, Views 以及 Router 各種 web 物件都放在這裡</li><ul><li>...略</li></ul></ul></ul><p></p><p><br /></p><p>由此大致可知,你的 Model 單數為檔名的檔案,會用來處理資料庫定義、資料轉換 (如明文密碼轉 hash、cast、changeset)。</p><p>複數為檔名的檔案,會用來處理 Repo 執行、查詢、寫 query 查詢、或做外部資料處理、read file、write file、send email、send sms、add queue job 等等。</p><p>在資料定義中,如果是寫 accounts 等帳號密碼使用者定義,還可以找到有 field 含有 virtual: true 屬性,讓資料不會寫進資料庫,而是要透過 changeset 之前,把 virtual 自己做密碼 hash,然後傳到真正的資料庫欄位,再寫入。</p><p>比方說帳號密碼會是這樣做:</p><p></p><ul style="text-align: left;"><li>field passowrd, :string, virtual: true < 不會寫入資料庫,待轉換成 hash</li><li>field password_hash, :string <- 會寫入資料庫,不過你要自己轉換寫入</li></ul><p></p><p><br /></p><p><br /></p><h4 style="text-align: left;"><span style="font-size: large;">在 Model API 中依照 Category id 列出 Post (關聯性操作)</span></h4><p><br /></p><p>在 /lib/hello/posts.ex 檔案中,下面加入一個 function:</p><p></p><pre class="brush: bash;" name="code">def list_post_by_category(category_id) do
# ^ pin 運算子是為了讓 category_id 變數變成一個確切固定的顯值,而且再也不會變動,已不是"變"數
query = from p in Post,
join: cp in "categories_posts",
on: cp.category_id == ^category_id,
distinct: p.id, # 選取不重複的 post id
select: p
Repo.all(query)
end
</pre><p>然後,在 iex -S mix 中,就可以這麼呼叫:</p><p></p><pre class="brush: bash;" name="code">Hello.Posts.list_post_by_category(2)
</pre><div><br /></div><p><br /></p><h4 style="text-align: left;"><span style="font-size: large;">建立 Many-To-Many 的 Category 與 Post 綁定</span></h4><p><br /></p><p>要幫 Post 加上各種 category 分類,可以這麼做:</p><p>在 /lib/hello/posts.ex 檔案中,在 create_post 這附近做修改:</p><p></p><pre class="brush: bash;" name="code">def create_post(attrs \\ %{}) do
%Post{}
|> Post.changeset(attrs)
|> Repo.insert()
end
# 新增
# multiple select
def bind_post_categories(post_id, category_ids) do
for c_id <- category_ids do
Repo.insert_all "categories_posts", [ %{
"category_id"=> c_id,
"post_id" => post_id
} ], returning: [:id]
end
# 另一總做法也可以用 Enum.map()
# bind_post_with_categories = Enum.map(category_ids, fn c_id ->
# { category_id: c_id, post_id: post_id}
# end)
#然後丟到 insert_all 後面那個陣列
end
</pre><div><br /></div><h3 style="text-align: left;"><span style="font-size: large;">讓 Language id 預設可以帶入 Post 一起新增進資料庫</span></h3><div><br /></div><div>關鍵點不是在修改 Ecto Model API,而是在 Model 處理,要修改的檔案是</div><div>/lib/hello/posts/post.ex:</div><p></p><pre class="brush: bash;" name="code">defmodule Hello.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :content, :string
field :title, :string
# 注意第一個參數是單數,然後接著給的是 struct 結構,然後告訴這個 db 要把 post 的外來鍵命名為 language_id
belongs_to :language, Hello.Languages.Language, foreign_key: :language_id
# 新增多對多關聯性定義
# 注意這裡用的是複數, 第二個參數要給定 struct 結構,然後告訴 ecto 你要透過 categories_posts 這張表加入這筆資料
many_to_many :categories, Hello.Categories.Category, join_through: "categories_posts"
timestamps()
end
@doc false
def changeset(post, attrs) do
# 新增 cast, validate_required
post
|> cast(attrs, [:title, :content, :language_id])
|> validate_required([:title, :content, :language_id])
end
end
</pre><div><br /></div><div>主要是需要新增 cast, validate_required 中的部分,強制呼叫要送資料去新增時,要記得驗證欄位是否有值。</div><div><br /></div><div><br /></div><div><br /></div><h4 style="text-align: left;"><span style="font-size: large;">Phoenix Controller / Views</span></h4><p><br /></p><p>首先,要先針對剛才 generate 出來的 html 做套用到 router 上才會看到視覺化的結果,要修改的是 lib/hello_web/router.ex:</p><p></p><pre class="brush: bash;" name="code">scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
resources "/posts", PostController
resources "/categories", CategoryController
resources "/languages", LanguageController
end
</pre><div>在這邊要加上三段 resources 可以在網址 localhost:4000/posts, /categories, /languages 看到 CURD</div><div><br /></div><div>現在,需要關注 /posts/new 新增文章時的行為,在此處看到的東西只有 title, content 兩個欄位,而現在還希望帶入 categories, language select 框框選擇,該如何做?</div><div><br /></div><div>-> 修改 Controller ,讓某些 router 會帶入這兩個東西的資料,然後在 view 的 html.eex 檔案中就可以用 @____ 得到變數。</div><div><br /></div><div><br /></div><div>也許一般來說可以從 controller 各個畫面的 controller 獨立查詢,但現在有更好的做法,就是用 plug,讓特定行為的 action 進入時,預先載入 function (也可稱作 middleware 吧)。</div><div><br /></div><div>/lib/hello_web/controllers/post_controller.ex:</div><div><br /></div><div>這串要加在 def index(... 之前</div><p></p><pre class="brush: bash;" name="code">alias Hello.Categories
alias Hello.Languages
# 在 :new, :edit 的時候,查詢一下 languages 列表
# :loca_categories 是對應到 load_categories 這個 func
plug :load_languages when action in [:new, :edit]
# 在 :new, :edit 的時候,查詢一下 categories 列表
# :loca_categories 是對應到 load_categories 這個 func
plug :load_categories when action in [:new, :edit]
defp load_languages(conn, _) do
languages = Languages.list_languages()
conn
|> assign(:languages, languages)
end
defp load_categories(conn, _) do
categories = Categories.list_categories()
conn
|> assign(:categories, categories)
end
</pre><div>在這裡要注意的是 plug 是有順序性的。</div><p><br /></p><p>現在,在 /posts/new 中雖然還沒有東西,但現在就要加上去了,在 /lib/hello_web/templates/post/form.html.eex 這個檔案看一下:</p><p>為什麼要看這個檔案? 因為原本 controller 呼叫的是 /lib/hello_web/templates/post/new.html.eex,可是這個檔案內部有呼叫渲染表單出來。</p><p><br /></p><p>對這個檔案新增兩個選項出來</p><p>/lib/hello_web/templates/post/form.html.eex:</p><p></p><pre class="brush: bash;" name="code"><%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= label f, :title %>
<%= text_input f, :title %>
<%= error_tag f, :title %>
<%= label f, :content %>
<%= text_input f, :content %>
<%= error_tag f, :content %>
<!-- Enum.map(@languages, &{&1.name, &1.id}) 這個用法是把它變成 { name:XXX, id: XXX } 形式列成列表, & 是 fn x, x 簡化為值的本身,就變成用 & 呼叫-->
<%= label f, :language_id %>
<%= select f, :language_id, Enum.map(@languages, &{&1.name, &1.id}), prompt: "Choose Language" %>
<%= error_tag f, :language_id %>
<!-- Enum.map(@categories, &{&1.name, &1.id}) 這個用法是把它變成 { name:XXX, id: XXX } 形式列成列表, & 是 fn x, x 簡化為值的本身,就變成用 & 呼叫-->
<%= label f, :category_ids %>
<%= multiple_select f, :category_ids, Enum.map(@categories, &{&1.name, &1.id}), prompt: "Choose Category" %>
<%= error_tag f, :category_ids %>
<div>
<%= submit "Save" %>
</div>
<% end %>
</pre><div><br /></div><p>接著,需要修改 Controller 接收到額外這兩個參數要做什麼事,首先, language_id 不需要做任何事,因為 Model Struct 中早就會做 cast 把 language_id 轉換,問題應該就會在 categories 要新增出來,怎麼做。</p><div>/lib/hello_web/controllers/post_controller.ex:</div><p></p><pre class="brush: bash;" name="code">def create(conn, %{"post" => post_params}) do
# 把字串轉換為 int
# ["1", "2"] -> [1, 2]
category_ids_int = Enum.map(post_params["category_ids"], fn str_id ->
{int_id, _p} = Integer.parse(str_id)
int_id # 回傳
end)
case Posts.create_post(post_params) do
{:ok, post} ->
# 在這裡做文章 category 新增
Posts.bind_post_categories(post.id, category_ids_int)
conn
|> put_flash(:info, "Post created successfully.")
|> redirect(to: Routes.post_path(conn, :show, post))
{:error, %Ecto.Changeset{} = changeset} ->
render(conn, "new.html", changeset: changeset)
end
end
</pre><div><br /></div><p>如此一來,在 /posts/new 就可以看到各種選項可以被新增了。</p><p><br /></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">列出分類的文章</span></h3><p><br /></p><p>大致上,就是要讓 Categories 頁面可以 show 出只有包含 category_id 的 post 頁面。</p><p><br /></p><p>在這裡,嘗試兩種做法來顯示 category:</p><p></p><ol style="text-align: left;"><li>Query: ?category_id=1 在 Controller 中做 Pattern Matching</li><li>Routing: /posts/cate/:category_id 在 Controller 讀取</li></ol><p></p><p><br /></p><p><span style="font-size: large;">作法 1</span></p><p><br /></p><p>這個方法希望用 /posts?category_id=1 這個方式來顯示,他的做法是在 post_controller.ex 建立 pattern matching 的 index function:</p><p></p><pre class="brush: bash;" name="code"># 注意 pattern matching 有順序性,這個有比對值得要先
def index(conn, %{ "category_id" => category_id }) do
# 除錯時可以看到他會不會進來 pattern matching
IO.puts("=============================================")
IO.inspect(category_id)
IO.puts("=============================================")
# 字串的 categroy_id 應該要轉成數字
{ category_id_int, _p } = Integer.parse(category_id)
post = Posts.list_post_by_category(category_id_int)
render(conn, "index.html", post: post)
end
# 預設不 care params 的要放後面
def index(conn, _params) do
post = Posts.list_post()
render(conn, "index.html", post: post)
end
</pre><div><br /></div><p>注意,pattern matching 要加在 index 預設值的上方,才有可能會 fall-in,關於 pattern matching,也可以想成是 switch case,如果你提早進入 case 如果你沒有繼續讓 case 往下做,那下面的 case 也不會被 matching 到。</p><p>在這裡的例子,你可以想像你的 switch case 的 default 比 case 提早寫,所以會直接進入 default。</p><p>直接在網址找: /posts?category_id=2 ,就可以發現他會過濾文章了,而且不加的時候,可以看到第一個 matching 完全不會進去,看終端機有沒有 ===== 就知道了。</p><p><br /></p><p>然後,還希望可以從 /categories 這個頁面可以連過來這個網站還要附加參數,要怎麼做?</p><p>在 /lib/hello_web/templates/cateroy/index.html.eex 中,新增一個 link:</p><p></p><pre class="brush: bash;" name="code"><%= for category <- @categories do %>
<tr>
<td><%= category.name %></td>
<td>
<span><%= link "Show", to: Routes.category_path(@conn, :show, category) %></span>
<span><%= link "Edit", to: Routes.category_path(@conn, :edit, category) %></span>
<span><%= link "Delete", to: Routes.category_path(@conn, :delete, category), method: :delete, data: [confirm: "Are you sure?"] %></span>
<!-- 注意這裡的 routes 是單數, Routes.____ 有哪些,可以用 mix phx.routes 指令看到 -->
<!-- 注意,方法一是用 query stirng 當作參數, post_path 最後一個參數要用 map %{ ... } -->
<span><%= link "Posts", to: Routes.post_path(@conn, :index, %{ "conference_id" => category.id }) %></span>
</td>
</tr>
<% end %>
</pre><div><br /></div><div>第二個參數 :index, :sohw, :edit 仔細一猜,就可以發現那些都是 Controller Function 名稱。</div><p>第三個參數,如果在這裡都不加,就等於是找原始沒有 query string pattern matching 的 controller function。</p><p><br /></p><p><span style="font-size: large;">作法 2</span></p><p><br /></p><p>作法二要嘗試的是新增到 Router 去,看能不能用 url params 處理,像是這樣:</p><p>/posts/cate/:category_id</p><p>由於上面已經用過 Pattern Matching 而且在這裡的做法會一模一樣,因此這裡想換成讓 Router 去執行特定的 Controller: list_by_category。</p><p>直接新增到 post_controller:</p><p></p><pre class="brush: bash;" name="code">def list_by_category(conn, %{ "category_id" => category_id }) do
# 除錯時可以看到他會不會進來 pattern matching
IO.puts("=============================================")
IO.inspect(category_id)
IO.puts("=============================================")
# 字串的 categroy_id 應該要轉成數字
{ category_id_int, _p } = Integer.parse(category_id)
post = Posts.list_post_by_category(category_id_int)
render(conn, "index.html", post: post)
end
</pre><div><br /></div><p>這個 function 跟 index 是完全一模一樣的。</p><p><br /></p><p>現在,要在 router.ex 中新增一個 router 進來:</p><p></p><pre class="brush: bash;" name="code">scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
resources "/posts", PostController
resources "/categories", CategoryController
resources "/languages", LanguageController
# 新增
get "/posts/cate/:category_id", PostController, :list_by_category
end</pre><p><br /></p><p>不過此刻,在 /categories 頁面中,要跳轉過來的方式就完全不同,需要注意 Routes.xxx_path 是不同的,請注意 /lib/hello_web/templates/cateroy/index.html.eex 會像這樣:</p><p></p><pre class="brush: bash;" name="code"><!-- 注意,方法二是用 url params 當作參數, post_path 最後一個參數要依序使用單獨參數 -->
<!-- 換言之,如果你的 rotuer 是 /cate/:category_id/:a/:b ,那你也必須給成: -->
<!-- Routes.post_path(@conn, :list_by_category, category.id, "a", "b" ) -->
<span><%= link "Posts", to: Routes.post_path(@conn, :list_by_category, category.id ) %></span></pre><p><br /></p><p>然後,在 /categories/ 就可以看到點擊按鈕,會跳到 /posts/cate/2 這樣的 router。</p><p><br /></p><p>Pipeline 小記:</p><p>個人的見解,對於很多 Functions work 呼叫都會有一系列的整合 Function,例如 Controller 本身會做很多的事,而為了 keep code dry,把很多功能拆成 function ,然後在組合的時候,用 pipeline (|>) 把它們的工作串接再一起,也就是用來掩飾複雜工作的一種用法。</p><p><br /></p><p>Reference:</p><p>http://blog.plataformatec.com.br/2016/05/ectos-insert_all-and-schemaless-queries/<br />https://stackoverflow.com/questions/44879027/how-to-make-scaffold-for-two-entities-relations-with-elixir-phoenix</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushBash.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/cfeduke/shBrushElixir/shBrushElixir.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-87719233979493829002021-05-25T22:12:00.003+08:002021-07-04T11:56:05.003+08:00Building GraphQL pattern with Ecto (II) - GraphQL Part<p><br /><span></span></p><a name='more'></a><p></p><p>API Interface 有非常多種,除了常見的 REST API,還有 WebSocket, gRPC, (底層的) DLL Interop, Protobuf, TCP Sockers, UDP, WebTransport, GraphQL...etc ,GraphQL 就是其中一種通訊界面,在 REST API 中,是透過 Request, Response Body 來解析資料,在 GraphQL 中是提早定義好要呼叫的 3 種型態: Query, Mutation, Subscription ,呼叫方式如果帶有 Params,就會放在呼叫 Endpoint 的 Params 中,然後嚴格要求 Server 按照 GraphQL Schema 定義給出資料,否則就會出錯,因此在 GraphQL 框架這端,就幫忙做到了驗證。</p><p>本篇並不會細說 GraphQL 真正使用方式,而是要記錄如何使用 Elixir Build 一個 GraphQL Project。</p><p><br /></p><h2 style="text-align: left;">Where's the Pattern?</h2><p><br /></p><p>GraphQL 與 Phoenix 和 Ecto 的關係,基本上,Phoenix 提供整套建置方案,可以從建 Schema,到查詢資料,都用 Phoenix 指令幫你產生 Ecto 的物件們,GraphQL 的 Absinthe 則是繼承在 Phoenix API 上的一個 application。</p><p>一如往常,只需要一個連入的 api endpoint,像是: /query。</p><p><br /></p><p><br /></p><h2 style="text-align: left;">從純 Phoenix 專案建立 GraphQL</h2><p><br /></p><p>有三件事要做,讓 Phoenix 直接變成 GraphQL:</p><p>1. mix.ex 中,加入 Absinthe 套件</p><p></p><pre class="brush: elixir;" name="code">defp deps do
[
{:absinthe, "~> 1.6"},
{:absinthe_plug, "~> 1.5"},
{:jason, "~> 1.1"},</pre><p></p><p><br /></p><p>2. 建立 Schema.ex</p><p>Absinthe 的 GraphQL Schema 不需要自己手寫 GraphQL ,而是透過 Elixir 本身的 meta-programming 去實現,Absinthe 也會自動幫你產生 GraphQL Schema。</p><p><br /></p><p>這個 Schema 預設沒有,要在 /drent/lib/drent_web/ 下建立一個檔案: /drent/lib/drent_web/schema.ex。</p><p><br /></p><pre class="brush: elixir;" name="code"># 定義這個 Schema 模組
defmodule DrentWeb.Schema do
# 使用 Absinthe.Schema 所有的內容
use Absinthe.Schema
# 定義 GraphQL Object Struct 的型態
# non_null 就是 !,像是 id: Int!
# non_null(list_of(non_null(:string))) 就是: [String!]!
object :staff do
field :id, non_null(:id)
field :fullname, non_null(:string)
end
# 定義 Query 所有的 Resolvers
query do
end
# 定義 Mutation 所有的 Resolvers
mutation do
end
end</pre><div><br /></div><p>3. API 加上 GraphQL Entry Point</p><p>在 /drent/lib/drent_web/router.ex 中,直接新建某個 scope,然後給予 GraphQL 進入點:</p><p><br /></p><pre class="brush: elixir;" name="code">scope "/" do
pipe_through :api
forward "/graphiql", Absinthe.Plug.GraphiQL,
schema: DrentWeb.Schema, # Schema 模組 (defmodule) 確切位置
interface: :simple, # 簡易模式,可以改 :advenced
context: %{pubsub: DrentWeb.Endpoint}
end</pre><p><br /></p><p>這麼一加完,在指令處執行:</p><br /><pre class="brush: bash;" name="code">mix phx.server</pre><p><br /></p><p>打開: localhost:4000/graph<span style="background-color: #fcff01; color: red;">i</span>ql (注意是 i ql),就會看到 gql 操作介面。</p><p><br /></p><h2 style="text-align: left;">建立 Schema</h2><p><br /></p><p>剛才的 Schema 中的 object 就是每一個單結構的定義,如果要新增兩個不同的 Struct ,則可以這麼寫:</p><p><br /></p><pre class="brush: elixir;" name="code">object :staff do
field :id, non_null(:id) # gql: id ID!
field :fullname, non_null(:string) # gql: fullname String!
end
object :test do
field :testname, :string # gql: testname String (可為空)
end</pre><p><br /></p><p>建立 Query, Mutation Resolvers</p><p><br /></p><p>就只用來操作 Staff 的 CRUD,直接在 /drent/lib/drent_web/ 新增一個 resolvers 資料夾,變成: /drent/lib/drrent_web/resolvers,然後底下新增一個檔案叫做 StaffResolver.ex,裡面是:</p><p><br /></p><pre class="brush: elixir;" name="code"># 定義 Resolver 的模組路徑
defmodule DrentWeb.StaffResolver do
alias Drent.Users
# 取得所有 staff
def all_staff(_root, _args, _info) do
# phoneix 指令是建立在 User 下, staff, profile 的東西都在 Users 裡,故 alias Drent.Users
# 而不是 alias Drent.Users.Staff
{:ok, Users.list_staffs()}
end
# 取得單一 staff
# 假設傳進來的 args 是: %{ id: 1 }
def get_staff(_root, args, _info) do
# 直接開啟 case pattern-match 決定要回傳什麼
case Users.get_staff!(args.id) do
nil ->
{:error, "EMPTY"}
staff ->
{:ok, staff}
end
end
# 刪除 staff
def remove_staff(_root, args, _info) do
Users.delete_staff(%Users.Staff{ id: args.id })
{:true}
end
# 更新 staff 的名字 (用上一集寫過的 ecto)
def rename_staff(_root, args, _info) do
Users.rename_staff_by_id(args.id, args.fullname)
{:true}
end
end
</pre><div><br /></div><p>在這邊,在最後的 rename_staff 還沒有新增過 Users 有這個功能,於是複製上一篇最後一個 rename 功能,在 /drent/lib/drent/users.ex 這個外部的 檔案,結尾處內加入這個 def function:</p><p><br /></p><pre class="brush: elixir;" name="code">def rename_staff_by_id(%Staff{} = staff, _atttrs \\ %{}) do
Repo.update(Ecto.Changeset.cast(
%Staff{ id: staff.id },
%{ "fullname" => staff.fullname },
[ :fullname ]
))
end</pre><p><br /></p><p>這麼一來, StaffResolver.ex 就可以使用這個功能了。</p><p><br /></p><p>最後,我們需要新增這些 Resolver 操作到 schema.ex 中:</p><p><br /></p><pre class="brush: elixir;" name="code"># 定義這個 Schema 模組
defmodule DrentWeb.Schema do
# 使用 Absinthe.Schema 所有的內容
use Absinthe.Schema
@desc """
這像是:
type Staff {
id: ID!
fullname: string!
}
"""
object :staff do
field :id, non_null(:id)
field :fullname, non_null(:string)
end
# 引用其他 Resolver 模組
alias DrentWeb.StaffResolver
# 定義 Query 所有的 Resolvers
query do
@desc """
Get all staffs
這像是:
type Query{
query get_all_staff(): [Staff!]!
}
"""
field :get_all_staff, non_null(list_of(non_null(:staff))) do
# 回傳這個 Resolver 裡面的某個 Function 回去,這是回傳 Function 本身
# 是給別人去執行這個 Function 的 Link,通常後面 /3 是指要找多少參數的 Func
resolve &StaffResolver.all_staff/3
end
@desc """
Get specific staff by id. (注意可為 null)
這像是:
type Query {
query get_staff_by_id(id: ID!): Staff
}
注意, arg 這個才是 func 裡面的參數, arg 也可以很多個,像是:
arg :id, non_null(:id)
arg :fullname, :string
gql 就是
query get_staff_by_id(id: ID!, fullname: String): Staff
"""
field :get_staff_by_id, :staff do
arg :id, non_null(:id)
resolve &StaffResolver.get_staff/3
end
end
# 定義 Mutation 所有的 Resolvers
mutation do
@desc """
Remove Specific Staff by id
gql:
type Mutation {
remove_staff_by_id(id: ID!): Boolean!
}
"""
field :remove_staff_by_id, non_null(:boolean) do
arg :id, non_null(:id)
resolve &StaffResolver.remove_staff/3
end
@desc """
Update Specific Staff Name
gql:
type Mutation {
rename_staff_by_id(id: ID!, fullname: String!): Boolean!
}
"""
field :rename_staff_by_id, non_null(:boolean) do
arg :id, non_null(:id)
arg :fullname, non_null(:string)
resolve &StaffResolver.rename_staff/3
end
end
end
</pre><div><br /></div><div><br /></div><div>GraphQL 操作測試 - 所有 Staff:</div><pre class="brush: bash;" name="code">{
getAllStaff{
id
fullname
}
}</pre><p><br /></p><p>GraphQL 操作測試 - 單一 Staff:</p><pre class="brush: bash;" name="code"># 這裡的 $id 是從 query variables 的 key 來的
query queryStaff($id: ID!){
# 這裡放的是從 query variables 帶來的東西
getStaffById(id: $id){
id
fullname
}
}
</pre><div><br /></div><div>在 query variables 打開,放入:</div><div><pre class="brush: bash;" name="code">{
"id": 1
}
</pre><div><br /></div></div><div><br /></div><div><br /></div><div><br /></div><div>GraphQL 操作測試 - 更名 Staff:</div><pre class="brush: bash;" name="code">mutation renameStaffById($id: ID!, $fullname: String!){
renameStaffById(id: $id, fullname: $fullname)
}
</pre><div>在 query variables 打開,放入:</div><div><br /></div><div><pre class="brush: bash;" name="code">{"id": 1, "fullname": "FOX V3"}</pre></div><div><br /></div><div><br /></div><div><br /></div><div>GraphQL 操作測試 - 刪除 Staff:</div><pre class="brush: bash;" name="code">mutation removeStaff($id: ID!){
removeStaffById(id: $id)
}
</pre><div><div>在 query variables 打開,放入:</div><div><pre class="brush: bash;" name="code">{"id": 1}</pre></div></div><div><br /></div><div>刪不掉可能是正常的,因為 Staff 有被其他表 Constraint ,所以可能刪不掉。</div><div><br /></div><div><br /></div><p>References:</p><p>https://cloud-trends.medium.com/grpc-vs-restful-api-vs-graphql-web-socket-tcp-sockets-and-udp-beyond-client-server-43338eb02e37<br />https://s.itho.me/cloudsummit/2020/slides/7034.pdf<br />https://www.howtographql.com/graphql-elixir/1-getting-started/</p><p><br /></p><p><br /></p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushBash.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/cfeduke/shBrushElixir/shBrushElixir.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-26946745522457178492021-05-24T22:49:00.003+08:002021-05-24T22:49:36.033+08:00Building GraphQL pattern with Ecto (I) - Ecto Part<span><a name='more'></a></span><p>本篇文章是對個人學習 Elixir 之路做的紀錄,學習過程中有點小顛坡,從 2021-01-01 開始正式學習 Elixir 生態,過程中都是看<a href="https://pragprog.com/" target="_blank"> pragprog 出版社</a>的書居多 (Chris McCord 的那幾本 Phoenix, Meta-programming 和 Craft GraphQL APIs in elixir with absinthe),跟一本歐萊禮的 Introducing Elixir: Getting Started in Functional Programming。</p><p>學習障礙主要是我不是買最新 Edition 的書,是舊版的 pdf,舊版的 pdf 所述說的 Phoenix 架構其實有變得比較不太一樣,傳參數的寫法也不同,像是 params \\ :empty 現今也直接用 params \\ %{} 取代了,新版的 Phoenix 那樣跑會直接出錯給你看,還有一些加密套件 comeonin 也不是書上的那種用法了,不過我覺得靠著一些個人的見解跟強勢的經驗法則,還蠻快就克服這些問題,可是對於完全新手來說,其實沒有一個指標性又最新的書可以參考,畢竟我覺得這個生態還蠻缺少知識資源的累積。 但要是對 Elixir 有一個了解之後,其實這些就都不是障礙了,最快速的學習方法是如何盡快的掌握 Elixir,在顯然在舊資源 + Google 的情況下還是可以解決的。</p><p>關於這個文章,是 Building GraphQL pattern with Ecto 的上集,會講述建專案、結構到 Ecto 操作資料庫 (Ecto 不是 ORM,他是將資料庫操作框架抽象化成工具,可以通用多個資料庫),而且會寫幾個查詢,讓這些查詢在下集時被使用。</p><p>*小提醒: 回查文件 (像是 Ecto),應該是解決問題的好方法,因為 Elixir 生態似乎都是如此,至少官方還有把文件寫得比較好一點</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">建立一個 Phoenix 專案 For GraphQL APIs</span></h2><p><br /></p><p>下集,我們要靠 Phoenix 這個 Web 專案當作基底,然後在上面蓋上 GraphQL , Phoenix 本身就是跑在 OTP Server 上,整個專案已經幫我們處理好基本架構的事務,只剩下我們需要對專案進行業務邏輯增減。</p><p><br /></p><p>直接輸入指令:</p><br /><pre class="brush: bash;" name="code">mix phx.new drent --no-webpack --no-html</pre><p>這會直接建立一個 drent 的專案。</p><p><br /></p><p>專案說明:</p><p>我們要建立一個租借的系統,角色大致上有以下規劃:</p><p></p><ul style="text-align: left;"><li>Staff 員工</li><ul><li>Profile 職員檔案</li></ul><li>Rental 租借合約</li><ul><li>每個租借合約都有一個 Staff 員工</li><li>租借合約有多個 Devices ,建立 Rental - Device Many-to-Many Table</li></ul><li>Device </li><ul><li>每個 Device 都有一個 Staff 作為擁有者</li></ul></ul><p></p><p><br /></p><p>然後,我們要對這個專案加入 Dependencies,直接打開專案下的 mix.exs:,在 dependencies 處加上下面這三個套件:</p><p></p><pre class="brush: elixir;" name="code">defp deps do
[
{:absinthe, "~> 1.6"},
{:absinthe_plug, "~> 1.5"},
{:jason, "~> 1.1"},</pre><p></p><p><br /></p><p>這三個套件是針對 GraphQL 的支援,也就是 Absinthe 套件。</p><p>然後,輸入指令進行安裝:</p><p><br /></p><pre class="brush: bash;" name="code">mix deps.get</pre><p><br /></p><p>接著,你需要一個 PostgreSQL 資料庫,如果這個資料庫不是放在自己的電腦上,則需要到 config/dev.exs 檔案中去修改名稱。</p><p><br /></p><p>然後,你要進去你的資料庫,去建立一個 DB:</p><p>CREATE DATABASE drent_dev;</p><p><br /></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">建立 Business Model 與資料 Model</span></h2><p><br /></p><p>使用指令自動增加我們需要的 schema:</p><p><br /></p><pre class="brush: bash;" name="code">mix phx.gen.context Users Staff staffs fullname:string
mix phx.gen.context Users Profile profiles company:string, role:string
mix phx.gen.context Devices Device devices name:string sn:string
mix phx.gen.context Rentals Rental rentals title:string reason:string</pre><p><br /></p><p>以上四個 context generation,是針對業務需求所需要的 4 大類別,他會透過指令幫我們預先在 lib/drent/xxx 建好。</p><p><br /></p><p>專案結構上,這篇文章現在的 Phoenix 是這麼分的:</p><p></p><ul style="text-align: left;"><li>lib/drent/ 放 Repository Pattern 相關的類別,也就是這些單純的資料存取操作的工作以及 Typing</li><li>lib/drent_web 放所有與 Web 有關的東西,像是 GraphQL Resovler, REST API, WebSocket, ....etc,其中也包含 MVC 的 Views 和 Controllers</li></ul><div><br /></div><p></p><p>總之,新增這四個大類別之後,需要對他們的資料進行一些增修,總共要更動:</p><p></p><ul style="text-align: left;"><li>/lib/drent/devices/device.ex</li><li>/lib/drent/rentals/rental.ex</li><li>/lib/drent/users/staff.ex</li><li>/lib/drent/users/profile.ex</li></ul><p></p><p><br /></p><p>*注意 Phoenix Project 中所有的複數 s 詞,是會自動分出來而且是有意義在的,需要特別在有 s 和沒有 s 之間區分,尤其是 atom symbol 中,像是 :rental 和 :rentals, :staff 和 :staffs。</p><p><br /></p><p>以下修改中,全部是在改關聯性,只有關聯性需要手動修正,裡面主要用到了 has_one, has_many, many_to_many, 以及屬於的 belongs_to 的操作。</p><p><br /></p><p>belongs_to 跟 has_one 本質做的事情都一樣,只是所屬的角色不同,有一個人是 has_one,另一個人就要用 belongs_to ,如果不加 belongs_to, Ecto 恐怕不會幫你做很多關聯事務處理,事實上這不是在對資料庫做 Table 更動,Table 更動是等會要做的另一件事: Migration。 </p><p><br /></p><p>/lib/drent/devices/device.ex:</p><p></p><pre class="brush: elixir;" name="code">schema "devices" do
field :name, :string
field :sn, :string
# 一個裝置屬於一個擁有者
belongs_to :staff, Drent.Users.Staff, foreign_key: :staff_id
# 多個裝置可以有多個租借
many_to_many :rentals, Drent.Rentals.Rental, join_through: "rentals_devices"
timestamps()
end
</pre><div><br /></div><p></p>/lib/drent/rentals/rental.ex:<p></p><pre class="brush: elixir;" name="code">schema "rentals" do
field :reason, :string
field :title, :string
# 一個租借屬於一個 staff (要先有 staff 才有租借,所以不是用 has_one, 是 belongs_to)
belongs_to :staff, Drent.Users.Staff
# 不同的租借可以有不同很多個 devices
many_to_many :devices, Drent.Devices.Device, join_through: "rentals_devices"
timestamps()
end
</pre><div><br /></div><div>/lib/drent/users/staff.ex:</div><p></p><pre class="brush: elixir;" name="code">schema "staffs" do
field :fullname, :string
# 一個 staff 有一個 profile
has_one :profile, Drent.Users.Profile
# 一個 staff 有很多的租借
has_many :rentals, Drent.Rentals.Rental
# 一個人可以是很多裝置的擁有者
has_many :devices, Drent.Devices.Device
timestamps()
end
</pre><div><br /></div><p>/lib/drent/users/profile.ex:</p><p></p><pre class="brush: elixir;" name="code">schema "profiles" do
field :company, :string
field :role, :string
# 一個 profile 屬於一個 staff
belongs_to :staff, Drent.Users.Staff
timestamps()
end
</pre><div><br /></div><p>以上,完成對資料模型的更改,現在要做資料庫的關聯性,這要透過 Migrations 處理,原則上 Phoenix 在一開始你 gen 完這些 type models 的時候,都幫你在 priv/repo/migrations/* 建立好了,如果要自己手動建立,可以輸入指令:</p><p><br /></p><pre class="brush: bash;" name="code">mix ecto.gen.migration [your_custom_migration_name]</pre><p><br /></p><p>現在,我們要建立一個租借與裝置 (rentals_devices) 的 many-to-many 表,然後也對 Phoenix 先前建立好的 migration 一起做修改,請輸入指令:</p><p><br /></p><pre class="brush: bash;" name="code">mix ecto.gen.migration create_rentals_devices</pre><p><br /></p><p>然後要對以下檔案做變動:<br /></p><ul style="text-align: left;"><li>/priv/repo/migrations/xxxxxxxx_create_devices.exs</li><li>/priv/repo/migrations/xxxxxxxx_create_rentals.exs</li><li>/priv/repo/migrations/xxxxxxxx_create_profiles.exs</li><li>/priv/repo/migrations/xxxxxxxx_create_rentals_devices.exs</li></ul><p></p><p><br /></p>/priv/repo/migrations/xxxxxxxx_create_devices.exs:<p></p><pre class="brush: elixir;" name="code">def change do
create table(:devices) do
add :name, :string
add :sn, :string
# 是嗎?
# 一個裝置屬於一個擁有者
add :staff_id, references(:staffs)
timestamps()
end
# 注意不可這樣寫,這樣一個人只能擁有一個 devices
# create unique_index(:devices, [:staff_id])
end
</pre><div><br /></div><p>/priv/repo/migrations/xxxxxxxx_create_rentals.exs:</p><p></p><pre class="brush: elixir;" name="code">def change do
create table(:rentals) do
add :title, :string
add :reason, :string
# 一個租借屬於一個 staff
add :staff_id, references(:staffs)
timestamps()
end
end
</pre><div><br /></div><p>/priv/repo/migrations/xxxxxxxx_create_profiles.exs:</p><p></p><pre class="brush: elixir;" name="code">def change do
create table(:profiles) do
add :company, :string
add :role, :string
# profile 是屬於某一個 staff 的
add :staff_id, references(:staffs)
timestamps()
end
end
</pre><div><br /></div><div>以下這個是 many-to-many 的表,他沒有 type,所以要自己建:</div><p>/priv/repo/migrations/xxxxxxxx_create_rentals_devices.exs:</p><p></p><pre class="brush: elixir;" name="code">def change do
create table(:rentals_devices) do
add :rental_id, references(:rentals)
add :device_id, references(:devices)
end
create unique_index(:rentals_devices, [:rental_id, :device_id])
end
</pre><div><br /></div><p>以上都完成後,還剩下最後一個種子 Seeds 需要新增,這是給我們測試資料預設用的,我們甚至可以從這裡了解到一些資料結構方面的樣子,修改 priv/repo/seeds.exs:</p><p></p><pre class="brush: elixir;" name="code">alias Drent.Users.Staff
alias Drent.Users.Profile
alias Drent.Devices.Device
alias Drent.Rentals.Rental
alias Drent.Repo
# 先新增裝置
d1 = %Device{
name: "Macbook Pro '15 2016",
sn: "7712-1125-3262-2133"
} |> Repo.insert!
d2 = %Device{
name: "Mac Pro Server AMD 2019",
sn: "8584-1562-1656-1954"
} |> Repo.insert!
d3 = %Device{
name: "Nighthawk® 12-Stream Dual-Band WiFi 6 Router (up to 6Gbps) with NETGEAR Armor™, MU-MIMO, USB 3.0 ports",
sn: "5821-5262-7585-6325"
} |> Repo.insert!
d4 = %Device{
name: "Surface Laptop i5 8G",
sn: "1515-3262-8595-6216"
} |> Repo.insert!
# 新增 staffs
%Staff{
fullname: "Fox",
profile: %Profile{
company: "ZFZ"
},
# 這個 staff 擁有哪些 devices
devices: [
d1,
d2
],
# 這個 staff 建立哪些租借合約? 租哪些裝置?
rentals: [
%Rental{
reason: "會展需要租借電腦",
title: "免費借用",
devices: [
d2, d3
]
}
]
} |> Repo.insert!
%Staff{
fullname: "Jamón",
profile: %Profile{
company: "OOP"
},
devices: [
d3,
d4
],
rentals: [
%Rental{
reason: "會展需要租借電腦",
title: "免費借用",
devices: [
d1, d3
]
}
]
} |> Repo.insert!
</pre><div><br /></div><p>現在,可以針對以上的 Migrations 和 Seeds 做完全設定,請直接跑指令:</p><p><br /></p><pre class="brush: bash;" name="code">mix ecto.setup</pre><p><br /></p><p>這個 setup 是針對資料庫一次性的,資料庫跑完一次,就會在資料庫的 migrations 表中記錄,因此如果要退回 setup,會需要 migration 寫 up, down ,或乾脆 drop database 然後重新來過。</p><p><br /></p><p>要單獨跑 Seeds 也是可以的,指令是:</p><p><br /></p><pre class="brush: bash;" name="code">mix run priv/repo/seeds.exs</pre><p><br /></p><p>如果不想要包再一起寫 seeds.exs 像上面這種子結構 (%XXX{ devices: %VVV{ ... } }) 的形式的話,可以參考 build_assoc, put_assoc (接下來也會提及說明)</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">Ecto 查詢</span></h2><p><br /></p><p>一般的 Type Table 查詢,可以直接使用 Repo.get_by! 函數查詢, ! 是如果沒有資料,就會直接跳錯,如果不加驚嘆號,那你就必須自己使用 Pattern Matching 去自己客製化錯誤,等一下會做一個示範,以下是單純取得資料的 get_by,參數很簡單,只要放 Type 進去,後面接上 id 當作參數就好。</p><p><br /></p><p>直接用指令開啟 iex -S mix 進入互動,然後進行上述的操作:</p><p><br /></p><p></p><pre class="brush: elixir;" name="code">alias Drent.Repo
alias Drent.Users.Staff
Repo.get_by!(Staff, id: 1) #可以放任何 match 資料的參數
</pre><div><br /></div><p>這裡所謂的 Type,是指在 /lib/drent/ (不是 web) 專案中定義的那些資料,像是上方的例子就是 /lib/drent/Users/Staff.ex 這個資料結構,上面這個查詢是查 Staff 為 1 的人。</p><p><br /></p><p>然後,在此簡單探討驚嘆號的處理,在真正的程式存取操作中,不加驚嘆號的處理方法會變成:</p><p></p><pre class="brush: elixir;" name="code">case Repo.get_by(Staff, id: 1) do
nil ->
{:error, "NOT EXISTS!"}
staff ->
{:ok, staff}
end
</pre><div><br /></div><p>這個 Pattern Matching 就可以幫你自動配對如果是 nil 的 sw case 或有一般變數 (此命名為: staff) 的 sw case,要做什麼事。</p><p><br /></p><h4 style="text-align: left;">查詢方法</h4><p><br /></p><p>另一種查詢方式,也可以用 Build Query 的方式進行,他是這麼寫的:</p><p></p><pre class="brush: elixir;" name="code"># 需要引用 Ecto.Query 裡面的那些 macro
import Ecto.Query
#先定義 query
query = from(s in Staff, where: s.id == 1, select: s.fullname)
#然後拿去查所有配對到 id == 1 的資料
Repo.all(query)
</pre><div><br /></div><p>也可以用另一種 Macro 的查詢方法:</p><p></p><pre class="brush: elixir;" name="code"># macro 的查詢方式:
query = select(Staff, [s], s.title)
Repo.all(query)</pre><p><br /></p><h4 style="text-align: left;">將變數放入查詢的特殊作法</h4><p><br /></p><p>Ecto 操作這些 query,必須要使用固定值,傳送固定值的方法是 pin 方法( ^ ) ,而不是使用變數,因為變數會變,可以想像成,把變數序列化 (Serialize) 這樣他就是定值,不會再變了。</p><p><br /></p><p></p><pre class="brush: elixir;" name="code"># 在 where 中放變數來查詢
fullname = "Fox" # want to query fox matched
query = from(s in Staff, where: s.fullname == ^fullname, select: s.fullname)
Repo.all(query)</pre><div><br /></div><p>上面這個例子中,把 fullname 視為是傳進來的變數,而在配對的時候,使用了 ^fullname (pin 運算子) 來把值固定下來,這等同於 s.fullname == "Fox" ,Fox 就是 ^ pin 操作後的定值。 <span style="font-size: x-small;">[A6]</span></p><p><br /></p><h4 style="text-align: left;">關聯性查詢</h4><p><br /></p><p>現在,我們隨便查了一個 Repo.get(Staff, 1) ,你會得到 Staff 結構,可是你會發現有 profile 欄位、 devices 欄位都是 Not Loaded ,這些資訊不是從資料庫來的,是從 Elixir 你寫的定義中去定義關聯性的,所以它內建有功能可以幫你帶入這個關聯性。</p><p><br /></p><p>一查完,得到這樣的資料:</p><p><br /></p><pre class="brush: elixir;" name="code">%Drent.Users.Staff{
__meta__: #Ecto.Schema.Metadata<:loaded, "staffs">,
devices: #Ecto.Association.NotLoaded<association :devices is not loaded>,
fullname: "Fox",
id: 1,
inserted_at: ~N[2021-05-23 09:22:06],
profile: #Ecto.Association.NotLoaded<association :profile is not loaded>,
rentals: #Ecto.Association.NotLoaded<association :rentals is not loaded>,
updated_at: ~N[2021-05-23 09:22:06]
}</pre><div><br /></div><div>而載入的做法,其實只是要告訴 Ecto 你要載入誰而已,寫法比較 <span style="font-size: x-small;">[A5]</span>:</div><p><br /></p><pre class="brush: elixir;" name="code"># 第一種方法
# 沒有 load 預載狀態
staff = Repo.get(Staff, 1) # 查 id 為 1 的資料
# 幫 Staff 上預載狀態 (預載 devices)
Ecto.assoc(staff, :devices)
# 幫 Staff 上預載狀態 (預載 profile)
Ecto.assoc(staff, :profile)
# 幫 Staff 上預載狀態 (預載 rentals)
Ecto.assoc(staff, :rentals)
# 第二種方法
staff = Repo.get(Staff, 1)
staff = Repo.preload(staff, :rentals)
# 第三種方法
# 也可以用這種方式預載,查出所有 :rentals 掛在這個 Staff 底下的人,也可以繼續加 ,:devices, :profile...
Repo.all(from s in Staff, preload: [:rentals])</pre><div><br /></div><div><br /></div><div>如果你嘗試了這個例子,你會發現兩大問題需要解決: preload 的資料如果太多,要怎樣建分頁、每一個 Preload 做法,基本上直接查了兩次,要如何先進行優化,請參考下一節。</div><div><br /></div><div><br /></div><div><br /></div><h4 style="text-align: left;">查詢優化</h4><div><br /></div><div><br /></div><div>直接使用 preload 去做預載入,可以直觀的看到它一次查詢了兩個 query (看上面那個 Repo.all(...) 的例子),如果要對其進行優化,則可以通過以下手寫 join assoc 的方式減少查詢次數:</div><div><br /></div><pre class="brush: elixir;" name="code">query = from(s in Staff, join: r in assoc(s, :rentals), preload: [rentals: r])</pre><div><br /></div><div>這個是用 join 的方式,一次 query 中就幫你用 join 查詢做到位。</div><div><br /></div><div><br /></div><h4 style="text-align: left;">Preload JOIN 操作</h4><div><br /></div><div><br /></div><div>如果要在 join 的情況下做條件查詢,可以這麼做:</div><div><br /></div><pre class="brush: elixir;" name="code">#欲查詢特定 rentals 的 id,可以直接用 where 比對查詢:
Repo.all(from(s in Staff, join: r in assoc(s, :rentals), where: r.id == 1 ,preload: [rentals: r]))</pre><div><br /></div><div><br /></div><div><br /></div><div>如果要查 LIKE 的方法,可以這麼做:</div><div><br /></div><pre class="brush: elixir;" name="code">#欲查詢特定 rentals 的 title 狀況,可以使用 like 放在 where 裡面查詢試試看:
titlelike = "免費"
like_cond = "%#{titlelike}%"
query = from(s in Staff, join: r in assoc(s, :rentals), where: like(r.title, ^like_cond) ,preload: [rentals: r])</pre><div><br /></div><div><br /></div><h4 style="text-align: left;">JOIN 操作</h4><div><br /></div><div>我們的結構中,Join 大部分都沒什麼問題,唯讀 Many-To-Many 的表要如何做 Join?,目前租借 Rentals 的 Devices 可以是 Many-To-Many,因此我們有這個表: rentals_devices,要做 Join 才會確切知道他們之間租借了什麼,不過因為 rentals_devices 不在定義的 Type 結構中,所以要用字串代替。</div><div><div><br /></div><div><br /></div><pre class="brush: elixir;" name="code">alias Drent.Rentals.Rental
alias Drent.Devices.Device
query = from r in Rental,
join: mdtable in "rentals_devices",
on: r.id == mdtable.rental_id,
join: d in Device,
on: d.id == mdtable.device_id,
select: { r, d }</pre><div><br /></div><div>可以看到這麼做,就可以讓兩張表去 Join 了,而在 Join 表也可以做另外的條件查詢:</div></div><div><br /></div><div><pre class="brush: elixir;" name="code">#我們也可以用 where 在 join 中查詢 d.id 為 1 的
query = from r in Rental,
join: mdtable in "rentals_devices",
on: r.id == mdtable.rental_id,
join: d in Device,
on: d.id == mdtable.device_id,
where: d.id == 1,
select: { r, d }</pre><div><br /></div></div><div><br /></div><h4 style="text-align: left;">Sub-query with Join 的查法</h4><div><br /></div><div><br /></div><div>關於分頁機制的問題,在這裡可以開始進行一個討論,如果要限制載入資料量,可以先設定限制,然後撈出資料來,而且限制資料的方式是用 Sub-query 來達成看看:</div><div><br /></div><div><pre class="brush: elixir;" name="code">#也可以用 subquery 做到限制,雖然以下沒有認真細查 pagnition 情境,但用 limit 限制查詢到的資料有限,以節省效能
query_rental_id = 1
minimal_mdtable = from rd in "rentals_devices", where: rd.rental_id == ^query_rental_id, select: %{ rental_id: rd.rental_id, device_id: rd.device_id }, limit: 1
query = from r in Rental,
join: mdtable in subquery(minimal_mdtable),
on: r.id == mdtable.rental_id,
join: d in Device,
on: d.id == mdtable.device_id,
where: r.id == ^query_rental_id,
select: { r, d }</pre><div><br /></div></div><div>注意, minimal_mdtable 整個 type 就是 ecto 的 type,他放到 subquery 就會是另一個 table 加進來的形式了。</div><div><br /></div><div><br /></div><h4 style="text-align: left;">Fragment Raw 片段查法</h4><div><br /></div><div><br /></div><div>fragment 可以幫助我們查詢一些 raw 相關的片段,也可以套用函數,和 ? 的 prepar statement 功能:</div><div><br /></div><div><pre class="brush: elixir;" name="code">from s in Staff,
where:
fragment("staffs.id = ?", 1) == 1
and
fragment("lower(?)", "fox") == "fox"</pre><div><br /></div></div><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">業務邏輯需要的查詢</span></h2><p><br /></p><p>先寫一些之後要給 GraphQL 用的查詢:</p><p><br /></p><p>列出使用者:</p><div><br /></div><div><pre class="brush: elixir;" name="code"># 列出使用者
# GraphQL 也許可根據 __typename 去 preload 使用者的資料
# 新增使用者 + 新增 Profile 並同時綁定
alias Drent.Users.Profile
alias Drent.Users.Staff
alias Drent.Devices.Device
alias Drent.Rentals.Rental
alias Drent.Repo
# 之後要改寫 def 形式
new_user_with_profile = fn(fullname, company) ->
# 簡單新增結構
staff = %Staff{
fullname: fullname
}
# 新增到資料庫
staff = Repo.insert!(staff)
# 建立一個 profile 關聯性,而且是綁到 staff 上 (會自動綁到 staff_id 這個地方, staff_id 是自動的!!)
profile = Ecto.build_assoc(staff, :profile, %{ company: company })
# 建立 profile 並且含有關聯性
Repo.insert!(profile)
end
# 呼叫方法: new_user_with_profile.("WAWA", "COOOOLMAN")
</pre></div><div><br /></div><div>上述可以看到使用了 build_assoc 這個方法,這個方法是可以先把 staff 建出來,再把關聯性建上去的一種做法,可以適用 has_one, has_many,不適用 many-to-many。</div><div><br /></div><p>新增 Devices 並設定 Devices 屬於某個使用者:</p><div><br /></div><div><pre class="brush: elixir;" name="code"># 新增 Devices 並設定 Devices 屬於某個使用者
new_devices_belong_to_staff = fn(name, sn, staff_id) ->
device = %Device{
name: name,
sn: sn,
staff_id: staff_id # 原本我們做的 belongs_to 具有這個效應
} |> Repo.insert!
device
end
# 呼叫方法: new_devices_belong_to_staff.("NEW THINGS", "1515-2323-2323-6666", 1)
</pre></div><div><br /></div><p>新增租借 + Devices 綁定到 rentals_devices 表:</p><p>這個部分就會需要使用 Ecto.Changeset.put_assoc/4 去做儲存,這是給 many_to_many 所使用的建立關聯性方法,build_assoc 是無法處理的,也因為 rentals devices 都沒有 foreign_keys,所以作為替代,要使用 Ecto 變更集跟 put_assoc 處理。</p><div><pre class="brush: elixir;" name="code"># 新增租借 + Devices 綁定到 rentals_devices 表
# 參數上, title: string, reason: string, staff_id: int
# 唯讀 [:devices | devs] 是辨別陣列的 Pattern Matching:
# 用法上會像是: [:devices, 1, 2, ,3, 4],一開頭先告訴這個 pattern 有 :devices 這個 symbol 作為起始
# 這樣 devs 就會是 list [1,2,3,4]
new_rental_bind_devices = fn (title, reason, staff_id, [:devices | devs]) ->
# 新增一個租借,並新增到資料庫
rent = %Rental {
title: title,
reason: reason,
staff_id: staff_id,
}
|> Repo.insert!
# 這是很特別的做法,因為 [:devices | devs ] 傳進來都是 int 陣列, 我們假裝他是預載過的資料
# 因為實際上建立關聯性不需要那麼多資訊
# for 會自動建起 list, 每個元素都是 do 裡面產生的
devices = for d <- devs do
%Device{ id: d, __meta__: %Ecto.Schema.Metadata{ source: :devices, state: :loaded } }
end
# 預載資料 (尤其 :devices 需要關聯性)、建立變更集、將關聯性資料放到 devices 中、更新
rent
|> Repo.preload([:devices, :staff])
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_assoc(:devices, devices)
|> Repo.update()
end
# 呼叫方法: new_rental_bind_devices.("FREE RENT", "I NEED IT", 1, [:devices, 1, 2, 3])</pre></div><p><br /></p><p>上述有提到一個很特別的作法: devices = [假預載資料] ,社群中的朋友們認為, devices 應該都要做 Repo.get_by!() 去載入每一個 device 結構,那樣結構就不再是 [ :devices, 1,2,3,4] 而會變成 [ :devices, %Device{}, %Device{}...],而上述這個寫法,也是社群一部分朋友認為建立關聯性只是要用到 id ,需要兩邊的關聯性資料全部都要帶出來才能建立嗎? <span style="font-size: x-small;">[A7] [A8] [A9] [A10]</span></p><p><br /></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">變更集 Changeset, Insert, Update, Remove</span></h2><p><br /></p><p>Insert, Remove 似乎都是 Repo 內建就幫我們辦到的事情,唯有 Update 還需要再思考一下,變更集是 Ecto 的一個解決方案,他可以用模組的形式做資料更改,也可以做到確認資料的驗證、淺在錯誤、正確性。</p><p><br /></p><p>詳情可以參考: https://elixirschool.com/zh-hant/lessons/ecto/changesets/</p><p><br /></p><p>在這裡使用 Changeset 示範如何變更 Staff 姓名 <span style="font-size: x-small;">[A11]</span>:</p><div><br /></div><div><pre class="brush: elixir;" name="code"># 修改某個使用者的 fullname
change_staffname_by_id = fn(staff_id, fullname) ->
alias Drent.Users.Staff
alias Drent.Repo
Repo.update(Ecto.Changeset.cast(%Staff{ id: (staff_id, }, %{ "fullname" => fullname) }, [:fullname]))
end
# 呼叫方法: change_staffname_by_id .(1, "FOX 2")</pre></div><p><br /></p><p>你可能會需要帶出資料,或是像上面使用 %Staff{} 具體有 id 結構的東西,然後做成 changeset,沒辦法單純做更新。 詳情可以參考 <span style="font-size: x-small;">[A12] - Chapter 8. Making changes with Ecto.Changeset - 8.1. Can’t I just ... update? : Nope. You can’t.</span></p><p><br /></p><p><br /></p><p>2021/02/20 著作, 2021/05/24 發佈。</p><p><br /></p><p>References:</p><p>http://blog.plataformatec.com.br/2015/08/working-with-ecto-associations-and-embeds/<br />https://elixirforum.com/t/how-to-seed-pivot-table-relations/4494/4<br />https://hexdocs.pm/ecto/Ecto.html#build_assoc/3<br />https://elixirschool.com/zh-hant/lessons/ecto/associations/<br />https://hexdocs.pm/ecto/Ecto.Query.html<br />https://elixirforum.com/t/ecto-not-allowing-string-interpolation-in-fragments/28459/3<br />https://elixirforum.com/t/seeding-database-with-relationships/13240<br />https://hexdocs.pm/ecto/Ecto.html#build_assoc/3<br />https://elixirforum.com/t/help-filtering-many-to-many-associations-with-ecto/4210/4<br />https://riptutorial.com/elixir/example/6956/pattern-matching-on-a-list<br />https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3<br />https://elixirforum.com/t/build-assoc-vs-put-assoc/24071<br />https://medium.com/@andreichernykh/thoughts-on-structuring-an-elixir-phoenix-project-cb083a8894ef<br />[A5] https://stackoverflow.com/questions/39896713/how-can-i-preload-association-and-get-it-returned-in-ecto<br />[A6] https://stackoverflow.com/questions/33324302/what-are-elixir-bang-functions<br />[A7] https://hexdocs.pm/ecto/Ecto.Changeset.html#put_assoc/4<br />[A8] https://gist.github.com/cblavier/356e662fdbc7910cc4bc39737d7851c2<br />[A9] https://elixirforum.com/t/updating-many-to-many-with-put-assoc-and-an-array-of-ids/8648/2<br />[A10] https://elixirschool.com/zh-hant/lessons/ecto/associations/#%E5%84%B2%E5%AD%98%E9%97%9C%E8%81%AF%E8%B3%87%E6%96%99<br />[A11] https://hexdocs.pm/ecto/Ecto.Changeset.html#cast/4<br />[A12] https://livebook.manning.com/book/phoenix-in-action/chapter-8/9</p><div><br /></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushBash.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/cfeduke/shBrushElixir/shBrushElixir.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-27092365370185236622021-04-02T13:14:00.003+08:002021-04-02T13:14:37.697+08:00Golang: Pattern for Building a GraphQL System and the Part of Caution<p>本文記載於 2021/03/31 ,不同的邏輯可能會因為時代的不同而變化,欲要使用 Golang 打造一個具有 GraphQL 和 REST-API 並存的 Server,可以參考本篇文章的做法。</p><span><a name='more'></a></span><p>文章摘自現階段工作中已執行中的軟體架構,在工作中, Golang 扮演接管 Legacy 系統部分新功能的角色,簡單的說,這個工作是維護一個沒有文件、說明、 git 的龐大軟體架構,而目前的工作階段總共有 3 種不同的程式語言在接管維護這個龐大的系統,如此段所述, 會使用 Golang 接續 Legacy 舊系統的新功能開發。</p><p>然而,本項工作是完全的 GraphQL,所以勢必在團隊溝通上,選擇使用 GraphQL 作為 API 接入口是一個比較好的選擇,以下探討現在是如何實現這樣的架構 Pattern,並且陳列需要考量的點,若有優缺點、效能上的考量,或許未來可以再新的文章中做討論,不在本篇討論範圍。</p><p><br /></p><p>首先,必須先散列專案中主要的主題以及使用的技術,然後再從目錄結構的方式從旁了解架構設計的本質。</p><p><br /></p><p></p><ul style="text-align: left;"><li>Simple HTTP Server (go-chi)</li><ul><li>Middleware JWT Authenticate</li><li>CORS</li><li>REST API</li><li>GraphQL</li></ul><li>Database Entity / Model Pattern (E<-M<-G 模式)</li><ul><li>GORM</li><li>Go-Migrations</li><li>Logging</li></ul><li>GQL Resolver Model</li><ul><li>mirror feature func</li><li>nullable pointer helper func</li><li>passed EM Model</li></ul></ul><p></p><p><br /></p><p>* E<-M 模式,是指在這個系統中,沒有外部 API 呼叫,只從 Go 裡面自己測試呼叫這個 Pattern 的資料,是從 Model (M) 開始呼叫,由 M 裡面去執行 Entity(E) 的行為作處理後丟回 M,M 再丟回給呼叫者。</p><p>* E<-M<-G 模式,是指上一個模式中,呼叫者可以是 GraphQL 的 Resolver 機制,統稱 (G),也就是作為一個外部的進入點。 (此模式以此類推可以延展,思維是 Entity 一定是作為單一資源訪問的形式處理任何邏輯,但效能不見得好。)</p><p><br /></p><p>以流程來說, G(GraphQL Resolvers) 是最先被 HTTP 服務流入的服務,然後 G 再去呼叫 M 再去呼叫 E。 </p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">EM 模式的實現</span></h2><p><br /></p><h2 style="text-align: left;"><span style="font-size: large;">Entity 模式</span></h2><p>首先是 Entity 模型的實現,對於 Entity 來說,你會有一個最主要的 /entity/entity.go ,會放入通用內容:</p><pre class="brush: go;" name="code">package entity
type DB struct {
Gorm *gorm.DB
}
func New(config config.Config) *DB
func (d *DB) Migrate() error
// 統一化 Query 都會幫你把資料映射到 mirror 中
func (d *DB) GenQuery(query string, mirror interface{}) error
// 統一化 Execute 都只會做事,不會回傳任何東西
func (d *DB) GenExec(query string) error
// 統一化 Insert 都會給出插入後的 LAST_INSERT_ID
func (d *DB) GenInsert(query string) (lastInsertID int, err error)
// String 是 helper,會將值升級為 pointer
func String(v string) *string {
return &v
}
// Integer 是 helper,會將值升級為 pointer
func Integer(v int) *int {
return &v
}
// Time 是 helper,會將值升級為 pointer
func Time(v time.Time) *time.Time {
return &v
}
</pre><div><br /></div><div>然後在該同 package 目錄下,會有各式各樣的 go 會依照以下方式實作:</div><div><ol style="text-align: left;"><li>定義 gorm 需要的 struct</li><li>struct 的 func 需要定義 TableName() 給 gorm</li><li>struct 的 func 開始寫需要存取 CRUD 純資料存取的功能</li></ol><div>最主要的核心哲學,就是 Entity 應該保持乾淨,盡量沒有商業邏輯的牽扯。</div></div><div><br /></div><div>隨便舉例一個資料庫 Table 的 CRUD 如何按照 Entity Pattern 實作 (/entity/classroom.go) :</div><div><pre class="brush: go;" name="code">package entity
// 定義一個 Database Table 也是定義資源類型
type Classroom struct {
ID int `db:"id" gorm:"primaryKey;autoIncrement;column:id;type:int;" json:"id"`
ClassName string `db:"className" gorm:"column:className;varchar(255)" json:"className"`
CreatedAt time.Time `db:"createdAt" gorm:"column:createdAt" json:"createdAt"`
DeletedAt *time.Time `db:"deletedAt" gorm:"column:deletedAt" json:"deletedAt,omitempty"`
}
// Gorm 所需要使用的 TableName() 才能做 auto-migrate
func (c *Classroom) TableName() string {
return "Classroom"
}
// 定義自己要的行為,但是是掛在 DB 底下 (這樣才能存取 Passed Model)
func (d *DB) CreateClassroom(name string) (id int, err error);
// 定義查詢條件,讓結構體不是 pointer,但內容可以是 pointer 令值為空
type ClassroomQueryOption struct {
ID *int //可能是空的,表示不查
ClassName *string //可能是空的,表示不查
CreatedAt *time.Time //可能是空的,表示不查
DeletedAt *time.Time //可能是空的,表示不查
}
func (d *DB) ListClassroom(opts ClassroomQueryOption) (id int, err error);
// 取得單一資源,回傳 entity-struct,不要回傳除了基本資料、 entity-struct 以外的資源 struct
func (d *DB) GetClassroom(id int) (c Classroom, err error);
// 刪除單一資源
func (d *DB) DeleteClassroom(id int) error;
</pre></div><div><br /></div><div>*按照這樣的哲學 Pattern,你不應該在 Entity 中的 CRUD Functions 存取同樣為 Entity 的 Functions,請使用 JOIN 之類的方法帶出符合 Entity-Struct 定義的資料。</div><div><br /></div><p></p><h3 style="text-align: left;"><span style="font-size: large;">*null 值問題處理</span></h3><p>在上述的例子中,你會發現可以留資料庫 null 欄位的變數,都是使用指標。</p><p>在市面上常見的做法有使用 gorm 的 null type (e.g: sql.NullString, sql.NullTime) [1],也有使用第三方的 guregu/null [2] 來當作型別,更常見的是以上使用 pointer 表示可能為空值的變數,各有利弊,使用 pointer 缺點就是帶值很麻煩,必須要新增變數,再賦予指標。</p><p>由於 Golang 相容 C++ ,沒有特別讓資料型別預設值為 nil,於是資料庫的 null 值問題將會造成 Golang 一點小麻煩,因為用 nil 強制丟到 Golang 型別,也只會得到該預設值,所以沒有指標的 int a 被資料庫丟一記 nil 之後,可能會產生 a 是 0 的結果,可是你必須知道 a = 0 不等價於 a 為 nil。</p><p>所以資料庫是否為空的判斷一定要使用 pointer 來獨立判斷 nil,而不是判斷初始值,像是 if a != nil { ... } 。</p><p>即便 DB, Program null 是 40 年前就有的問題,至今也需要使用對自己更有利的做法處理。</p><p>對 Golang 而言,也許有部分的人恨透使用 Pointer 變數,會讓專案完全避免 Null 值,此點可以斟酌考量,不一定有誰對誰錯的問題,而是在於這個精神、設計原則、慣例是否可以帶來比弊點更強大的好處。</p><p><br /></p><h3 style="text-align: left;"><span style="font-size: large;">*資料庫 Migrations</span></h3><p>Entity 模式中,因為資料庫整合 gorm,既然使用 ORM ,那麼系統也可以更自動化地處理 Migrations,不過問題似乎在於,gorm 的 auto-migrations 只能加上新 Table 而無法減去 Table、修改 Column,於是你必須<b>手動</b>建置一個完整的 Migrations,這整套方案如下:</p><p></p><ol style="text-align: left;"><li>使用一個可以提供 Migration 機制的服務,像是 Go-Migrations,他會在資料庫做一個表,用於紀錄目前版本,以及在 Migration 前是否有資料 (dirty 欄位) 的紀錄。</li><li>手動寫一個腳本,有兩個 function: up, down,意思就是 migration 升級此版,up 基本上要手寫所有變更的 SQL Scripts,包含 ALTER TABLE MODIFY COLUMN 至細微項目; down 則是如果 up 後想要回復,必須提供刪除新版退回舊版的機制。</li><li> up 的資料沒有預設值也沒有關係</li><li> 若要使用 auto-migration,要確定該服務是否有對 column 建立 Index, Constraint。</li></ol><div>所以 Migration 基本上就可以做到:</div><div><ol style="text-align: left;"><li>Upgrade Database</li><li>Rollback</li><li>History</li></ol><div>注意,如果您正在維護的系統中,大致上確認系統資料庫已經是 Migration 的新版,但是卻沒有 Migration 紀錄,或是遺失 Migration 紀錄、部分 Migration 紀錄,那您應該不要再次跑 Migration,而是使用此版本作為第一版,再日後才開始繼續做 Migration,但須要記得,如果發現專案有 Migration 腳本,並且是遺失紀錄的情況下,建議可以手動在資料庫一個個補上,這樣才能讓接手維護的下一版 Migrations 正常執行。</div></div><div><br /></div><div>您可能要著手先了解 Legacy Migrations 在資料庫是否有留下 Migration Record Table,再檢查完整性,才開始動手修資料。</div><p></p><p>*小提醒: 您需要記得維護舊系統資料庫時,資料的可信度一般來說都是最低的,資料瑕疵造成您判斷的問題時常出現,出問題時應該先懷疑資料的正確性。</p><p><br /></p><h2><span style="font-size: large;">Model 模式</span></h2><p>首先是 Model 模式的實現, Model 的設計考量點相對於 Enity 來說已經小很多,以下是 /model/model.go:</p><div><pre class="brush: go;" name="code">package model
// Model 會處理所有商業邏輯的表中層
type Model struct {
// DB 是被新增出來的 DB Entity,它的資料庫連線此時應該是開好的。
DB *entity.DB
// Config 是常見被帶入 Config 設定的物件,可以保留給您決定。
Config config.Config
}
// 使用 New 幫你帶出實體化的 Model 物件,供存取商業邏輯
func New(d *entity.DB, conf config.Config) *Model {
return &Model{d, conf}
}
</pre></div><div><br /></div><p>然後,任意一個同 model package 底下的 model,會用於存取商業邏輯,做運算、處理、整合其他 DB-CRUD-Functions,例如對 Classroom 的操作: /model/classroom.go:</p><div><pre class="brush: go;" name="code">package model
func (m *Model) CreateClassroom(name string) (id int, err error){
// 把呼叫的資源 pass 給 DB-Entity Fuction
id, err := m.DB.CreateClassroom(name)
if err != nil {
return 0, err
}
//.... 整合其他商務邏輯
// 比方說判斷
// 比方說牽扯好幾種服務的邏輯呼叫,都在這邊
}
// 回傳有可能還是 Entity-Struct 的樣子,那麼就直接帶出
func (m *Model) GetClassroom(id int) (entity.Classroom, error){
return m.DB.GetClassroom(id)
}
</pre></div><div><br /></div><h3 style="text-align: left;"><span style="font-size: large;">*單元、整合測試的起點</span></h3><div><br /></div><div>開始維護舊系統、甚至在這樣的 Pattern 中,需要有測試才能保證原有系統的穩定性,避免我們新增一個功能後導致原有系統的功能無法正常運作,可是我們又沒有時間寫這麼多測試,這讓我們不禁思考要從哪邊測試才是最佳的方案。</div><div><br /></div><div>依照歸納方式, Entity-Struct 在架構哲學上已經是當作個體看待,對於一個單純 CRUD 的資源,除了 JOIN 寫錯以外,事實上不太可能再出錯,於是如果有時間的話,可以對 Entity-Struct 做 Unit-Test (需要先做 DB Seeding)。</div><div><br /></div><div>而我們定義商業邏輯都會在 Model 中出現,於是 Model 是最需要被測試的內容,而且 Model 最大的問題是它混了大量的商務邏輯,導致他們很難只做 Unit Test,因此必須取捨哪些功能才需要 Unit Test,或是全部都不做 Unit Test。</div><div><br /></div><div>以混亂度非常高且不可避免之的 Model 而言,選擇做 Integration Test 整合測試可能是暫時的最佳解,從 Integration Test 做起驗證商業邏輯正確性,增加 Model 測試覆蓋率。</div><div><br /></div><div>在做 Integrations 之前,我們要對 Database 做初始假資料處理,叫做 DB Seeding ,它的處理方式因人而異,你可以直接開成共享全域變數在 test 中,或是在資料庫直接插入好初始值。</div><div><br /></div><div>你的 Unit Test 可以這樣設定所有 Model 都能被單測的 Pattern (/model/classroom_test.go):</div><div><pre class="brush: go;" name="code">package model
import (
"testing"
"github.com/stretchr/testify/assert"
)
// classroom 是單元測試共享的資料。
var classroom entity.Classroom
var m *Model
// 初始化本測試需要的環境,每一次針對單 function unit test 都可被載入,所以其他檔案有重複 init 其實不影響。
func init() {
// 把設定檔案帶進來
conf := config.New()
// 設定資料庫連線, DB-Entity
d := entity.New(conf)
// 先 migrate
d.Migrate()
// 先取得一個已經有 db 設定好的商務邏輯 model 物件
m = New(d, conf) // Model 中的 New
}
func TestCreateClassroom(t *testing.T) {
classroom = entity.Classroom{
ClassName: "TEST CLASS",
CreatedAt: time.Now(),
}
a := assert.New(t)
cid, err := m.CreateClassroom(classroom)
a.NoError(err)
a.NotZero(cid)
// 設定 global 變數為剛才插入後的值
classroom.ID = cid
}
</pre></div><div>此時如果需要一個 Integration Test,那麼它的 Pattern 可能會長這樣 (/model/model_test.go):</div><div><pre class="brush: go;" name="code">package model
var m *Model
// 初始化本測試需要的環境
func setupTest() {
// 把設定檔案帶進來
conf := config.New()
// 設定資料庫連線, DB-Entity
d := entity.New(conf)
// 先 migrate
d.Migrate()
// 先取得一個已經有 db 設定好的商務邏輯 model 物件
m = New(d, conf) //Model 中的 New
}
</pre></div><div><br /></div><div>上面是主要的測試 main ,接著示範 classroom 單一做整合測試 (/model/classroom_test.go):</div><div><pre class="brush: go;" name="code">type ClassroomSuite struct {
// 繼承 suite struct
suite.Suite
//可以塞一些要被共用的資料:
classroom entity.Classroom
}
// SetupSuite
func (cs *ClassroomSuite) SetupSuite() {
setupTest()
}
func (cs *ClassroomSuite) CreateClassroom(t *testing.T) {
classroom = entity.Classroom{
ClassName: "TEST CLASS",
CreatedAt: time.Now(),
}
cid, err := m.CreateClassroom(classroom)
s.NoError(err)
s.NotZero(cid)
// 設定 global 變數為剛才插入後的值
s.classroom.ID = cid
}
// TestAll (Integration Test)
func (cs *ClassroomSuite) TestClassroomSuite() {
// 有先後順序的跑
s.Run("CreateClassroomSuite", s.CreateClassroom)
// 再跑
s.Run(.......)
}
// 這是要優先被執行測試的主體進入點
func TestClassroomSuite(t *testing.T) {
suite.Run(t, new(ClassroomSuite))
}
</pre></div><div><br /></div><p>*資料庫 seeding 可以設定要 truncate, drop table 也可以不要,建議兩者都可以留存供測試者選擇。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: large;">EM 模式接入 G (EMG)</span></h2><p><br /></p><p>現在,我們要開始探討 GraphQL 究竟要如何接入目前的 EM 模式,而且可以完美抽換這個邏輯呢? </p><p>首先, GraphQL 最後產生出來的 Resolver Function,它在相容 EM 模式裡,是一個呼叫 Model 商務邏輯的角色,它是透過 Web GraphQL 請求,由 Resolver 去呼叫那些商務邏輯後,把回傳資料轉成 GraphQL 能接受的格式,然後再轉手給前端。</p><p>也就是說, GraphQL Resolver 就只扮演轉介資料的角色,在核心理念的擴展中,它基本上只被允許做這幾件事:</p><p></p><ol style="text-align: left;"><li>輸入資料的轉介型態,因為 gqlgen 的 struct 和 entity-struct 是不一樣的,可以直接把 input 轉成 json ,再把 json 轉成 entity-struct,也可以用 reflection 映射的方式處理、或 map 的方式處理。</li><li>輸入資料 pointer 轉換、 enum pointer 轉換、options-struct 轉換</li><li>自動帶入 JWT Authenticate Context 的 User.id 等</li><li>輸出如果要把 classroomId 這種欄位,轉成符合 GraphQL 精神的 Classroom {} struct,可以在這邊一個一個回查、再帶出去</li><li>不可以因為 GraphQL 的格式精神,影響 Model 要輸出的結果,比方說 4. 的內容就盡量不應該再 Model 中 Patch 加上去,除非一開始就有必要的考量帶出 Struct 在 Data 中。</li></ol><div><br /></div><div>*注意 EMG 不應該引起原架構的改變,因此如果你需要從 classroomId 帶出整個 classroom,甚至又是陣列,那應該在 resolver 跑回圈一個一個帶出來到 GraphQL 的 struct,盡量不要影響 Model 要輸出的內容,這也正是符合EMG 原則。</div><div><br /></div><div><br /></div><div>GraphQL-Resolver (gqlgen) 相容進 EM 模式的 Pattern,你會看到這樣的開頭範例,與 package graph 同一個目錄,有個 (graph/resolver.go):</div><div><pre class="brush: go;" name="code">import (
"encoding/json"
)
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require here.
// 原本給的 Resolver,在上面做我們的 EM 架構延伸,帶入 EM 的 Model(with Entity)
type Resolver struct {
model *model.Model
}
// 把 Resolver 建立物件出來
func New(model *model.Model) *Resolver {
return &Resolver{
model,
}
}
// 幫忙把兩個不同的 struct 做轉換
func refl(src interface{}, dest interface{}) error {
marshaledJSON, err := json.Marshal(src)
if err != nil {
return err
}
err = json.Unmarshal(marshaledJSON, &dest)
if err != nil {
return err
}
return nil
}
<br /></pre></div><div><br /></div><div>這個 resolver.go 原本只是一個空的 struct,用來給予 graphql query, mutation 掛在 resolver 底下的,可是我們還是可以帶入自己想要的東西,把 EM 模式串進 Resolver 變成 EMG。</div><div><br /></div><div>在 Resolver 這邊,用一個終端存取的範例告知你在 gqlgen 出來的 (graph/schema.resolvers.go) 中,它是長什麼樣子:</div><div><pre class="brush: go;" name="code">/*
GraphQL: schema.gql
scalar Time
type Mutation {
createClassroom(input: CreateClassroomInput!)
}
type Query {
listClassroom(studentId: Int!): [Classroom!]
}
type Classroom {
id: Int!
className: string!
createdAt: Time!
deletedAt: Time!
}
input CreateClassroomInput {
className: string!
createdAt: Time!
creatorUserId: ID!
}
*/
package graph
// This file will be automatically regenerated based on the schema, any resolver implementations
// will be copied through when generating and any unknown code will be moved to the end.
import (
"context"
"time"
)
func (r *mutationResolver) CreateClassroom(ctx context.Context, input schema.CreateClassroomInput) (bool, error) {
var classroom entity.Classroom
err := refl(input, &classroom) // 把 GQL.INPUT 映射到 entity.classroom
if err != nil {
return false, err
}
// 從 JWT Authentication 帶出 Context 補完資訊,是符合 G 這層應該做的行為
if input.creatorUserId == nil {
user := middleware.ForContext(ctx)
classroom.creatorUserId = user.ID
}
// 執行 model 商業邏輯的 CreateClassroom 方法
cid, err = r.model.CreateClassroom(classroom)
if err != nil {
return false, err
}
// 把方法帶回 local-var
classroom.ID = cid
// reflect ,把 entity-classroom 轉換輸出成 gql-schema-classroom 格式
var out schema.Classroom
err = refl(classroom, &out)
return true, err
}
// 這是給 GQL 工具用的,唯有我們需要 Resolver 帶入 Resolver 的 model
// Mutation returns schema.MutationResolver implementation.
func (r *Resolver) Mutation() schema.MutationResolver { return &mutationResolver{r} }
// 這是給 GQL 工具用的,唯有我們需要 Resolver 帶入 Resolver 的 model
// Query returns schema.QueryResolver implementation.
func (r *Resolver) Query() schema.QueryResolver { return &queryResolver{r} }
// 這是給 GQL 工具用的,唯有 mutationRes, queryRes 要帶入我們自己訂的 Resolver 工具型態繼承
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
</pre></div><div><br /></div><div>*再次補充,G 這層只做資料輸入處理、輸出處理,輸入就包含帶出 Context JWT 中使用者的 id,不做邏輯處理,因為這會造成邏輯分散,邏輯分散就需要花心思重新做測試。</div><div><br /></div><div><br /></div><div><br /></div><div>HTTP 服務整合 EMG, JWT With Context</div><div>在 HTTP 端事實上也應該被當作獨立的模組處理,所以這並不是 Main,而是要開啟對 Resolver 的接入點,假設不是使用 Resolver 串 EMG 架構,是使用 REST-API 串 EMR 架構,也是同理在這一層做接入,以 EMG GraphQL 為例子,一個 WSGI Server 這個模組會長像這樣 (/server/server.go):</div><div><div><pre class="brush: go;" name="code">package server
import (
"cloud.google.com/go/storage"
)
/*
根據 EMG 架構,從 WebServer 進入的點應該是終端層的 G,在 Server 可以取捨要帶入什麼服務
*/
type Server struct {
// DB 是 DB 相關的服務,你不一定會用到,
// 因為給出下面 Resolver 的時候,本身已經有設定好連線的 Model, DB-Entity 在內了。
// DB *entity.DB
// EMG 的 G 介面端,也可以是 REST-API 的 R 介面端 (總之你會 pass Model 到 Resolver 再 pass 過來)
Resolver *graph.Resolver
// GCP Client 也許在共用 Router 會用到
// GCP *storage.Client
}
func New(r *graph.Resolver/*, d *entity.DB, g *storage.Client*/) *Server {
return &Server{
//DB: d,
Resolver: r,
//GCP: g,
}
}
<br /></pre></div><div>接著,我們需要設計一個 Middleware 做 JWT 管控 (只有驗證,沒有發驗證的功能) /middleware/middleware.go:</div><div><pre class="brush: go;" name="code">package middleware
import (
"context"
"fmt"
"net/http"
"strings"
"bitbucket.org/superbarkingdog/mmagymgo/config"
"github.com/dgrijalva/jwt-go"
)
// A private key for context that only this package can access. This is important
// to prevent collisions between different context uses
var userCtxKey = &contextKey{"user"}
type contextKey struct {
name string
}
// A stand-in for our database backed user object
type User struct {
Name string `json:"name"`
UserId int `json:"staffId"`
EXP int `json:"exp"`
IAT int `json:"iat"`
Permissions []interface{} `json:"permissions"`
Role string `json:"role"`
jwt.StandardClaims
}
func getTokenFromRequest(r *http.Request) string {
// first, fetch token from the `access_token` cookie
if c, err := r.Cookie("access_token"); err == nil {
if c.Value != "" {
return string(c.Value)
}
}
// if it's not there, check in the Bearer token
if substrings := strings.Split(r.Header.Get("Authorization"), "Bearer "); len(substrings) == 2 {
return substrings[1]
}
return ""
}
func respHelper(w http.ResponseWriter, msg string) {
w.WriteHeader(403)
w.Header().Add("Content-Type", "application/json")
w.Write([]byte(msg))
}
var ACCSEC = "https://youtube.com/watch?fsdfsdfsdfdsfdsds"
func JWTAuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var token string = getTokenFromRequest(r)
// Check token is exist in header
if token == "" {
respHelper(w, "no access token provided")
return
}
t, err := jwt.ParseWithClaims(token, &User{}, func(payload *jwt.Token) (interface{}, error) {
if _, ok := payload.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("Unexpected signing algorithm: %v", payload.Header["alg"])
}
return []byte(ACCSEC), nil
})
if err != nil || !t.Valid {
respHelper(w, "JWT verification failed")
return
}
user, ok := t.Claims.(*User)
if !ok {
respHelper(w, "claims failed")
return
}
// set user jwt to context
ctx := context.Background()
ctx = context.WithValue(ctx, userCtxKey, user)
// bring ctx to req
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
})
}
}
// ForContext finds the user from the context. REQUIRES Middleware to have run.
func ForContext(ctx context.Context) *User {
raw, _ := ctx.Value(userCtxKey).(*User)
return raw
}
<br /></pre></div></div><div>完成後,最後要在 / 目錄下建立一個 router.go (也可以自己選地方放) 層級的服務去處理 HTTP Request Router 相關事務,他會長這樣:</div><div><pre class="brush: go;" name="code">package main
import (
"io"
"net/http"
"net/url"
"cloud.google.com/go/storage"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
chi "github.com/go-chi/chi/v5"
md "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
// 初始化帶入 GraphQL 的 Chi Router。
func NewRouter(/*d *entity.DB, */s *server.Server) *chi.Mux {
router := chi.NewRouter()
router.Use(md.Logger)
router.Use(md.RequestID)
router.Use(md.RealIP)
router.Use(md.Logger)
router.Use(md.Recoverer)
// Basic CORS
router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"}, // Use this to allow specific origin hosts
//AllowedOrigins: []string{"https://*", "http://*"},
//AllowOriginFunc: func(r *http.Request, origin string) bool { return true },
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
}))
// 將需要保護的路由器分別 group
router.Group(func(r chi.Router) {
// 帶入 middleware 處理這個區域的路由
r.Use(middleware.JWTAuthMiddleware())
serv := handler.NewDefaultServer(schema.NewExecutableSchema(NewGraphQL(s)))
r.Handle("/go-service/gql", serv)
})
router.Get("/go-service/gql", playground.Handler("query", "/go-service/gql"))
// GCP Router for file uploads
// router.Post("/uploads", GCPFileUploader(s.GCP, "BUCKET_NAME"))
return router
}
</pre></div><div><br /></div><div>其中如果在多服務的伺服器上,使用 /prefix/suffix 去區分, 對於使用 nginx 的服務會比較好控制。 (e.g: /prefix/suffix/query, /prefix/suffix/mutation)</div><div><br /></div><div>最後需要一個 main function 去啟動這些服務,他的模式大概就是長這樣 main.go:</div><div><pre class="brush: go;" name="code">// 載入設定檔案。
conf := config.New()
// 載入 GCP Client
gcp, err := storage.NewClient(context.Background(), option.WithCredentialsFile(conf.GCPApplicationCredentials))
if err != nil {
//log.Fatalln("Faild to open GCP Storage.")
fmt.Println("Failed to open GCP Storage.")
}
// 初始化資料庫
d := entity.New(conf)
// 帶有設定好資料庫連線的商業邏輯
m := model.New(d, conf)
// EMG 架構 (純 GraphQL 服務)
// 傳入帶有資料庫的商業邏輯的 GraphQL Resolver 進入 Server
s := server.New(graph.NewResolver(m)/*, d, gcp*/)
// EMR 架構 (純 REST-API 服務)
// restapi := restapi.New(m)
// go restapi.Run(":8080")
// 啟動整套服務
http.ListenAndServe(":1234", NewRouter(/*d, */s))
<br /></pre></div><div>在 main 統整的最後一刻,呈現了 EMG, EMR(REST API), 以及 JWT, Middleware With Context 是如何被傳進去整套服務 Pattern 的。</div><p><br /></p><p>要稍微注意 GraphQL 這一邊,因為 Date 的格式不是所有服務都能識別,所以在前端使用 Time GraphQL types 的時候,可以轉成 ISO String 用 String 傳進來: new Date().toISOString()。 </p><p>以上說明是針對<b><u>前端</u></b>使用 Query, Mutation 要定義 Date 型別時,可以這麼使用:</p><p>query Salary($yearMonth: Time) { ... }</p><p>然後定義 Typescript 該 yearMonth 型別的時候,使用 string 格式:</p><p>export interface SalaryQueryVariables {</p><p> yearMonth: string;</p><p>}</p><p>而在 Golang 端的 GraphQL 一樣也是使用 Time,而且自動就可以被轉成 time.Time 格式了。 </p><p><br /></p><p>Type 的另外一個問題是 ID,如果在資料庫會帶出 id 或使用 ID 查詢,可以直接使用 ID (string) 作為 GraphQL 的 type,如此前端傳入 int, string 都會被統一化。</p><p><br /></p><p>總結,這樣的目錄結構會像是:</p><p>entity/</p><p>├─ entity.go</p><p>├─ classroom.go</p><p>model/</p><p>├─ classroom.go</p><p>├─ model.go</p><p>config/</p><p>├─ config.go</p><p>restapi/</p><p>graphql/</p><p>├─ schema.resolver.go</p><p>├─ resolver.go</p><p>middleware/</p><p>├─ middleware.go</p><p>server/</p><p>├─ server.go</p><p>main.go</p><p>router</p><div><br /></div><p><br /></p><h2 style="text-align: left;"><span style="font-size: large;">跨系統整併 GraphQL Schema 的大挑戰</span></h2><p><br /></p><p>如今,一套 Legacy 系統被停止維護,接手的新系統也許不一定是同一個程式語言,這麼一來架構上想共用 ORM 就會變得不容易,因此如果是這樣的情形: PersonName 姓名存在 A 系統, B 系統只有 PersonId ,那麼 B 如果作為 GraphQL 伺服器,你可能需要建立與 A 系統一樣的 ORM Typing,並且回傳這些資料,如果 A 系統的 ORM Typing 太多,B 系統可以取捨是否要帶出這麼完整的資料。</p><p><br /></p><p>Reference:</p><p>[1]: https://gorm.io/docs/models.html<br />[2]: https://github.com/guregu/null/issues<br />https://medium.com/@victorsteven/understanding-unit-and-integrationtesting-in-golang-ba60becb778d<br />https://github.com/carprice-tech/migorm</p>
<script src="https://cdn.jsdelivr.net/gh/gytisrepecka/brush-go/shBrushGo.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-38747179329272979792021-03-10T22:26:00.001+08:002021-03-10T22:26:23.252+08:00Gqlgen custom scalar and datetime two-way bind serialize<p> 使用 GraphQL 如果需要使用到一些自訂的型別,如同特殊的 DateTime Serialize 的狀況,可以使用 Custom Scalars,本篇文章說明如何使用 Go-gqlgen 的自訂純量 (scalars)。</p><span><a name='more'></a></span><p>預先防雷警告,以下文章只是說明純量的操作,本文章輸入出的結果只是作為範例,它必定會失敗,如果是在找 gqlgen 有沒有時間的型別,是有的。</p><p><br /></p><p>在 GraphQL Schema 檔案開頭就使用:</p><p><br /></p><p>scalar Time</p><p><br /></p><p>就可以直接在各處定義中使用 Time 這個型別,例如:</p><p>createdAt: Time!</p><p><br /></p><p>使用 gqlgen ,最好要使用 gqlgen.yml 設定檔去帶入 scalars 模組位置,差別就在於使用指令:</p><p>go run github.com/99designs/gqlgen generate</p><p>此指令時,會自動去讀取該指令所在目錄下的 gqlgen.yml 檔案。</p><p><br /></p><p>在設定之前,先說明一下該 yaml 設定檔的內容:</p><p>
</p><div style="background-color: #fffffe; font-family: SFMono-Medium, "SF Mono", "Segoe UI Mono", "Roboto Mono", "Ubuntu Mono", Menlo, monospace; font-size: 13px; line-height: 18px;"><div><span style="color: #a0a0a0; font-style: italic;"># graphql schema 所有檔案的位置</span></div><div><span style="color: teal;">schema</span>:</div><div> - <span style="color: #0451a5;">graphql/schema/*.graphql</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># generated.go 這個 interface 最後要產生在哪邊</span></div><div><span style="color: teal;">exec</span>:</div><div><span style="color: teal;"> filename</span>: <span style="color: #0451a5;">graphql/schema/generated.go</span></div><div><span style="color: teal;"> package</span>: <span style="color: #0451a5;">schema</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># 給 Apollo federation 使用的,沒有用 apollo 就不需要打開這項設定</span></div><div><span style="color: teal;">federation</span>:</div><div><span style="color: teal;"> filename</span>: <span style="color: #0451a5;">graphql/schema/federation.go</span></div><div><span style="color: teal;"> package</span>: <span style="color: #0451a5;">schema</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># models_gen.go 這個所有結構 struct 的檔案最後要產生在哪邊</span></div><div><span style="color: teal;">model</span>:</div><div><span style="color: teal;"> filename</span>: <span style="color: #0451a5;">graphql/schema/models_gen.go</span></div><div><span style="color: teal;"> package</span>: <span style="color: #0451a5;">schema</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># 需不需要自動幫忙產生預設實作的 resolvers.go 檔案 (不要可以自己關閉)</span></div><div><span style="color: #a0a0a0; font-style: italic;"># auto implements</span></div><div><span style="color: teal;">resolver</span>:</div><div><span style="color: teal;"> layout</span>: <span style="color: #0451a5;">follow-schema</span></div><div><span style="color: teal;"> dir</span>: <span style="color: #0451a5;">graphql/mar</span></div><div><span style="color: teal;"> package</span>: <span style="color: #0451a5;">graph</span></div><div><span style="color: teal;"> filename_template</span>: <span style="color: #0451a5;">"{name}.resolvers.go"</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># Optional: turn on use ` + "`" + `gqlgen:"fieldName"` + "`" + ` tags in your models</span></div><div><span style="color: #a0a0a0; font-style: italic;"># struct_tag: json</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># Optional: turn on to use []Thing instead of []*Thing</span></div><div><span style="color: #a0a0a0; font-style: italic;"># omit_slice_element_pointers: false</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># Optional: set to speed up generation time by not performing a final validation pass.</span></div><div><span style="color: #a0a0a0; font-style: italic;"># skip_validation: true</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># gqlgen will search for any type names in the schema in these go packages</span></div><div><span style="color: #a0a0a0; font-style: italic;"># if they match it will use them, otherwise it will generate them.</span></div><div><span style="color: #a0a0a0; font-style: italic;">#autobind:</span></div><div><span style="color: #a0a0a0; font-style: italic;"># - "github.com/your/app/graph/model"</span></div><br /><div><span style="color: #a0a0a0; font-style: italic;"># This section declares type mapping between the GraphQL and go type systems</span></div><div><span style="color: #a0a0a0; font-style: italic;">#</span></div><div><span style="color: #a0a0a0; font-style: italic;"># The first line in each type will be used as defaults for resolver arguments and</span></div><div><span style="color: #a0a0a0; font-style: italic;"># modelgen, the others will be allowed when binding to fields. Configure them to</span></div><div><span style="color: #a0a0a0; font-style: italic;"># your liking</span></div><div><span style="color: #a0a0a0; font-style: italic;"># 額外 (第三方以上) 的純量模組使用</span></div><div><span style="color: teal;">models</span>:</div><div><span style="color: teal;"> ID</span>:</div><div><span style="color: teal;"> model</span>:</div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.ID</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int64</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int32</span></div><div><span style="color: teal;"> Int</span>:</div><div><span style="color: teal;"> model</span>:</div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int64</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int32</span></div></div><p><br /></p><p>對於自訂純量,如果要在 graphql 檔案中,使用自訂型別 DateTime,則要先開一個 go 的檔案,使用以下設定做為參考:<br /></p><pre class="brush: go;" name="code">package scalars
import (
"fmt"
"io"
"time"
)
// 定義一個型別,叫做 DateTime (有大小寫敏感)
type DateTime struct {
t time.Time
}
func New(t time.Time) *DateTime {
return &DateTime{
t: t,
}
}
func (d *DateTime) GetTime() time.Time {
return d.t
}
// gqlgen 會在每一次 query 中自動做反序列化
func (d *DateTime) UnmarshalGQL(vi interface{}) (err error) {
v, ok := vi.(string)
if !ok {
return fmt.Errorf("unknown type of DateTime: `%+v`", vi)
}
if d.t, err = time.Parse("2006-01-02T15:04:05.000Z", v); err != nil {<br /> return err
}
return nil
}
// gqlgen 會在 query 要回傳 response query 時做序列化,把日期換成時間再丟出去
func (d DateTime) MarshalGQL(w io.Writer) {
w.Write([]byte(d.t.Format(TimeLayout)))
}
</pre><br /><p></p><p>假設這個檔案叫做 DateTime.go,那麼就把這個模組放在 gqlgen.yml 描述中:</p><div style="background-color: #fffffe; font-family: SFMono-Medium, "SF Mono", "Segoe UI Mono", "Roboto Mono", "Ubuntu Mono", Menlo, monospace; font-size: 13px; line-height: 18px;"><div><span style="color: #a0a0a0; font-style: italic;"># 額外 (第三方以上) 的純量模組使用</span></div><div><span style="color: teal;">models</span>:</div><div><span style="color: teal;"> ID</span>:</div><div><span style="color: teal;"> model</span>:</div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.ID</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int64</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int32</span></div><div><span style="color: teal;"> Int</span>:</div><div><span style="color: teal;"> model</span>:</div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int64</span></div><div> - <span style="color: #0451a5;">github.com/99designs/gqlgen/graphql.Int32</span></div><div style="line-height: 18px;"><div><span style="color: teal;"> DateTime</span>:</div><div><span style="color: teal;"> model</span>:</div><div> - <span style="color: #0451a5;">bitbucket.org/xxx/xxx.DateTime </span><span style="color: #a0a0a0; font-style: italic;"># 指向你的 package 位置,斜線到該檔案目錄,用 . 連接裡面的 Type 名稱。 (注意名稱也是有大小寫敏感)</span></div></div><p><br style="font-family: "Times New Roman"; font-size: medium;" /></p></div><p>完成後,做 gqlgen generate 就可以看到自動產生的 models_gen.go, resolvers 裡面的 struct 應該都會換成你自訂的型別。</p><p><br /></p><p>References:</p><p>https://gqlgen.com/reference/scalars/<br />https://blog.laisky.com/p/gqlgen/<br />https://medium.com/@rtw0913/%E6%B7%BA%E8%AB%87-graphql-%E8%87%AA%E8%A8%82%E7%B4%94%E9%87%8F%E8%AE%8A%E6%95%B8-70c36f9f35b8</p>
<script src="https://cdn.jsdelivr.net/gh/gytisrepecka/brush-go/shBrushGo.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-65085392729044015762021-03-02T10:08:00.002+08:002021-03-02T10:08:00.198+08:00Elixir: HTTP Router Proxy <p> 在特殊的應用情境中,客戶端請求某些資源時可能會需要透過中介 Proxy 去存取資料,本篇文章引用 【<a href="https://zespia.tw/blog/2016/07/24/build-proxy-with-elixir-and-hackney/" target="_blank">用 Elixir 和 hackney 做 proxy</a>】這篇文章所實作的 Proxy ,雖然作者最後自己寫了一個 PlugProxy 套件去取代這樣的手法,但本文就以當作練習的角度觀察 cowboy, hackney。<span></span></p><a name='more'></a><br /><p></p><p>原本文章使用的 http client 是 hackney ,他是原來 erlang 的原生套件,最後用來開啟 http proxy server 的是 cowboy 的框架。</p><p><br /></p><p>建立一個相關的專案:</p><div><span><pre class="brush: bash;" name="code">mix new proxyserver --sup
</pre></span><p></p><p><br /></p></div><p>相依性套件安裝</p><p>mix.exs:</p><p></p><pre class="brush: elixir;" name="code">defp deps do
[
{:hackney, github: "benoitc/hackney"},
{:plug_cowboy, "~> 2.0"}
]
end</pre><span><br />新增後,使用指令安裝: </span><div><span><br /><pre class="brush: bash;" name="code">mix deps.get
</pre></span><p></p><p><br /></p><p>安裝完成後,在 proxyserver/lib/proxyserver 建立一個檔案 Proxy.ex,內容是:</p><p></p><pre class="brush: elixir;" name="code">defmodule Proxyserver.Proxy do
import Plug.Conn
def init(options), do: options
# 會被任何的 http 進來的流量呼叫
def call(conn, _) do
target_method = method2atom(conn.method)
target_url = url_builder(conn)
{:ok, c} = :hackney.request(target_method, target_url, conn.req_headers, :stream, [])
conn |> handle_transfer_out(c) |> handle_transfer_back(c)
end
# 把 method 字串轉成 atom 模式: "GET" -> :get
defp method2atom(method), do: method |> String.downcase |> String.to_atom
# 製作一個字串串接的 url with query string
defp url_builder(conn) do
dist_url = "http://localhost:8888" <> conn.request_path
case conn.query_string do
"" -> dist_url
any_query_string -> dist_url <> "?" <> any_query_string
end
end
# 將內容轉手請求出去
defp handle_transfer_out(conn, client) do
case read_body(conn, []) do
# 如果是 :ok 讀完的情形,就把 conn 丟回去
{:ok, body, conn} ->
:hackney.send_body(client, body)
conn
# 如果是 :more 還沒讀完的情形,就繼續處理自己
{:more, body, conn} ->
:hackney.send_body(client, body)
handle_transfer_out(conn, client)
end
end
# 將 proxy 內容丟回去給使用者
defp handle_transfer_back(conn, client) do
{:ok, status, headers, client} = :hackney.start_response(client)
{:ok, body} = :hackney.body(client)
%{conn | resp_headers: headers} |> send_resp(status, body)
end
end</pre><span><br />然後,在 proxyserver/lib/proxyserver/ 目錄底下可以找到 application.ex ,改寫 children 內容如下</span>:</div><p></p><pre class="brush: elixir;" name="code">children = [
{Plug.Cowboy, scheme: :http, plug: Proxyserver.Proxy, options: [port: 8081]}
]</pre><span><div><span><br /></span></div><div><span><span>啟動伺服器:</span><div><span><br /><pre class="brush: bash;" name="code">mix run --no-halt</pre></span></div></span></div><div><span><br /></span></div><div>也可以用 iex 來啟動伺服器做 debug:</div><div><div><span><div><span><br /><pre class="brush: bash;" name="code">iex -S mix</pre></span></div></span></div></div><div><br /></div><br />然後,針對 localhost:8888/test.html 以取得該資料為主,在 elixir 啟動時,訪問 localhost:8081/test.html 就會看到轉手 test.html 的資料。<br /><br /></span><div><p>Reference:<br />https://zespia.tw/blog/2016/07/24/build-proxy-with-elixir-and-hackney/<br />https://github.com/benoitc/hackney/tree/master/doc<br />https://elixirforum.com/t/accessing-the-request-body-in-plug/15975/2<br />https://hexdocs.pm/plug/Plug.Conn.Adapter.html#c:read_req_body/2<br />https://github.com/tallarium/reverse_proxy_plug/tree/master/lib<br />https://github.com/elixir-plug/plug_cowboy</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushBash.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/cfeduke/shBrushElixir/shBrushElixir.js" type="text/javascript"></script></div>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-10192095239257415512021-03-01T21:42:00.002+08:002021-03-01T21:42:02.727+08:00Elixir: Plug.Cowboy Server <p>Cowboy 是 Erlang 原生的 HTTP Server Framework,但是在 Elixir 也有 Plug.Cowboy 可以支援使用原生 Erlang 的功能,建構一個簡易的 HTTP Server。<span></span></p><a name='more'></a><p></p><p>本文的目的是接續下一段文章,要將 Cowboy 的 Router 用 Proxy 撈取資料再回傳到 Requestor 的手上,在這之前,紀錄 build 一個 cowboy server 的流程。</p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">建立專案及 dependencies</span></h2><p><br /></p><p>首先,透過 mix 建立一個專案的 template (--sup 會建立一個 OTP App ,這是必要的):</p><p>
</p><pre class="brush: bash;" name="code">mix new cowserver --sup
cd cowserver
</pre><p></p><p><br /></p><p>然後,修改 mix.exs 檔案,在 deps 這個 function 中加入這個 deps 描述:</p><p></p><pre class="brush: elixir;" name="code">defp deps do
[
{:plug_cowboy, "~> 2.0"}
]
end
</pre><p><br /></p><p style="-webkit-text-stroke-width: 0px; color: black; font-family: "Times New Roman"; font-size: medium; font-style: normal; font-variant-caps: normal; font-variant-ligatures: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: left; text-decoration-color: initial; text-decoration-style: initial; text-decoration-thickness: initial; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px;"></p><p>加入完成後,就可以用 deps.get 安裝:</p><p></p><pre class="brush: bash;" name="code">mix deps.get
</pre><p></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">第一種伺服器 - 針對所有流量都回應 Hello World</span></h2><p><br /></p><p>在 cowserver/lib 底下,建立一個 hello_plug.ex 檔案,建立內容如下:</p><p></p><pre class="brush: elixir;" name="code">defmodule Cowserver.HelloPlug do
import Plug.Conn
# 初始化回傳 opts 參數
def init(opts), do: opts
# 被 user 用 http 呼叫後執行的事件
def call(conn, _opts) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, "Hello World!\n")
end
end</pre><p style="-webkit-text-stroke-width: 0px;"></p><p><br /></p><p>在 cowserver/lib/cowserver 可能會找到 application.ex ,如果沒有 (一開始 new 沒有用 --sup 的話),也可以自己新增,建立內容如下:</p><p></p><pre class="brush: elixir;" name="code">defmodule Cowserver.Application do
use Application
# 被當作常駐應用程式啟動的行為
def start(_type, _args) do
children = [
{Plug.Cowboy, scheme: :http, plug: Cowserver.HelloPlug, options: [port: 8081]}
]
opts = [strategy: :one_for_one, name: Cowserver.Supervisor]
Supervisor.start_link(children, opts)
end
end</pre><p style="-webkit-text-stroke-width: 0px;"></p><p><br /></p><p>最後,在 cowserver 目錄下修改 mix.exs 的 def application do 這個 function:</p><p></p><pre class="brush: elixir;" name="code">def application do
[
extra_applications: [:logger], #注意逗號
# 把 Application 帶進來啟動
mod: {Cowserver.Application, []}
]
end</pre><p style="-webkit-text-stroke-width: 0px;"></p><p>完成後,使用指令啟動,然後在瀏覽器打開 localhost:8081:</p><p></p><pre class="brush: bash;" name="code">mix run --no-halt
</pre><p></p><p><br /></p><h2 style="text-align: left;"><span style="font-size: x-large;">第二種伺服器 - 建立 Router 機制</span></h2><p><br /></p><p>第二種伺服器是含有 Router 機制的程式,可以直接接著上面的功能,在 cowserver/lib 這個資料夾中建立 router.ex,建立檔案內容如下:</p><p></p><pre class="brush: elixir;" name="code">defmodule Cowserver.Router do
use Plug.Router
plug :match
plug :dispatch
get "/" do
send_resp(conn, 200, "Hello World")
end
match _ do
send_resp(conn, 404, "Not Found!")
end
end</pre><p style="-webkit-text-stroke-width: 0px;"></p><p>然後,需要再次修改 application.ex 檔案中的 children 引用的 Plug,修改為:</p><p></p><pre class="brush: elixir;" name="code">defmodule Cowserver.Application do
use Application
# 被當作常駐應用程式啟動的行為
def start(_type, _args) do
children = [
# 這裡的 Cowserver.Router 是新修改的內容
{Plug.Cowboy, scheme: :http, plug: Cowserver.Router, options: [port: 8081]}
]
opts = [strategy: :one_for_one, name: Cowserver.Supervisor]
Supervisor.start_link(children, opts)
end
end</pre><p style="-webkit-text-stroke-width: 0px;"></p><p><br /></p><p>如此一來,開啟 localhost:8081/ 會回傳 Hello World,其他的路由器都會回傳 404 Not Found!</p><p><br /></p><p>Reference:</p><p>https://elixirschool.com/en/lessons/specifics/plug/<br />https://gist.github.com/bhelx/062ae2d78ab4768ab527d3db74f1869e<br />https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html#http/3<br />https://hexdocs.pm/plug/Plug.Conn.Adapter.html#c:read_req_body/2</p><p><br /></p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/SyntaxHighlighter/3.0.83/scripts/shBrushBash.js" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/gh/cfeduke/shBrushElixir/shBrushElixir.js" type="text/javascript"></script>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0tag:blogger.com,1999:blog-5363189838884304544.post-11304046819414380012021-02-28T14:15:00.004+08:002021-02-28T14:15:45.616+08:00Elixir: 引用 Erlang 模組<p> Elixir 本身支援使用 Erlang 的模組進行使用,本篇以 hackney Erlang 模組為例。<span></span></p><a name='more'></a><p></p><p><br /></p><h3 style="text-align: left;"><span style="font-size: x-large;">建立專案</span></h3><p><br /></p><p>使用 mix new [appname] 建立一個專案,並進入該資料夾修改 mix.exs 檔案最後的 deps:</p><p><script src="https://cdn.jsdelivr.net/gh/cfeduke/shBrushElixir/shBrushElixir.js" type="text/javascript"></script>
</p><pre class="brush: elixir;" name="code">defmodule Myapp.MixProject do
use Mix.Project
def project do
[
app: :myapp,
version: "0.1.0",
elixir: "~> 1.11",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
# Run "mix help compile.app" to learn about applications.
def application do
[
extra_applications: [:logger]
]
end
# Run "mix help deps" to learn about dependencies.
defp deps do
[
{:hackney, github: "benoitc/hackney"} # 這是 Erlang 的模組,
{:plug_cowboy, "~> 2.0"}
# {:dep_from_hexpm, "~> 0.3.0"},
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
]
end
end
</pre><p></p><p>其中, github 這個參數是用 repo 的帳號/repo 名稱, elixir 會自己找到並編譯。</p><p>然後,使用 mix deps.get 去進行外部模組編譯。</p><p>編譯完成後,用 iex -S mix 編譯整個專案並開啟。</p><p><br /></p><p>(一開始使用 rebar3 編譯,發現那是編譯給 erlang 的,其實應該在各自專案編譯 elixir 的版本)</p><p><br /></p><p>Reference:</p><p>https://hexdocs.pm/plug/readme.html#installation<br />https://github.com/benoitc/hackney</p>Mac Taylorhttp://www.blogger.com/profile/03325244969887757910noreply@blogger.com0