前言
關於 FlutterGo 或許不用太多介紹了。
如果有第一次聽說的小伙伴,可以移步FlutterGo官網查看下簡單介紹.
FlutterGo 在這次迭代中有了不少的更新,筆者在此次的更新中,負責開發后端以及對應的客戶端部分。這里簡單介紹下關於 FlutterGo 后端代碼中幾個功能模塊的實現。
總體來說,FlutterGo 后端並不復雜。此文中大概介紹以下幾點功能(接口)的實現:
- FlutterGo 登陸功能
- 組件獲取功能
- 收藏功能
- 建議反饋功能
環境信息
阿里雲 ECS 雲服務器
Linux iz2ze3gw3ipdpbha0mstybz 3.10.0-957.21.3.el7.x86_64 #1 SMP Tue Jun 18 16:35:19 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
mysql :mysql Ver 8.0.16 for Linux on x86_64 (MySQL Community Server - GPL)
node:v12.5.0
開發語言:midway
+ typescript
+ mysql
代碼結構:
src
├─ app
│ ├─ class 定義表結構
│ │ ├─ app_config.ts
│ │ ├─ cat.ts
│ │ ├─ collection.ts
│ │ ├─ user.ts
│ │ ├─ user_collection.ts
│ │ └─ widget.ts
│ ├─ constants 常量
│ │ └─ index.ts
│ ├─ controller
│ │ ├─ app_config.ts
│ │ ├─ auth.ts
│ │ ├─ auth_collection.ts
│ │ ├─ cat_widget.ts
│ │ ├─ home.ts
│ │ ├─ user.ts
│ │ └─ user_setting.ts
│ ├─ middleware 中間件
│ │ └─ auth_middleware.ts
│ ├─ model
│ │ ├─ app_config.ts
│ │ ├─ cat.ts
│ │ ├─ collection.ts
│ │ ├─ db.ts
│ │ ├─ user.ts
│ │ ├─ user_collection.ts
│ │ └─ widget.ts
│ ├─ public
│ │ └─ README.md
│ ├─ service
│ │ ├─ app_config.ts
│ │ ├─ cat.ts
│ │ ├─ collection.ts
│ │ ├─ user.ts
│ │ ├─ user_collection.ts
│ │ ├─ user_setting.ts
│ │ └─ widget.ts
│ └─ util 工具集
│ └─ index.ts
├─ config 應用的配置信息
│ ├─ config.default.ts
│ ├─ config.local.ts
│ ├─ config.prod.ts
│ └─ plugin.ts
└─ interface.ts
登陸功能
首先在class/user.ts
中定義一個 user
表結構,大概需要的字段以及在 interface.ts
中聲明相關接口。這里是 midway
和 ts
的基礎配置,就不展開介紹了。
FlutterGo 提供了兩種登陸方式:
- 用戶名、密碼登陸
GitHubOAuth
認證
因為是手機客戶端的 GitHubOauth
認證,所以這里其實是有一些坑的,后面再說。這里我們先從簡單的開始說起
用戶名/密碼登陸
因為我們使用 github 的用戶名/密碼登陸方式,所以這里需要羅列下 github 的 api:developer.github.com/v3/auth/,
文檔中的核心部分:curl -u username https://api.github.com/user
(大家可以自行在 terminal 上測試),回車輸入密碼即可。所以這里我們完全可以在拿到用戶輸入的用戶名和密碼后進行 githu 的認證。
關於 midway 的基本用法,這里也不再贅述了。整個過程還是非常簡單清晰的,如下圖:
相關代碼實現(相關信息已脫敏:xxx):
service
部分
//獲取 userModel
@inject()
userModel
// 獲取 github 配置信息
@config('githubConfig')
GITHUB_CONFIG;
//獲取請求上下文
@inject()
ctx;
//githubAuth 認證
async githubAuth(username: string, password: string, ctx): Promise<any> {
return await ctx.curl(GITHUB_OAUTH_API, {
type: 'GET',
dataType: 'json',
url: GITHUB_OAUTH_API,
headers: {
'Authorization': ctx.session.xxx
}
});
}
// 查找用戶
async find(options: IUserOptions): Promise<IUserResult> {
const result = await this.userModel.findOne(
{
attributes: ['xx', 'xx', 'xx', 'xx', 'xx', "xx"],//相關信息脫敏
where: { username: options.username, password: options.password }
})
.then(userModel => {
if (userModel) {
return userModel.get({ plain: true });
}
return userModel;
});
return result;
}
// 通過 URLName 查找用戶
async findByUrlName(urlName: string): Promise<IUserResult> {
return await this.userModel.findOne(
{
attributes: ['xxx', 'xxx', 'xxx', 'xxx', 'xxx', "xxx"],
where: { url_name: urlName }
}
).then(userModel => {
if (userModel) {
return userModel.get({ plain: true });
}
return userModel;
});
}
// 創建用戶
async create(options: IUser): Promise<any> {
const result = await this.userModel.create(options);
return result;
}
// 更新用戶信息
async update(id: number, options: IUserOptions): Promise<any> {
return await this.userModel.update(
{
username: options.username,
password: options.password
},
{
where: { id },
plain: true
}
).then(([result]) => {
return result;
});
}
controller
// inject 獲取 service 和加密字符串
@inject('userService')
service: IUserService
@config('random_encrypt')
RANDOM_STR;
流程圖中邏輯的代碼實現
GitHubOAuth 認證
這里有坑!我回頭介紹
githubOAuth 認證就是我們常說的 github app 了,這里我直接了當的丟文檔:creating-a-github-app
筆者還是覺得文檔類的無需介紹
當然,我這里肯定都建好了,然后把一些基本信息都寫到 server 端的配置中
還是按照上面的套路,咱們先介紹流程。然后在說坑在哪。
客戶端部分
客戶端部分的代碼就相當簡單了,新開 webView ,直接跳轉到 github.com/login/oauth/authorize
帶上 client_id
即可。
server 端
整體流程如上,部分代碼展示:
service
//獲取 github access_token
async getOAuthToken(code: string): Promise<any> {
return await this.ctx.curl(GITHUB_TOKEN_URL, {
type: "POST",
dataType: "json",
data: {
code,
client_id: this.GITHUB_CONFIG.client_id,
client_secret: this.GITHUB_CONFIG.client_secret
}
});
}
controller
代碼邏輯就是調用 service 中的數據來走上面流程圖中的信息。
OAuth 中的坑
其實,github app 的認證方式非常適用於瀏覽器環境下,但是在 flutter 中,由於我們是新開啟的 webView 來請求的 github 登陸地址。當我們后端成功返回的時候,無法通知到 Flutter 層。就導致我自己的 Flutter 中 dart 寫的代碼,無法拿到接口的返回。
中間腦暴了很多解決辦法,最終在查閱 flutter_webview_plugin 的 API 里面找了個好的方法:onUrlChanged
簡而言之就是,Flutter 客戶端部分新開一個 webView去請求 github.com/login
,github.com/login
檢查 client_id
后會帶着code 等亂七八糟的東西來到后端,后端校驗成功后,redirect Flutter 新開的 webView,然后flutter_webview_plugin
去監聽頁面 url 的變化。發送相關 event ,讓Flutter 去 destroy 當前 webVIew,處理剩余邏輯。
Flutter 部分代碼
//定義相關 OAuth event
class UserGithubOAuthEvent{
final String loginName;
final String token;
final bool isSuccess;
UserGithubOAuthEvent(this.loginName,this.token,this.isSuccess);
}
webView page
:
//在 initState 中監聽 url 變化,並emit event
flutterWebviewPlugin.onUrlChanged.listen((String url) {
if (url.indexOf('loginSuccess') > -1) {
String urlQuery = url.substring(url.indexOf('?') + 1);
String loginName, token;
List<String> queryList = urlQuery.split('&');
for (int i = 0; i < queryList.length; i++) {
String queryNote = queryList[i];
int eqIndex = queryNote.indexOf('=');
if (queryNote.substring(0, eqIndex) == 'loginName') {
loginName = queryNote.substring(eqIndex + 1);
}
if (queryNote.substring(0, eqIndex) == 'accessToken') {
token = queryNote.substring(eqIndex + 1);
}
}
if (ApplicationEvent.event != null) {
ApplicationEvent.event
.fire(UserGithubOAuthEvent(loginName, token, true));
}
print('ready close');
flutterWebviewPlugin.close();
// 驗證成功
} else if (url.indexOf('${Api.BASE_URL}loginFail') == 0) {
// 驗證失敗
if (ApplicationEvent.event != null) {
ApplicationEvent.event.fire(UserGithubOAuthEvent('', '', true));
}
flutterWebviewPlugin.close();
}
});
login page
:
//event 的監聽、頁面跳轉以及提醒信息的處理
ApplicationEvent.event.on<UserGithubOAuthEvent>().listen((event) {
if (event.isSuccess == true) {
// oAuth 認證成功
if (this.mounted) {
setState(() {
isLoading = true;
});
}
DataUtils.getUserInfo(
{'loginName': event.loginName, 'token': event.token})
.then((result) {
setState(() {
isLoading = false;
});
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(builder: (context) => AppPage(result)),
(route) => route == null);
}).catchError((onError) {
print('獲取身份信息 error:::$onError');
setState(() {
isLoading = false;
});
});
} else {
Fluttertoast.showToast(
msg: '驗證失敗',
toastLength: Toast.LENGTH_SHORT,
gravity: ToastGravity.CENTER,
timeInSecForIos: 1,
backgroundColor: Theme.of(context).primaryColor,
textColor: Colors.white,
fontSize: 16.0);
}
});
組件樹獲取
表結構
在聊接口實現的之前,我們先了解下,關於組件,我們的表機構設計大概是什么樣子的。
FlutterGO 下面 widget tab很多分類,分類點進去還是分類,再點擊去是組件,組件點進去是詳情頁。
上圖模塊點進去就是組件 widget
上圖是 widget,點進去是詳情頁
所以這里我們需要兩張表來記錄他們的關系:cat(category)和 widget 表。
cat 表中我們每行數據會有一個 parent_id
字段,所以表內存在父子關系,而 widget
表中的每一行數據的 parent_id
字段的值必然是 cat
表中的最后一層。比如 Checkbox
widget
的 parent_id
的值就是 cat
表中 Button
的 id。
需求實現
在登陸的時候,我們希望能獲取所有的組件樹,需求方要求結構如下:
[
{
"name": "Element",
"type": "root",
"child": [
{
"name": "Form",
"type": "group",
"child": [
{
"name": "input",
"type": "page",
"display": "old",
"extends": {},
"router": "/components/Tab/Tab"
},
{
"name": "input",
"type": "page",
"display": "standard",
"extends": {},
"pageId": "page1_hanxu_172ba42f_0520_401e_b568_ba7f7f6835e4"
}
]
}
],
}
]
因為現在存在三方共建組件,而且我們詳情頁也較FlutterGo 1.0 版本有了很大改動,如今組件的詳情頁只有一個,內容全部靠 md 渲染,在 md 中寫組件的 demo 實現。所以為了兼容舊版本的 widget,我們有 display
來區分,新舊 widget
分別通過 pageId
和 router
來跳轉頁面。
新建 widget 的 pageId 是通過FlutterGo 腳手架 goCli生成的
目前實現實際返回為:
{
"success": true,
"data": [
{
"id": "3",
"name": "Element",
"parentId": 0,
"type": "root",
"children": [
{
"id": "6",
"name": "Form",
"parentId": 3,
"type": "category",
"children": [
{
"id": "9",
"name": "Input",
"parentId": 6,
"type": "category",
"children": [
{
"id": "2",
"name": "TextField",
"parentId": "9",
"type": "widget",
"display": "old",
"path": "/Element/Form/Input/TextField"
}
]
},
{
"id": "12",
"name": "Text",
"parentId": 6,
"type": "category",
"children": [
{
"id": "3",
"name": "Text",
"parentId": "12",
"type": "widget",
"display": "old",
"path": "/Element/Form/Text/Text"
},
{
"id": "4",
"name": "RichText",
"parentId": "12",
"type": "widget",
"display": "old",
"path": "/Element/Form/Text/RichText"
}
]
},
{
"id": "13",
"name": "Radio",
"parentId": 6,
"type": "category",
"children": [
{
"id": "5",
"name": "TestNealya",
"parentId": "13",
"type": "widget",
"display": "standard",
"pageId": "page1_hanxu_172ba42f_0520_401e_b568_ba7f7f6835e4"
}
]
}
]
}
]
}
{
"id": "5",
"name": "Themes",
"parentId": 0,
"type": "root",
"children": []
}
]
}
簡單示例,省去 99%數據
代碼實現
其實這個接口也是非常簡單的,就是個雙循環遍歷嘛,准確的說,有點類似深度優先遍歷。直接看代碼吧
獲取所有 parentId 相同的 category (后面簡稱為 cat)
async getAllNodeByParentIds(parentId?: number) {
if (!!!parentId) {
parentId = 0;
}
return await this.catService.getCategoryByPId(parentId);
}
首字母轉小寫
firstLowerCase(str){
return str[0].toLowerCase()+str.slice(1);
}
我們只要自己外部維護一個組件樹,然后cat
表中的讀取到的每一個parent_id
都是一個節點。當前 id
沒有別的 cat
對應的 parent_id
就說明它的下一級是“葉子” widget
了,所以就從 widget
中查詢即可。easy~
//刪除部分不用代碼
@get('/xxx')
async getCateList(ctx) {
const resultList: IReturnCateNode[] = [];
let buidList = async (parentId: number, containerList: Partial<IReturnCateNode>[] | Partial<IReturnWidgetNode>[], path: string) => {
let list: IReturnCateNode[] = await this.getAllNodeByParentIds(parentId);
if (list.length > 0) {
for (let i = 0; i < list.length; i++) {
let catNode: IReturnCateNode;
catNode = {
xxx:xxx
}
containerList.push(catNode);
await buidList(list[i].id, containerList[i].children, `${path}/${this.firstLowerCase(containerList[i].name)}`);
}
} else {
// 沒有 cat 表下 children,判斷是否存在 widget
const widgetResult = await this.widgetService.getWidgetByPId(parentId);
if (widgetResult.length > 0) {
widgetResult.map((instance) => {
let tempWidgetNode: Partial<IReturnWidgetNode> = {};
tempWidgetNode.xxx = instance.xxx;
if (instance.display === 'old') {
tempWidgetNode.path = `${path}/${this.firstLowerCase(instance.name)}`;
} else {
tempWidgetNode.pageId = instance.pageId;
}
containerList.push(tempWidgetNode);
});
} else {
return null;
}
}
}
await buidList(0, resultList, '');
ctx.body = { success: true, data: resultList, status: 200 };
}
彩蛋
FlutterGo 中有一個組件搜索功能,因為我們存儲 widget
的時候,並沒有強制帶上該 widget
的路由,這樣也不合理(針對於舊組件),所以在widget
表中搜索出來,還要像上述過程那樣逆向搜索獲取“舊”widget
的router
字段
我的個人代碼實現大致如下:
@get('/xxx')
async searchWidget(ctx){
let {name} = ctx.query;
name = name.trim();
if(name){
let resultWidgetList = await this.widgetService.searchWidgetByStr(name);
if(xxx){
for(xxx){
if(xxx){
let flag = true;
xxx
while(xxx){
let catResult = xxx;
if(xxx){
xxx
if(xxx){
flag = false;
}
}else{
flag = false;
}
}
resultWidgetList[i].path = path;
}
}
ctx.body={success:true,data:resultWidgetList,message:'查詢成功'};
}else{
ctx.body={success:true,data:[],message:'查詢成功'};
}
}else{
ctx.body={success:false,data:[],message:'查詢字段不能為空'};
}
}
求大神指教最簡實現~🤓
收藏功能
收藏功能,必然是跟用戶掛鈎的。然后收藏的組件該如何跟用戶掛鈎呢?組件跟用戶是多對多
的關系。
這里我新建一個collection
表來用作所有收藏過的組件。為什么不直接使用widget
表呢,因為我個人不希望表太過於復雜,無用的字段太多,且功能不單一。
由於是收藏的組件和用戶是多對多的關系,所以這里我們需要一個中間表user_collection
來維護他兩的關系,三者關系如下:
功能實現思路
-
校驗收藏
- 從
collection
表中檢查用戶傳入的組件信息,沒有則為收藏、有則取出其在collection
表中的 id - 從
session
中獲取用戶的 id - 用
collection_id
和user_id
來檢索user_collection
表中是否有這個字段
- 從
-
添加收藏
- 獲取用戶傳來的組件信息
findOrCrate
的檢索collection
表,並且返回一個collection_id
- 然后將
user_id
和collection_id
存入到user_collection
表中(互不信任原則,校驗下存在性)
-
移除收藏
- 步驟如上,拿到
collection
表中的collection_id
- 刪除
user_collection
對應字段即可
- 步驟如上,拿到
-
獲取全部收藏
- 檢索
collection
表中所有user_id
為當前用戶的所有collection_id
- 通過拿到的
collection_id
s 來獲取收藏的組件列表
- 檢索
部分代碼實現
整體來說,思路還是非常清晰的。所以這里我們僅僅拿收藏和校驗來展示下部分代碼:
service
層代碼實現
@inject()
userCollectionModel;
async add(params: IuserCollection): Promise<IuserCollection> {
return await this.userCollectionModel.findOrCreate({
where: {
user_id: params.user_id, collection_id: params.collection_id
}
}).then(([model, created]) => {
return model.get({ plain: true })
})
}
async checkCollected(params: IuserCollection): Promise<boolean> {
return await this.userCollectionModel.findAll({
where: { user_id: params.user_id, collection_id: params.collection_id }
}).then(instanceList => instanceList.length > 0);
}
controller
層代碼實現
@inject('collectionService')
collectionService: ICollectionService;
@inject()
userCollectionService: IuserCollectionService
@inject()
ctx;
// 校驗組件是否收藏
@post('/xxx')
async checkCollected(ctx) {
if (ctx.session.userInfo) {
// 已登錄
const collectionId = await this.getCollectionId(ctx.request.body);
const userCollection: IuserCollection = {
user_id: this.ctx.session.userInfo.id,
collection_id: collectionId
}
const hasCollected = await this.userCollectionService.checkCollected(userCollection);
ctx.body={status:200,success:true,hasCollected};
} else {
ctx.body={status:200,success:true,hasCollected:false};
}
}
async addCollection(requestBody): Promise<IuserCollection> {
const collectionId = await this.getCollectionId(requestBody);
const userCollection: IuserCollection = {
user_id: this.ctx.session.userInfo.id,
collection_id: collectionId
}
return await this.userCollectionService.add(userCollection);
}
因為常要獲取 collection
表中的 collection_id
字段,所以這里抽離出來作為公共方法
async getCollectionId(requestBody): Promise<number> {
const { url, type, name } = requestBody;
const collectionOptions: ICollectionOptions = {
url, type, name
};
const collectionResult: ICollection = await this.collectionService.findOrCreate(collectionOptions);
return collectionResult.id;
}
feedback 功能
feedback 功能就是直接可以在 FlutterGo 的個人設置中,發送 issue 到 Alibaba/flutter-go 下。這里主要也是調用 github 的提 issue 接口 api issues API。
后端的代碼實現非常簡單,就是拿到數據,調用 github 的 api 即可
service
層
@inject()
ctx;
async feedback(title: string, body: string): Promise<any> {
return await this.ctx.curl(GIHTUB_ADD_ISSUE, {
type: "POST",
dataType: "json",
headers: {
'Authorization': this.ctx.session.headerAuth,
},
data: JSON.stringify({
title,
body,
})
});
}
controller
層
@inject('userSettingService')
settingService: IUserSettingService;
@inject()
ctx;
async feedback(title: string, body: string): Promise<any> {
return await this.settingService.feedback(title, body);
}
彩蛋
猜測可能會有人 FlutterGo 里面這個 feedback 是用的哪一個組件~這里介紹下
pubspec.yaml
zefyr:
path: ./zefyr
因為在開發的時候,flutter 更新了,導致zefyr 運行報錯。當時也是提了 issue:chould not Launch FIle (寫這篇文章的時候才看到回復)
但是當時由於功能開發要發布,等了好久沒有zefyr
作者的回復。就在本地修復了這個 bug,然后包就直接引入本地的包了。
共建計划
咳咳,敲黑板啦~~
Flutter 依舊在不斷地更新,但僅憑我們幾個 Flutter 愛好者在工作之余維護 FlutterGo 還是非常吃力的。所以這里,誠邀業界所有 Flutter 愛好者一起參與共建 FlutterGo!
此處再次感謝所有已經提交 pr 的小伙伴
共建說明
由於 Flutter 版本迭代速度較快,產生的內容較多, 而我們人力有限無法更加全面快速的支持Flutter Go的日常維護迭代, 如果您對flutter go的共建感興趣, 歡迎您來參與本項目的共建.
凡是參與共建的成員. 我們會將您的頭像與github個人地址收納進我們的官方網站中.
共建方式
- 共建組件
-
本次更新, 開放了 Widget 內容收錄 的功能, 您需要通過 goCli 工具, 創建標准化組件,編寫markdown代碼。
-
為了更好記錄您的改動目的, 內容信息, 交流過程, 每一條PR都需要對應一條 Issue, 提交你發現的
BUG
或者想增加的新功能
, 或者想要增加新的共建組件, -
首先選擇你的
issue
在類型,然后通過 Pull Request 的形式將文章內容, api描述, 組件使用方法等加入進我們的Widget界面。
- 提交文章和修改bug
- 您也可以將例如日常bug. 未來feature等的功能性PR, 申請提交到我們的的主倉庫。
參與共建
關於如何提PR請先閱讀以下文檔
貢獻指南
此項目遵循貢獻者行為准則。參與此項目即表示您同意遵守其條款.
FlutterGo 期待你我共建~
具體 pr 細節和流程可參看 FlutterGo README 或 直接釘釘掃碼入群
學習交流
關注公眾號: 【全棧前端精選】 每日獲取好文推薦。
公眾號內回復 【1】,加入全棧前端學習群,一起交流。