0%

專屬金魚腦的背單字小工具? ChanChan Memory

要提升一個工程師的競爭力,英文是極其被重視的技能,而單字又是英文的基礎,但對於金魚腦的我單字就像過江之鯽一下就忘,因此有了 ChanChan Memory 一個基於遺忘曲線的週期性複習小工具的誕生。

HIHI~😍 如果你是第一次來的話,『Chan-Chan-Dev』是一個專門用簡單的圖文與故事講解網路程式技術的網站。若你也喜歡用這種方式學習的話,歡迎加入 Chan-Chan-Dev Facebook 的粉絲團,在發佈的時候就有比較多機會收到通知喔!😍

前情提要

我們都知道要提升一個工程師的競爭力,英文是一個極其被重視的技能,因爲這關係到我們是否可以跟上國外最新的技術、能不能在 Stack Overflow 上找到問題的解答、更甚至關係到有沒有機會在外商公司得到 Offer 。

英文裡最重要的又是單字

但不曉得大家有沒有跟金魚腦一樣的我遇到總是單字背了就忘的困擾?😭 😭 😭

因此自己一直尋找著解決這個問題的方法,直到自己得知了遺忘曲線的概念。

關於遺忘曲線的實際內容維基百科講的比我詳細 100 倍,但用我金魚腦的翻譯就是:

我們剛學習的內容會隨著時間的推移,很快地就遺忘掉一大部分 😭 😭 😭 😭

依據維基百科上的資料可能會呈現這樣子的遺忘趨勢。

圖片來源:https://toments.com/1504944/

20分鐘後42%被遺忘掉58%被記住
1小時後56%被遺忘掉44%被記住
1天後74%被遺忘掉26%被記住
1周後77%被遺忘掉23%被記住
1個月後79%被遺忘掉21%被記住

原來我的金魚腦是有科學根據的!!😅

因此避免上述遺忘趨勢的方法也許就只能進行所謂的週期複習

圖片來源:https://memobank.pixnet.net/blog/post/24950161

也許我們學一個單字後,在一個小時後複習一次,在一天後複習一次,一週後複習一次,一個月後在複習一次,每一次的複習都會讓原本已經被遺忘的記憶得到提升,進而進入長期記憶。

但是我自己力行的結果是,如果只背一次單字後就不再繼續背新的單字似乎還滿可行的,但是如果一天背新的 10 個,第二天又背新的 10 個,又要複習第一天的,第三天繼續背新的 10 個,又要繼續複習第二天的,到了第七天後又要背新的 10 個加上第一天的 …

到最後其實自己有點難追蹤到底要哪個單字該要在哪個時間點複習,搞得我好亂阿!! 😭 😭 😭

因此有了 ChanChan Memory 的誕生。

什麼是 ChanChan Memory

ChanChan Memory 是基於遺忘曲線所沿伸的週期複習小工具 😆

功能與頁面需求

身爲這個工具的唯一使用者,預期這個小工具可以:

  • 使用者需要登入才可以使用
  • 使用者打開一個單字列表,將今天需要看到的單字顯示在上面,只要很單純地針對這些單字複習即可,不需要去管理這些單字到底下次什麼時候會在出現。
  • 使用者可以針對單字複習,系統依據使用者回覆對這個單字熟悉程度,決定下次顯示的時間。

因此針對這個需求可能需要以下的頁面與功能:

  • 登入頁面
    • 使用者登入功能
  • 新增單字頁面
    • 新增單字
  • 瀏覽現有單字頁面
    • 今日單字、本週單字、本月單字
    • 刪除、修改單字
  • 複習單字頁面
    • 更新單字學習記憶

因爲自己的使用情境可能會更常在手機裝置使用,因此只有製作手機版的頁面 😆

頁面的使用流程如下:


單字下次顯示邏輯

基於維基百科提供的時機點,我做了一點簡單的調整:

例如我今天新增一個單字 hypnosis,它會經歷以下的學習流程:

  • Level 1:在 hypnosis 被新增後,馬上就可以在今日單字清單看到
  • Level 2:在 hypnosis 上一次複習後的1 小時會出現在今日單字
  • Level 3:在 hypnosis 上一次複習後的8 小時會再次出現在今日單字
  • Level 4:在 hypnosis 上一次複習後的24 小時會再次出現在今日單字
  • Level 5:在 hypnosis 上一次複習後的7 天會再次出現在今日單字
  • Level 6:在 hypnosis 上一次複習後的 1 個月會再次出現在今日單字

當然因爲複習單字頁面有提供三個選項給使用者回答:EasyBlurryForget

如果使用者對 hypnosis 選擇了:

  • 選擇 Easy 就會進入到下一個 Level 的狀態
  • 選擇 Blurry 代表可能還沒那麼熟,那不會針對 Level 狀態調整,會繼續在隔天顯示一次
  • 選擇 Forget 代表可能完全忘記了,因此會跳回上一個 Level

以上大概就是針對 ChanChan Memory 的簡單介紹,接下來要來聊聊開發時的一些細節囉 🙂


專案使用技術

後端

  • 語言:Python / Django / Rest Framework /
  • 資料庫:MySQL
  • Web Server:Nignx

前端

  • Javascript / Vue / Vuex / Router
  • Vuetify

Vue / Vuetify

因爲這次主要想快速開發個 Prototype,因此就沒有特別在設計畫面了。

也剛好之前就有聽過 Vuetify 只是一直沒機會使用,Vuetify 已經提供了很多已經完成的 Component,可以套上就可以快速得到 70 分的效果,所以這次就用它撐一下視覺吧 😆

Django 與 Vue 開發整合

這次的後端是用 Python 與他的小夥伴 Django 完成的,後端以 API 跟前端進行溝通。

一般前後端分離的做法通常都需要兩個網址(後端 API 一個,前端網頁一個),在這次開發的過程中思考著有沒有機會讓 Vue Build 出來的 Asset 就直接放在 Django 的 static 資料夾內被讀取,這樣就可以直接用一個網址完成(其實是我懶的用兩個網址這樣 😆)

研究了一下剛好在網路上找到了這個教學影片 《Django Vue.js Integration for Production | Django casts 》,有興趣的大大可以參考一下囉。

資料物件關係

原本想用 wordmeaning 來作爲 table 與 class 的名稱,但想想還是決定用更爲抽象的 quetsionanswer 來命名,也許因爲問與答的組合也可以用在非單字以外的用途,只是剛好這個專案自己目前是用來學習記錄單字罷了。

一個 quetsion 可以有多個 answer,雖然目前只暫時用到一個,但至少保留了擴充的可能性。

另外拆出 Stage Table 記錄每個 Level 需要增加的時間。

id level time_amount
1 1 0
2 2 1
3 3 8
4 4 24
5 5 144
6 6 480

這樣子未來若希望再繼續增加下一個 Level 或者是更改每一個 Level 的時間就相對方便一些囉。

隱藏敏感資料

因爲長期用 Laravel 開發,所以很多設定都被自己以爲理所當然,例如環境變數設定的 Configuration,可以用 DotEnv 脫離版本控制。

因此當自己把資料庫從 sqlite3 轉到 MySQL 的時候就發現,MySQL 的帳號密碼居然是要放在 settings.py 的檔案裡,這個檔案會跟著 git 一起進入版本控制,因此感覺超級不對勁的!😭

還好自己不是孤單的,有好心的大大開發 python-decouple 讓我可以把這些需要隱藏的環境變數放在 .env 檔 😆

假資料

因爲在 Laravel 開發的時候習慣先快速地產生一些假資料做 API 的測試,在 Laravel 有 Model Factory,可以快速用 Seeder 或者是 Artisan Command 執行產生資料。

因此如果完成一樣的效果,自己使用了幾個套件輔助:

  • django-seed 針對 Model 設定快速產出假資料
  • faker 產生隨機資料內容

然後撰寫 Django 的 Custom Command 執行產生假資料。

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
import random

from django.core.management.base import BaseCommand, CommandError
from django_seed import Seed
from apis.models import Question, Stage, Answer


class Command(BaseCommand):
help = 'To Generate dummy data'

def add_arguments(self, parser):
parser.add_argument('count', nargs='+', type=int)

def handle(self, *args, **options):
count = options['count'][0]
seeder = Seed.seeder()
seeder.add_entity(Question, count, {
'content': lambda _: seeder.faker.text(),
'stage': lambda _: Stage.objects.get(pk=random.randint(1, 6)),
'next_show_at': lambda _: seeder.faker.date_between(start_date='-30d', end_date='+30d'),
'last_seen_at': lambda _: seeder.faker.date_between(start_date='-30d', end_date='today'),
'completed_at': lambda _: None
})

seeder.add_entity(Answer, count, {
'content': lambda _: seeder.faker.text(),
})

seeder.execute()

self.stdout.write(self.style.SUCCESS('Count Num is "%s"' % options['count']))

Serializers

因爲後端主要是純 API 的關係,所以選擇了好像滿多人使用的 Django REST framework

其中滿重要的功能就是 Serializer 。它是負責資料傳輸到前端的時候對於資料格式的整理,就像是 Laravel 的 API Resources 的功能,但此外又結合了 Validation 的功能!真是令我驚呼還可以這樣子使用,更多時候如果將 Serializer 繼承 serializers.ModelSerializer 的話,幾乎不用寫太多的 code 就可以完成了,就可以在基於 Model 上設定的條件做 Validation 了,也太方便了!😀

1
2
3
4
class AnswerSerializer(serializers.ModelSerializer):  
class Meta:
model = Answer
fields = ('id', 'content', 'created_at', 'update_at',)

優化 Database Query

在 Laravel 有 Eager Loading 避免 N + 1 的問題。

因爲在設置的物件關聯裡,當使用者要取得一組 question 的時候,會因爲在 Serializer 設定的關係一起去取得 anwser

1
2
3
4
5
6
class QuestionSerializer(serializers.ModelSerializer):
answers = AnswerSerializer(many=True, required=False)

class Meta:
model = Question
fields = '__all__'

因此在 Django 若想優化 Database 的 Query 可以使用 QuerySet 的 select_related() 或者是 prefetch_related() 來達到減少 Database Query 的效果。

1
2
3
4
5
6
7
def query_question(user, begin, end):
return Question.objects.filter(
user=user,
completed_at__isnull=True,
next_show_at__range=(begin, end)
).prefetch_related('answers')

JSON Web Token Authentication

在身份驗證的部分,因爲想要使用JSON Web Token Authentication 的方式,因此使用官方推薦的 djangorestframework-simplejwt 外掛。

這個外掛預設的情況下是以 username 作爲帳號,但是我不知道為什麼就很習慣用 Email 當帳號,因此找到了找到了某位大大寫的技術文章完成以 Email 作爲帳號的設置。

另外因爲使用 JSON Web Token 需要處理 access_token 過期而需要用 refresh_token 更新的問題,網路上也有大大寫了這篇很棒的文章可以參考。

因此目前的做法也是等到發生 401 錯誤的時候,用 refresh_token 再去更新一次 access_token 後,再將原本因爲 401 而錯誤的請求重新發送一次。

Deploy

因爲前後端分離的關係,因此每次要佈版就需要先在前端執行

1
npm run build

然後在把 build 完的內容加入後端的 git 版本控制,自己實在太懶得每次都要手動完成,因此寫了一隻小小的 Deploy bash script 完成這件事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/bash  

cd ../frontend/ || exit
npm run build
echo "Assets are Built !"

cd ../backend/ || exit
git add static/ templates/
git commit -m "build: build for production for deployment"
echo "Git commit added !"

git push
echo "Git push done"

Django 在 Server 上的 Deploy 設置從官網上看起來好像有滿多選擇的。

因爲在原本的 Digital Ocean 的機器上就已經有 Nignx 了,因此還是傾向繼續用 Nignx 到 Django 的這個流程。

只是以前在 PHP 用的是 PHP-FPM。那 Python 呢?那就是用 WSGI 囉。

因此請求的流程會如下:

1
user <---> Nignx <---> uWSGI <---> Django Project

在官網裡提供了這篇很棒的教學文章可以跟著一起設置,就可以一步步地跟著將完成的專案架設在 Server 上囉。

簡單來說就是藉由 uWSGI 在背景開一個 Socket,就可以在 Nginx 設定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
upstream django {
server unix:///path/to/your/mysite/mysite.sock; # 👈 Here
}

server {
listen 8000;
server_name example.com; # substitute your machine's IP address or FQDN
charset utf-8;

location /static {
alias /path/to/your/mysite/static;
}

location / {
uwsgi_pass django; # 👈 Put Here
include /path/to/your/mysite/uwsgi_params; # the uwsgi_params file you installed
}
}

至於怎麼生出這個 Socket 呢?可以簡單地新增自己的 uwsgi.ini

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# uwsgi.ini file
[uwsgi]

# Django-related settings
# the base directory (full path)
chdir = /path/to/your/project
# Django's wsgi file
module = project.wsgi
# the virtualenv (full path)
home = /path/to/virtualenv

# process-related settings
# master
master = true
# maximum number of worker processes
processes = 10
# the socket (use the full path to be safe
socket = /path/to/your/project/mysite.sock
# ... with appropriate permissions - may be needed
# chmod-socket = 664
# clear environment on exit
vacuum = true

然後再執行以下指令就可以囉。

1
uwsgi --ini uwsgi.ini 

但其中有一個設定有點困擾我

1
2
# the virtualenv (full path)
home = /path/to/virtualenv

因爲自己是使用 pipenv,但是網路上看到的很多都是使用 virtualenv 的用法得到這個資料夾,所以自己還真的不知道用 pipenv 的話,這個值需要放什麼 😭

後來才發現執行 pipenv shell 會得到一個路徑,結果這個路徑就是問題的解答囉。

後記

呼!簡單地把開發完比較有記憶點的內容簡單記錄一下,這也是自己的第一個 Django 小專案,也在開發過程或解 Bug 中學習到了滿多的,在中間發現自己對於 QuerySet 似乎還有很多需要學習的地方。

這篇文章的記錄的成分居多,如果你居然有看到這裡的話,真的是十分感謝你的捧場!

若文章的內容有錯誤的地方,也歡迎隨時一起討論交流。😘

最後感謝你的閱讀囉,我們下次見!Bye ~