How to avoid Unicode low surrogate must follow a high surrogate

Issue description

Main issue

最近前端送來了特殊字元讓後端發出 Unicode low surrogate must follow a high surrogate. 的錯誤,該錯誤來自,🙏 這個字被前端使用 JavaScript 內建的 split 函數,切割成 \ud83d\ude4f,後端在存入 PostgreSQL 時,產生錯誤

Why the word be split to two characters

在原本的 Unicode UTF-8 中, 2003 前的版本僅支援 U+0000 ~ U+FFFF (3 byte) ,🙏 其編碼是 2003 版本所支援的 U+10000 ~ U+10FFFF (4 byte), 在 JavaScript 的 split("") 是會將其分成兩個特殊字元 \ud83d\ude4f,這邊我們稱做 surrogate pair 分別為 high surrogatelow surrogate,以下編碼來自 wiki

Number of bytes Bits for code point First code point Last code point Byte 1 Byte 2 Byte 3 Byte 4
1 7 U+0000 U+007F 0xxxxxxx
2 11 U+0080 U+07FF 110xxxxx 10xxxxxx
3 16 U+0800 U+FFFF 1110xxxx 10xxxxxx 10xxxxxx
4 21 U+10000 U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

Fix it

JavaScript

Split word with Array.from

在 JavaScript (ES5) 如果要將字元分割,但是又不想要把高於 U+FFFF 的字切成兩個字元的話,可以使用 Array.from 就可以拿到正常的字元陣列

1
2
3
4
5
console.log("U+10000~: 🙏".split(""))
//["U", "+", "1", "0", "0", "0", "0", "~", ":", " ", "�", "�"]

console.log(Array.from("U+10000~: 🙏"))
// ["U", "+", "1", "0", "0", "0", "0", "~", ":", " ", "🙏"]

Upgrade to ES6

ES6 好像已經解決該問題,所以升級就好了 (?)

Python

在後端為了防範前端亂丟字元進來,我們可以將輸入的字串重新編碼,之後再做後續的處理,此處也可以用於需要傳送特殊字元到前端 (改寫 JSONRenderer)

1
2
data = '\ud83d'
data = data.encode('utf-8', errors='backslashreplace').decode()

PS

Python 在 3.3 以後支援到 0x10FFFF 所以在拿資料時就不會切開

1
2
3
4
5
6
import sys


print(hex(sys.maxunicode)) # '0x10ffff'

[c for c in '🙏'] # ['🙏']

如果你的 sys.maxunicode0xffff (小於 Python 3.3),就會看到下方的樣子

1
2
3
4
5
6
import sys


print(hex(sys.maxunicode)) # '0xffff'

[c for c in '🙏'] # ['\ud83d', '\ude4f']

References

Other keywords

  • emoji
  • Unicode low surrogate must follow a high surrogate
  • UnicodeEncodeError: ‘utf-8’ codec can’t encode character ‘\ud83d’ in position : surrogates not allowed

How to fix no route found error on Django Channels

Issue description

Django Channels 對於不存在的路徑存取,全部會拋出錯誤,而不是一般性的警告處理,所以如果和我一樣在 Djangoo Channels 有裝上 Sentry ,而且伺服器在被惡意嘗試路徑時就會看到一堆 ValueError: No route found for path '...'. 的錯誤資訊,好處是知道被打了,壞處就是會噴錢(如果不是自己 Hosting)。

Fix it

Make HandleRouteNotFoundMiddleware for this issue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from datetime import datetime
from logging import getLogger
from django.urls.exceptions import Resolver404


logger = getLogger(__file__)


class HandleRouteNotFoundMiddleware:

def __init__(self, inner):
self.inner = inner

def __call__(self, scope):
try:
inner_instance = self.inner(scope)
return inner_instance
except (Resolver404, ValueError) as e:
if 'No route found for path' not in str(e) and \
scope["type"] not in ['http', 'websocket']:
raise e

logger.warning(
f'{datetime.now()} - {e} - {scope}'
)

if scope["type"] == "http":
return self.handle_http_route_error
elif scope["type"] == "websocket":
return self.handle_ws_route_error

async def handle_ws_route_error(self, receive, send):
await send({"type": "websocket.close"})

async def handle_http_route_error(self, receive, send):
await send({
"type": "http.response.start",
"status": 404,
"headers": {},
})
await send({
"type": "http.response.body",
"body": "",
"more_body": "",
})

Usage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from core.middleware import HandleRouteNotFoundMiddleware


application = ProtocolTypeRouter({
'websocket': AuthMiddlewareStack(
HandleRouteNotFoundMiddleware(
URLRouter(
routing.websocket_urlpatterns
)
)
),
'channel': router,
'http': HandleRouteNotFoundMiddleware(
URLRouter(
urlpatterns
)
)
})

How it works

ProtocolTypeRouter

首先我們看到在 Django Channels 我們使用的 Router,可以看到在 __init__ 時把我們對應表放進去,在被 Call 時直接把 scope 塞到對應的 Instance 一樣是執行該 Instance__call__ (或是該物件已經是 Function 可以直接執行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ProtocolTypeRouter:
"""
Takes a mapping of protocol type names to other Application instances,
and dispatches to the right one based on protocol name (or raises an error)
"""

def __init__(self, application_mapping):
self.application_mapping = application_mapping
if "http" not in self.application_mapping:
self.application_mapping["http"] = AsgiHandler

def __call__(self, scope):
if scope["type"] in self.application_mapping:
return self.application_mapping[scope["type"]](scope)
else:
raise ValueError("No application configured for scope type %r" % scope["type"])

URLRouter

依照上面所述說的,我們常在 Protocol 對應裡面放入 URLRouter 所以我們這裡就只要看 ___call__ 就好了,可以看到在最後 else 的部份,會拋出兩個錯誤,也是我們這次主要要修正的問題。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class URLRouter:
"""
Routes to different applications/consumers based on the URL path.

Works with anything that has a ``path`` key, but intended for WebSocket
and HTTP. Uses Django's django.conf.urls objects for resolution -
url() or path().
"""
# ...

def __call__(self, scope):
# Get the path
path = scope.get("path_remaining", scope.get("path", None))
if path is None:
raise ValueError("No 'path' key in connection scope, cannot route URLs")
# Remove leading / to match Django's handling
path = path.lstrip("/")
# Run through the routes we have until one matches
for route in self.routes:
try:
match = route_pattern_match(route, path)
if match:
new_path, args, kwargs = match
# Add args or kwargs into the scope
outer = scope.get("url_route", {})
return route.callback(dict(
scope,
path_remaining=new_path,
url_route={
"args": outer.get("args", ()) + args,
"kwargs": {**outer.get("kwargs", {}), **kwargs},
},
))
except Resolver404 as e:
pass
else:
if "path_remaining" in scope:
raise Resolver404("No route found for path %r." % path)
# We are the outermost URLRouter
raise ValueError("No route found for path %r." % path)

Middleware

我們要想辦法在 ProtocolTypeRouter 呼叫 URLRouter 前,想辦法抓住這個錯誤,回傳正確找不到路徑的回傳,並且寫下 Log,為此,我們參考 Django ChannelsMiddleware ,它通常被包在 URLRouter 外層,在 consumer 前後處理 scope,並參考其實做方法,最後自己刻一個專門處理此問題的 Middleware

How Django Channels middlewares work

首先我們可以看到 Django ChannelsBaseMiddleware__init__ 時,只是把它傳來的值放進 inner 這個變數,在被呼叫時 (__call__) 回傳一個可以接受 receivesend 的異步函數,這個函數會在連線近來時被建立,且將 receivesend 被丟入 epoll 監聽的事件內,供異步伺服器和 client 溝通。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class BaseMiddleware:

def __init__(self, inner):
"""
Middleware constructor - just takes inner application.
"""
self.inner = inner

def __call__(self, scope):
"""
ASGI constructor; can insert things into the scope, but not
run asynchronous code.
"""
# Copy scope to stop changes going upstream
scope = dict(scope)
# Allow subclasses to change the scope
self.populate_scope(scope)
# Call the inner application's init
inner_instance = self.inner(scope)
# Partially bind it to our coroutine entrypoint along with the scope
return partial(self.coroutine_call, inner_instance, scope)

async def coroutine_call(self, inner_instance, scope, receive, send):
"""
ASGI coroutine; where we can resolve items in the scope
(but you can't modify it at the top level here!)
"""
await self.resolve_scope(scope)
await inner_instance(receive, send)

PS

以上程式我也回在 GitHub issue 上,有任何更好的建議也希望您能發出來,幫助大家。

Create a GIN index with Django on AWS RDS

GIN

What is it

GIN 是一種 INDEX 可以幫助加速全文搜索的速度

GIN stands for Generalized Inverted Index. GIN is designed for handling cases where the items to be indexed are composite values, and the queries to be handled by the index need to search for element values that appear within the composite items. For example, the items could be documents, and the queries could be searches for documents containing specific words.

Normal SQL

在傳統 SQL 下可以用以下幾個步驟完成建立 GIN INDEX

  • Install gin extension
1
CREATE EXTENSION IF NOT EXISTS pg_trgm;
  • Create index for table’s column
    1
    2
    3
    4
    CREATE INDEX <index_name>
    ON <schema_name>.<table_name> USING gin
    (<column_name>)
    TABLESPACE pg_default;

Special type

但是如果你是特殊的欄位,例如:varchar、text,此時你就必須要給它特定的 operator 才能建立

Create GIN INDEX for varchar column
  • Use gin_trgm_ops as operator
1
2
3
4
CREATE INDEX <index_name>
ON <schema_name>.<table_name> USING gin
(<column_name COLLATE pg_catalog."default" gin_trgm_ops)
TABLESPACE pg_default;
Or, you can set gin_trgm_ops as default
  • Set default operator class
1
UPDATE pg_opclass SET opcdefault = true WHERE opcname='gin_trgm_ops';
  • Create the index like other types of column
1
2
3
4
CREATE INDEX <index_name>
ON <schema_name>.<table_name> USING gin
(<column_name>)
TABLESPACE pg_default;

Use Django Postgres contribution library

對於 PostgreSQL 有較完善的 Django 對於 GIN INDEX 也是有支援的,所以你可以在 models.py 直接使用它

1
2
3
4
5
6
7
8
9
10
from django.db import models
from django.contrib.postgres.fields import JSONField
from django.contrib.postgres.indexes import GinIndex


class Post(models.Model):
content_segment = JSONField(default=list)

class Meta:
indexes = [GinIndex(fields=['content_segment'])]

The char field in Django

在 Django 中 Char、Text 等 varchar 類型的欄位要使用 GIN 和原生 SQL 一樣需要去設定需要使用的 operator, 產出 migration file, 並且 migrate 以後你應該會看到類似下面的錯誤

1
ERROR: data type character varying has no default operator class for access method "gin"

我們很簡單的可以在 Add Index 前加上設定 default operator 去 by pass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import django.contrib.postgres.indexes
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('posts', '....'),
]

operations = [
migrations.RunSQL([
"UPDATE pg_opclass SET opcdefault = true WHERE opcname='gin_trgm_ops';",
]),
migrations.AddIndex(
model_name='post',
index=django.contrib.postgres.indexes.GinIndex(fields=['title'], name='posts_po_title_374d31_gin'),
)
]

你應該會看到下面的錯誤

1
django.db.utils.ProgrammingError: operator class "gin_trgm_ops" does not exist for access method "gin"

表示你家的 PostgreSQL 沒有安裝 GIN 的套件,這很簡單,只需要改改 migration file 就好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import django.contrib.postgres.indexes
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('posts', '....'),
]

operations = [
migrations.RunSQL([
"CREATE EXTENSION IF NOT EXISTS pg_trgm;",
"UPDATE pg_opclass SET opcdefault = true WHERE opcname='gin_trgm_ops';"
]),
migrations.AddIndex(
model_name='post',
index=django.contrib.postgres.indexes.GinIndex(fields=['title'], name='posts_po_title_374d31_gin'),
)
]

再次 migrate 相信你已經成功了

AWS RDS is secure than your local DB server

AWS RDS 預設不會給你 superuser 權限,所以你沒有辦法直接執行

1
UPDATE pg_opclass SET opcdefault = true WHERE opcname='gin_trgm_ops';

這會讓你在 Django migrate 時看到以下錯誤

1
permission denied for relation pg_opclass

Fix it

記得我們在最一開始如何使用免設定預設 operator 就產生了一個 GIN 的 index 嗎?

如法炮製我們直接把 operator 插入在 CREATE INDEX 的 SQL 就可以達到了

Source code tour

  • GINIndex forefathers
    我們可以在發現 SQL statement 是由 schema_editor_create_index_sql 產生出來的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    # django/contrib/postgres/indexes.py
    from django.db.models import Index


    class PostgresIndex(Index):
    # ...


    class GinIndex(PostgresIndex):
    def create_sql(self, model, schema_editor, using=''):
    statement = super().create_sql(model, schema_editor, using=' USING %s' % self.suffix)
    with_params = self.get_with_params()
    if with_params:
    statement.parts['extra'] = 'WITH (%s) %s' % (
    ', '.join(with_params),
    statement.parts['extra'],
    )
    return statement


    # django/db/models/indexes.py
    class Index:
    def create_sql(self, model, schema_editor, using=''):
    fields = [model._meta.get_field(field_name) for field_name, _ in self.fields_orders]
    col_suffixes = [order[1] for order in self.fields_orders]
    return schema_editor._create_index_sql(
    model, fields, name=self.name, using=using, db_tablespace=self.db_tablespace,
    col_suffixes=col_suffixes,
    )
  • How schema_edit create SQL statement

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    # django/db/backends/postgresql/schema.py
    from django.db.backends.base.schema import BaseDatabaseSchemaEditor


    class DatabaseSchemaEditor(BaseDatabaseSchemaEditor):
    # ...
    sql_create_index = "CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s)%(extra)s"


    # schema.py
    class BaseDatabaseSchemaEditor:
    def _create_index_sql(self, model, fields, *, name=None, suffix='', using='',
    db_tablespace=None, col_suffixes=(), sql=None):
    """
    Return the SQL statement to create the index for one or several fields.
    `sql` can be specified if the syntax differs from the standard (GIS
    indexes, ...).
    """
    tablespace_sql = self._get_index_tablespace_sql(model, fields, db_tablespace=db_tablespace)
    columns = [field.column for field in fields]
    sql_create_index = sql or self.sql_create_index
    table = model._meta.db_table

    def create_index_name(*args, **kwargs):
    nonlocal name
    if name is None:
    name = self._create_index_name(*args, **kwargs)
    return self.quote_name(name)

    return Statement(
    sql_create_index,
    table=Table(table, self.quote_name),
    name=IndexName(table, columns, suffix, create_index_name),
    using=using,
    columns=Columns(table, columns, self.quote_name, col_suffixes=col_suffixes),
    extra=tablespace_sql,
    )
  • Override the create_sql

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    from django.contrib.postgres.indexes import GinIndex


    class CharGinIndex(GinIndex):

    def create_sql(self, model, schema_editor, using=''):
    assert len(self.fields_orders) == 1
    original_sql = schema_editor.sql_create_index
    schema_editor.sql_create_index = 'CREATE INDEX %(name)s ON %(table)s%(using)s (%(columns)s COLLATE pg_catalog."default" gin_trgm_ops)%(extra)s'
    statement = super().create_sql(model, schema_editor, using)
    schema_editor.sql_create_index = original_sql
    return statement

我們在 create_sql 上面覆寫掉 schema_editorsql_create_index 語法,並且在呼叫完 create_sql 以後把它還原(因為一次 migrate 中 schema editor 會被重複使用,若沒有還原其他在同一次 migrate 中使用到相同 schema editor 的就會被影響)

Conclusion

在這邊用比較髒的方式處理了這個問題,主要是因為在 BaseDatabaseSchemaEditor 有以下方式可以更改 sql 參數就可以達到這功能,但是在 Index 類別並沒有把此參數讓我們可以丟進去,由於不會影響功能,暫時就不重複造輪子。

1
2
3
4
5
6
class BaseDatabaseSchemaEditor:
def _create_index_sql(self, model, fields, *, name=None, suffix='', using='',
db_tablespace=None, col_suffixes=(), sql=None):
# ...
sql_create_index = sql or self.sql_create_index
# ...

Closure

Closure

What is closure

Closure 簡單來說,就是某函數在另一個函數內被創造並且參照了創建函數的某些變數,此時該變數會存留於記憶內,儘管創建函數已經結束。

First time meet to Closure

N年前在學習 Common Lisp 時教學內出現了一個陌生又奇特的技巧,Closure,以下是他的實做

1
2
3
4
5
6
7
(let ((counter 0))
(defun reset ()
(setf counter 0))
(defun stamp ()
(setf counter (+ counter 1))))
(list (stamp) (stamp) (reset) (stamp))
; (1 2 0 1)

為了怕正常人看不懂,以下用 Python 翻譯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def gen_counter():
counter = 0

def reset():
nonlocal counter
counter = 0
return counter

def stamp():
nonlocal counter
counter += 1
return counter

return reset, stamp


reset, stamp = gen_counter()
print(stamp()) # 1
print(stamp()) # 2
print(reset()) # 0
print(stamp()) # 1

可以看出在 gen_counter 內的 兩個函數 (reset, stamp) 一同共用內部變數 counter 儘管 gen_counter 已經回傳並且結束,但是在之後的程式卻還是擁有當初初始化的 count,亦即 counter 在記憶體中不會因為 gen_counter 已經回傳就被回收。

Django Q

What is Django Q

Django 的 ORM 十分的簡易讓新手們可以簡單的寫出一般的增刪改查,但是如果要用比較進階的搜尋 (SQL WHERE CLAUSE),例如:正常人都寫的出來的 OR

1
2
SELECT * FROM post 
WHERE content LIKE '%HELLO%' OR title LIKE '%HELLO%';

在 Django ORM 就必須要使用 Q 來達成

1
2
3
4
5
6
from django.db.models import Q


Post.objects.filter(
Q(content__contains='HELLO') | Q(title__contains='HELLO')
)

其中使用 | 作為 OR 所有的 Q 就如同原本寫 ORM 的條件

Dancing with

在專案中有一項很常見的功能,就是關鍵字搜尋,很容易想像的是,如果使用一般的 SQL 就用 LIKE 慢慢組起來,但是在每個需要搜尋的功能中使用 Django Q 來組建實在很不好維護,以下利用簡化版的真實專案的案例演示 Closure 如何使它看起來更優雅

models.py

1
2
3
4
5
6
7
8
9
10
from django.db import models
from django.contrib.postgres.fields import JSONField


class Content(models.Model):
title = models.CharField(max_length=200)
title_pinyin = models.CharField(max_length=400)
tags = JSONField(default=list)
content = models.TextField()
content_pinyin = models.TextField()

sql.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from django.db.models import Q


def gen_keywords_search():
q = Q()

def icontains(contain=None):
nonlocal q
if contain:
q = (q |
Q(title__icontains=contain) |
Q(title_pinyin__icontains=contain) |
Q(tags__icontains=contain) |
Q(content__icontains=contain) |
Q(content_pinyin__icontains=contain))
return q
return icontains

views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from rest_framework.decorators import api_view

from api.page import pager

from .models import Content
from .sql import gen_keywords_search
from .serializer import PostListSerializer, SearchContentSerializer


@api_view(['post'])
def search(request):
serializer = SearchContentSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.data
qs = request.user.post_set.all()

if 'keywords' in data:
q = gen_keywords_search()
[q(key) for key in data['keywords'].split()]
qs = qs.filter(q())

return pager(
request,
qs,
PostListSerializer,
page_size=5
)

gRPC

實做一個可以寄信的 gRPC

Proto

寫一個寄發信件服務所需要的資料格式

Proto 是一個文件用來儲存 gRPC server 與 client 交換資料時鎖需要的資料格式,建議可以看看它與 JSON 的對照表來迅速了解需要怎樣寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
syntax = "proto3"; // use proto version 3

package pb; // package name

/*
Add the Send function for use
*/
service Mail{
rpc Send (MailRequest) returns (MailStatus) {}
}

/*
Declare what data you need to let server know
and server will use it to send a mail
*/
message MailRequest{
string from = 1;
repeated string to = 2;
repeated string cc = 3;
string subject = 4;
string body = 5;
string type = 6;
}

/*
Means what the mail status
be send or not
*/
message MailStatus{
int32 status = 1;
string code = 2;
}

產生 golang 的程式

1
2
go get -u github.com/golang/protobuf/protoc-gen-go
protoc --go_out=plugins=grpc:. *.proto

觀察產生出來的檔案

可以看到 MailRequest 直接幫你轉換成 golang 的 struct,還多了一些奇怪的東西,但是我們只要知道以後不管是 client 還是 server 都可以用這一些定義好的 protocol 來 import 來使用

1
2
3
4
5
6
7
8
9
10
11
type MailRequest struct {
From string `protobuf:"bytes,1,opt,name=from,proto3" json:"from,omitempty"`
To []string `protobuf:"bytes,2,rep,name=to,proto3" json:"to,omitempty"`
Cc []string `protobuf:"bytes,3,rep,name=cc,proto3" json:"cc,omitempty"`
Subject string `protobuf:"bytes,4,opt,name=subject,proto3" json:"subject,omitempty"`
Body string `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"`
Type string `protobuf:"bytes,6,opt,name=type,proto3" json:"type,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}

Server

這邊我們就可以實做一個可以寄信的服務 (此處使用 gomail 套件))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package main

import (
"log"
"net"
"os"
"time"

"golang.org/x/net/context"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"

"myMail/pb"

gomail "gopkg.in/gomail.v2"
)

type server struct{}

var ch = make(chan *gomail.Message)

/*
Send is a simple function for send email
*/
func (s *server) Send(ctx context.Context, mail *pb.MailRequest) (*pb.MailStatus, error) {
m := gomail.NewMessage()
m.SetHeader("From", mail.From)
m.SetHeader("To", mail.To...)
m.SetHeader("Subject", mail.Subject)
m.SetBody(mail.Type, mail.Body)
ch <- m
return &pb.MailStatus{Status: int32(0), Code: ""}, nil
}

func main() {
// 監聽 50051 port
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("無法監聽該埠口:%v", err)
}
s := grpc.NewServer()
pb.RegisterMailServer(s, &server{})
reflection.Register(s)
go func() {
d := gomail.NewDialer("smtp.gmail.com", 587, os.Getenv("GMAIL_ACC"), os.Getenv("GMAIL_PASS"))

var s gomail.SendCloser
var err error
open := false
for {
select {
case m, ok := <-ch:
if !ok {
return
}
if !open {
if s, err = d.Dial(); err != nil {
panic(err)
}
open = true
}
if err := gomail.Send(s, m); err != nil {
log.Print(err)
}
// Close the connection to the SMTP server if no email was sent in
// the last 30 seconds.
case <-time.After(30 * time.Second):
if open {
if err := s.Close(); err != nil {
panic(err)
}
open = false
}
}
}
}()
if err := s.Serve(lis); err != nil {
log.Fatalf("無法提供服務:%v", err)
close(ch)
}
}

Client

我們希望 client 模擬一般會用到的 http 服務,但是我們不寫邏輯在裡面,就瀏覽就呼叫 server 寄信了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package main

import (
"context"
"fmt"
"log"
"os"
"net/http"

"myMail/pb"

"google.golang.org/grpc"
)

func main() {
// 連線到遠端 gRPC 伺服器。
conn, err := grpc.Dial("server:50051", grpc.WithInsecure())
if err != nil {
log.Fatalf("連線失敗:%v", err)
}
defer conn.Close()

// 建立新的 Mail 客戶端,所以等一下就能夠使用 Mail 的所有方法。
c := pb.NewMailClient(conn)

// 傳送新請求到遠端 gRPC 伺服器 Mail 中,並呼叫 Send 函式
mr := pb.MailRequest{
From: os.Getenv("MAIL_FROM"),
To: []string{"abc@example.com"},
Cc: []string{},
Subject: "How to use gRPC",
Body: "Just done",
Type: "text/html",
}
http.HandleFunc("/send", func(w http.ResponseWriter, r *http.Request) {
ret, err := c.Send(context.Background(), &mr)
if err != nil {
log.Fatalf("無法執行 Send 函式:%v", err)
} else {
fmt.Fprintf(w, "Send %s", ret.Code)
}
})

log.Fatal(http.ListenAndServe(":8080", nil))
}

中場休息

看看我們目錄們現在長怎樣

1
2
3
4
5
6
7
8
9
tree myMail
.
├── client
│   └── main.go
├── pb
│   ├── mail.pb.go
│   └── mail.proto
└── server
  └── main.go

我們可能有幾種管理方式

  • 可以看出來我們現在其實可以拆開成為三個 git repo
    一個是 client 一個是 server 一個是 pb 由團隊們共同協同修改 pb 由個人或團隊維護單一個或多個 client(可能是某商業應用),再由個人或一個團隊維護 server (實做單純的 mail service),此時 pb 的修改將會關忽到所有人、client 的修改不會動到 mail serice,此時 mail service 團隊如果想要修改訊息格式必須要交給 pb 團隊去實現或是交付 PR 給 pb 團隊
  • 但是如果把 mail service 的 pb 單獨綁到 server 這個專案,使得最後只有 client(1~*) & server(1) 個 repo 將會發生以下事情,client 只需要安裝 pb 但是卻要把整個 service 下載下來,就算 golang 不會幫你 compiled 沒用到的東西,但是在 CI/CD 時還是會去下載那些用不到的玩意兒,在第一次使用以及後來 service 有更新時都會影響整個部屬時間

故在此我覺得第一個方案比較妥當,也比較符合 micro service 的感覺

Dockerize

其實就是包成 Docker

Dockerfile

這裡很懶惰的接受參數後 build client or server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
cat Dockerfile 
FROM golang AS build-env
ARG BUILD_PATH
ADD . /go/src/myMail
RUN cd /go/src/myMail/$BUILD_PATH && go get && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o app

FROM alpine
ARG BUILD_PATH
ARG EXPOSE_PROT
WORKDIR /app
COPY --from=build-env /go/src/myMail/$BUILD_PATH/app /app/
RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
EXPOSE ${EXPOSE_PROT}
# 50051, 8080

Build

1
2
3
4
docker build \
--build-arg BUILD_PATH=server \
--build-arg EXPOSE_PROT=50051 \
-t mailserver .
1
2
3
4
docker build \
--build-arg BUILD_PATH=client \
--build-arg EXPOSE_PROT=8080 \
-t mailclient .

K8s

拆拆拆,拆成 micro service 以後部屬變成麻煩,資料傳遞的網路也變成麻煩,K8s 可能可以幫我們少點這種麻煩,但是還不夠,這裡只的範例只其實架構還是爛爛的,少了很多微服務必要的元件,如: message queue, circuit breaker, API route…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
kind: Service
apiVersion: v1
metadata:
name: server
spec:
selector:
app: server
ports:
- port: 50051
protocol: TCP

---
apiVersion: apps/v1
kind: Deployment
metadata:
name: server-depolyment
spec:
replicas: 3
selector:
matchLabels:
app: server
template:
metadata:
labels:
app: server
spec:
containers:
- name: server
image: mailserver
ports:
- containerPort: 50051
env:
- name: GMAIL_ACC
valueFrom:
secretKeyRef:
name: gmail-acc
key: env
- name: GMAIL_PASS
valueFrom:
secretKeyRef:
name: gmail-pass
key: env

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
kind: Service
apiVersion: v1
metadata:
name: client
spec:
selector:
app: client
type: NodePort
ports:
- port: 8080
nodePort: 30290
protocol: TCP
targetPort: 8080

---
apiVersion: apps/v1
kind: Deployment
metadata:
name: client-depolyment
spec:
replicas: 3
selector:
matchLabels:
app: client
template:
metadata:
labels:
app: client
spec:
containers:
- name: client
image: mailclient
ports:
- containerPort: 8080
env:
- name: MAIL_FROM
valueFrom:
secretKeyRef:
name: gmail-acc
key: env

Try it

1
2
kubectl -f server.yaml
kubectl -f client.yaml

GolangQuickSort

用 Golang 實做快速排序 (quick sort)

快速排序是很常用的一個排序方法,下方我將會用 Golang 實做同步以及異步的快速排序。

同步

實做

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
func sort(list []int, center int) (complete []int) {
left := []int{}
right := []int{}
for _, num := range list[:center] {
if num <= list[center] {
left = append(left, num)
} else {
right = append(right, num)
}
}
if len(list) > center+1 {
for _, num := range list[center+1:] {
if num <= list[center] {
left = append(left, num)
} else {
right = append(right, num)
}
}
}
if len(left) > 1 {
left = sort(left, len(left)/2)
}
if len(right) > 1 {
right = sort(right, len(right)/2)
}

return append(append(left, list[center]), right...)
}

異步

go

Golang 強大的異步使用 goroutine 讓寫異步程式如喝水般簡單,go 不同於其他語言使用 thread 或是 process (fork) 之類的的方法,他在底層運用自己強大的 go scheduler 讓每個異步程序可以在作業系統可用執行緒改變下,依然可以執行你所要跑得程序。go-scheduler

實做

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
go func() {
fmt.Println("time need")
}
}

執行指令 go run main.go 你會發現沒東西,因為它將無名 function 放到背景後程式就結束了,為了要可以看到我們要 print 的東西我們讓它睡覺一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"time"
)

func main() {
go func() {
fmt.Println("You can see me.")
}
time.Sleep(100 * time.Millisecond)
}

此時再次執行 go run main.go 你會發現可以看到我們要輸出的字串了!

為什麼?

1
2
3
4
5
6
# 原本沒有等待的程式
--> main.go --> go func --> end
goroutine wait --> 主程式結束所以沒有 print
# 等待的程式
--> main.go --> go func --> wait...............................--> end -- 在 go func 跑完 Sleep 後才結束
goroutine wait --> print

channel

Golang 在 goroutine 中所使用的溝通媒介,類似其他語言多 threading 使用全域的 Queue

實做
1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "fmt"

func main() {
c := make(chan int)
// 在背景執行 把 100 丟到 c 裡面
go func() {
c <- 100
}()
// 從 c 裡面拿值,此處會等待 c 有值為止才會執行
Print(<-c)
}

速度比較

Golang 的測試

在 Golang 寫測試很簡單只需要在同一目錄中使用相同 package ,並且檔案名稱以 _test.go 結尾,並且把要測試的程式接收依照特殊的參數以及命名即可

1
2
3
4
5
6
7
8
// 跑 test,以 Test 當作 function 開頭並接收 (t *testing.T) 參數
func TestSomeThing(t *testing.T) {

}
// 跑 benchmark,以 Benchmark 當作 function 開頭並接收 (t *testing.B) 參數
func BenchmarkSomeThing(t testing.*B) {

}

建立測試檔案

1
2
touch $GOPATH/project/sort_test.go
code $GOPATH/project/sort_test.go

實做

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"fmt"
"testing"
)

var needSort = []int{}

func init() {
for i := 0; i < 1000000; i++ {
needSort = append(needSort, rand.Intn(1000000))
}
}

func BenchmarkSync(b *testing.B) {
// fmt.Println("call BenchmarkSync", len(needSort))
sort(needSort, len(needSort)/2)
}

func BenchmarkAsync(b *testing.B) {
// fmt.Println("call BenchmarkAsync", len(needSort))

cmp := make(chan int)
go goSort(needSort, int(len(needSort)/2), cmp)
for i := 0; i < len(needSort); i++ {
<-cmp
}
}

跑測試

執行
1
go test -bench=.
輸出

最後可以看到同步的程式 go test 幫我們跑了 2000000000 次,平均每次只要跑 0.24 ns,相較於非同步程式,跑一次就要花 6650871377 ns 快上非常多

1
2
3
4
5
6
7
8
goos: linux
goarch: amd64
pkg: gosort
BenchmarkSync-4 2000000000 0.24 ns/op
BenchmarkAsync-4 1 6650871377 ns/op
PASS
ok gosort 15.644s
go test -bench=. 27.97s user 1.55s system 185% cpu 15.910 total

結論

非同步的程式需要等待 go routing 幫它開啟一些東西,就速度上不一定會比較快,它的好處當然就是不用等它跑完,也可以分多個線程下去加快速度,但是如果沒有優化好就會像上面的程式一樣慢慢的。

GolangHTTPHandler

簡易復刻出的 Golang HTTP HandleFunc

身為一個 web 狗,用新語言寫個 router 也是應該的,Golang 本身在寫 HTTP 服務就有極大的優勢,官方自帶的 library 就很好用了,以至於到目前為止的統計大部分的人還是直接使用原生的 library 而非使用框架,但是 router 這部份就統計看來已經有了大方向, Mux 是目前大多數人使用的 router 框架,這邊我們玩一下 Golang 原生的 handler 讓它可以和原生的 HandleFunc 有一樣的感覺

第一步:寫一個簡單的 HTTP server

相信大家都不會。。。當然就是要 google

關鍵字 : golang http

第一篇就會看到官方的 library 的連結囉!

開一個新專案

1
2
3
cd $GOPATH/src/
mkdir router
touch main.go

依照官方網站的提示寫出一個簡單的 HTTP 服務

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"net/http"
"html"
"log"
)

func main() {
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})

log.Fatal(http.ListenAndServe(":8080", nil))
}

瀏覽看看

1
2
go run main.go
firefox 127.0.0.1:8080/bar

所以

我們可以知道 Golang 本身其實就可以做簡單的 router 讓對應的 URL 可以去執行你的 function,但是如果你想自己搞呢?

資料

Router 最重要的資訊其實就是 Domain 後面的 URI 或稱作 Path,所以我就用這兩個關鍵字直接打在 接收 *http.Request 的 function 裡面,發現 Path 沒有反應,但是 URI 讓 VScode 給了提示,那另一個 Path 就如同官方的教學,可以用 r.URL.Path 取得。

1
2
3
4
func main() {
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
r.RequestURI
})

Handler

官方的程式碼中有一段我沒有貼上來,原因就是貼上去會壞掉,那我們要怎麼自己來寫這所謂的 handler 呢?

1
2
fooHandler := "" // 至少要宣告吧
http.Handle("/foo", fooHandler)

看看錯誤碼吧

1
2
cannot use foohandler (type string) as type http.Handler in argument to http.Handle:
string does not implement http.Handler (missing ServeHTTP method)

看起來是少了 ServeHTTP 這個方法,所以我們需要讓 fooHandler 有這個 Method 才能跑,但是我們也不知道他要有啥才好,所以回到官方看看找到 ServeHTTP,我們就照著做一個空的 Method 試試看能不能跑。

1
2
3
4
5
6
type myhandler struct {
}

func (handle *myhandler) ServeHTTP(w http.ResponseWriter, r *http.Request){

}

跑一下

1
2
go run main.go
firefox 127.0.0.1:8080/foo

可以看到是空的,但是不會壞掉,那就試著在這個 function 裡面加點東西吧

1
fmt.Fprint(w, "r: "+r.Method+r.URL.Path)

重跑一次看看

1
2
go run main.go
firefox 127.0.0.1:8080/foo

可以看到 firefox 裡面有文字了

1
r: GET/foo

來個對應表吧

對應表

為了要簡易的復刻 HandleFunc 我們就用個簡單的 map 來儲存對應的 URL Path 到對應的 function

1
2
3
type myhandler struct {
route map[string]func(http.ResponseWriter, *http.Request)
}

以上我們定義了一個 struct 來當作我們的 handler 讓它有一個 route 的屬性,之後我們就可以讓使用者透過它來讓進來的 Request 跑去我們要的 function 裡面

註冊

有了表,我們要讓別人可以填表,所以我們在 myhandler 下面實做一個註冊用的 function

1
2
3
4
5
6
7
8
func (handle *myhandler) Register(uri string, f func(http.ResponseWriter, *http.Request)) {
// 如果還沒初始化,幫它初始化
if handle.route == nil {
handle.route = make(map[string]func(http.ResponseWriter, *http.Request))

}
handle.route[uri] = f
}

ServeHTTP

最後就是要處理進來的 Request 啦!我們只要確定近來的 URL Path 有在我們的表內,我們就可以去呼叫存在 route 內所對應的 function

1
2
3
4
5
6
func (handle *myhandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Println("r: ", r.Method, r.RequestURI) // 在 terminal 可以看到 log
if _, ok := handle.route[r.RequestURI]; ok {
handle.route[r.RequestURI](w, r)
}
}

main

最後就來玩玩我們寫好的 handler 吧!

1
2
3
4
5
6
7
8
9
10
11
func main() {
handler := new(myhandler)
handler.Register("/ping", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "pong")
})
s := &http.Server{
Addr: ":8080",
Handler: handler,
}
log.Fatal(s.ListenAndServe())
}

Try it!

1
2
go run main.go
firefox 127.0.0.1:8080/ping

結論

程式就是這樣有了資料就可以很多事情,但是一切一定都不是我們想像的那麼簡單,看看 Mux 上的功能,想想看我們要怎麼做才能完成這麼多功能呢?

如果要實做出 Middle ware 讓別人可以加,你覺得怎麼改比較好呢?

最後得程式碼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package main

import (
"fmt"
"log"
"net/http"
)

type myhandler struct {
route map[string]func(http.ResponseWriter, *http.Request)
}

func (handle *myhandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Println("r: ", r.Method, r.RequestURI)
if _, ok := handle.route[r.RequestURI]; ok {
handle.route[r.RequestURI](w, r)
}
}

func (handle *myhandler) Register(uri string, f func(http.ResponseWriter, *http.Request)) {
if handle.route == nil {
handle.route = make(map[string]func(http.ResponseWriter, *http.Request))

}
handle.route[uri] = f
}

func main() {
handler := new(myhandler)
handler.Register("/ping", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "pong")
})
s := &http.Server{
Addr: ":8080",
Handler: handler,
}
log.Fatal(s.ListenAndServe())
}

FindErrorNumber

找出錯誤的數字,使用 Golang

題目

在輸入一連串的數字中(從一開始連續[1, 2, 3, 4, 5, 6])找到錯誤(重複)的數字,並且把錯誤的先列出來再將正確的數字附加到到後面

輸入

1
2
[1,2,2,4]
[1,2,2,4,5,5,7]

輸出

1
2
[2,3]
[2,5,3,6]

實做

main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import "fmt"

func findErrorNums(nums []int) (ret []int) {
dict := make(map[int]int)
for _, num := range nums {
_, ok := dict[num]
if ok {
ret = append(ret, num)
} else {
dict[num] = num
}
}
for i := 1; i <= len(nums); i++ {
_, ok := dict[i]
if !ok {
ret = append(ret, i)
}
}
return

}

func main() {
input := []int{1, 2, 2, 4}
output := findErrorNums(input)
fmt.Println(output)
}

細解

Package

宣告這檔案在哪個 package 裡面,若是在別的 package 裡面你可以在別的 package import 後直接呼叫

  • $GOPATH/src/my/m.go

    1
    2
    3
    4
    5
    6
    7
    package my

    import "fmt"

    func Pm(str string) {
    fmt.Println("Pm: ", str)
    }
  • $GOPATH/src/my/y.go

    1
    2
    3
    4
    5
    6
    7
    package my

    import "fmt"

    func Py(str string) {
    fmt.Println("Py: ", str)
    }
  • $GOPATH/src/hello/main.go

    1
    2
    3
    4
    5
    6
    7
    8
    package main

    import "my"

    func main() {
    my.Pm("Hello")
    my.Py("Hello")
    }
1
2
3
4
cd $GOPATH/src/hello/
go run main.go
Pm: Hello
Py: Hello

import

引入別人寫的或是自己寫的 package ,你可以直接呼叫大寫開頭的 func

func

定義 function

template

1
2
3
func <(name struct_or_type)> functionName(<arg> <type>) <(auto_return_var type)> {

}

Lambda

1
2
3
func() {
// do something
}

簡易版本

1
2
3
func DoSomething() {
fmt.Println("Do something")
}

使用參數

1
2
3
func DoSomething(str string) {
fmt.Println("Do something", str)
}

使用回傳

1
2
3
func IsNil(obj interface{}) bool {
return obj==nil
}

使用自動回傳

1
2
3
4
func NotZero(num int) (ret bool) {
ret = num > 0 || num < 0
return
}

使用榜定

1
2
3
4
5
6
7
type Obj struct {
mock bool
name string
}
func (obj *Obj) IsMock() bool {
return obj.mock
}

make

建立 slicemapchan

slice

1
2
3
4
s := make([]string, 3) //建立長度為 3 的 string slice
fmt.Println("emp:", s)
fmt.Println("len:", len(s))
fmt.Println("cap:", cap(s))

map

1
2
3
m := make(map[int]string) // 建立用 int 為 key, 儲存 string 的 map
m[0] = "str"
fmt.Println(m)

chan

1
2
3
4
5
c := make(chan int, 2) // 建立長度 2 的 chan
c <- 100 // 放個 100 進去
fmt.Println(c)
obj := <-c // 從 chan 拿出東西
fmt.Println(obj)

map

一個用 key 拿值的東西

1
2
3
4
5
6
7
8
9
10
11
m := make(map[string]string) // 建立用 string 為 key, 儲存 string 的 map
m["key"] = "val"
m["k"] = "v"
fmt.Println(m)
fmt.Println(m["k"])
m["k"] = "key"
fmt.Println(m)
val, key_in_map := m["k"]
fmt.Println(val, key_in_map)
val, key_in_map := m["m"]
fmt.Println(val, key_in_map)

for

Golang 的 for 有幾個玩法

無限迴圈

1
2
3
for {

}

變數宣告與使用

template
1
2
3
for <var init>; <condition>; <after do> {

}
example
1
2
3
4
5
6
7
8
9
for i:=0; i<10; i++ {
fmt.Println("I have", i)
}

x := 20
for i:=0 ; x>10; i++ {
x--
fmt.Println("x: ", x, "i: ", i)
}

迭代

1
2
3
4
nums := []int{1, 2, 3, 4, 5, 6}
for index, num := range(nums){
fmt.Println(index, num)
}

While

1
2
3
4
5
sum := 10
for sum < 20 {
sum++
}
fmt.Println(sum)

slice

可以變更大小的,array

1
2
3
4
s := []int{1, 2, 3} // 宣告裝著 [1, 2, 3] 的 slice
fmt.Println(s)
s := append(s, 100)
fmt.Println(s)

Hello Golang

Golang

這篇文章將從頭開始說起 Golang 的基本

安裝

Golang 在 archlinux 上面安裝很簡單下以下指令

1
sudo pacman -S go

設定 Golang 基本環境變數

1
2
3
4
# super 是我的 使用者名稱
export GOPATH=/home/super/go
export GOBIN=/home/super/go/bin
export PATH=$PATH:$GOBIN

編輯器

我大部分還是習慣在 emacs 上開發,但是沒在使用 vim 或是 emacs 的人還是建議使用 vscode 比較方便

安裝 vscode

1
2
3
4
curl -O https://aur.archlinux.org/cgit/aur.git/snapshot/visual-studio-code-bin.tar.gz
tar -xvz -f visual-studio-code-bin.tar.gz
cd visual-studio-code-bin
makepkg -sir .

設定 Golang

  • 按下安裝 vscode 建議的 extension (選擇 install)
  • 重新載入 vscode (選擇 reload)

寫下你的第一支程式

進入你的家目錄中的 go/src 資料夾 裡面建立你的第一支程式的目錄 hello

1
2
mkdir -p ~/go/src/hello
cd ~/go/src/hello

用 vscode 打開

1
code main.go

按下存檔 (ctl+s),隨便打字

看到右下角叫你安裝套件把它全裝 (install all)

等它一下然後重開

從新編輯 main.go

1
2
3
4
5
6
7
8
9
package main

import (
"fmt"
)

func main() {
fmt.Println("Hello")
}

存檔 (ctl+s)

在 vscode 開啟終端機 (ctl+`) 並執行程式

1
go run main.go

備註

  • 非 archlinux 的使用者可以在 golang 官方下載 golang
  • 同上 vscode 也可以在官方下載
  • 在 vscode 上面開啟終端機 View -> Integrated Terminal