在參與各種app業務開發的過程中,大部分都會遇到需要對某些功能/界面/數據可以靈活的管理後臺控制,客戶端根據配置變化而變化,不需要發版本就可以解決這些需求,大致功能需求就是需要提供一個後臺功能,能夠給產品/運營童鞋進行配置管理,然後通過服務端接口輸出給客戶端進行邏輯/渲染使用,這裏針對這種場景,分享一個相對通用的解決方案
項目背景
當前項目中針對這種配置的需求,每次都需要開發人員重新開發後臺表單,然後修改配置接口針對配置進行輸出,因爲這個功能的開發要歸宿到很早以前,也不知道當初爲啥要這麼做,現在存在的問題就是不容易維護和拓展,以及重複開發的成本
整理需求
- 配置管理後臺
- 支持版本控制
- 支持客戶端類型(安卓/IOS/所有)
- 表單可配置
- 配置輸出接口
- 增量下發
- 保證高可用,高穩定,高性能
- 客戶端
- 接口下發配置數據進行緩存
技術背景
- 管理後臺:php服務端+jquery+bootstrap
- 接口項目:php服務端
技術過程
-
前端技術選型:
- vuejs
- element ui
核心問題,如何後臺配置生成表單(開發人員來配置)?
初步計劃是通過配置表單的JSON生成element ui的表單,進行了一些調研,也找到可以通過配置JSON生成element ui表單的js庫,感覺靈活性差了些,而且當時還不支持富文本,感覺後續拓展也是大問題,所以棄用,後面嘗試自己來實現,通過vuejs+element ui組件相對簡單的方式實現了這個配置表單的功能,能夠支持基本需求,具體看後面代碼(簡單粗暴)
- 接口數據增量下發,以及客戶端獲取配置時機和緩存策略
客戶端每次啓動的時候去獲取一次配置,緩存【配置數據】,新增配置添加到緩存,已經存在進行替換
接口輸出【配置數據】的同時在響應頭上【timestamp】= 帶上當前請求的服務器時間戳
客戶端獲取數據,緩存【配置數據】&【timestamp】
客戶端下次請求的頭上帶上【timestamp】= 緩存的時間戳,第一次請求可以不用
服務端接收到請求的時候獲取客戶端的【timestamp】,過濾配置的時候校驗最後更新時間>=【timestamp】進行輸出【配置數據】
- 保障高可用,高穩定,高性能,容錯
配置數據進行多級緩存,第一級緩存【redis】,第二級緩存【服務器內存】(php apcu)
接口優先從【服務器內存】中獲取,如果不存在從【redis】 並同步到【服務器內存】,不存在從【mysql】 並同步到【redis】,正常後臺編輯完就同步到redis,【服務器內存】就進行短暫性的緩存(3s),保障在高併發的情況下可以快速下發,弊端就是數據變化的時候會延遲N/s後更新
客戶端在獲取緩存配置的時候如果不存在需要自己有個默認配置,極端情況下無法獲取配置的容錯機制,保障功能的正常運行
解決方案
配置管理列表界面:
配置添加和表單JSON配置界面(開發人員操作):
配置數據表單界面(產品/運營童鞋操作):
前端框架/庫:
- vuejs
- element ui 餓了麼UI
- jsoneditor json編輯組件
- VueQuillEditor vuejs富文本組件
主要的代碼內容,如下:
表設計:
-- 配置中心表
CREATE TABLE `config_center` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`title` varchar(100) NOT NULL DEFAULT '' COMMENT '標題',
`code` varchar(60) NOT NULL DEFAULT '' COMMENT '標識',
`platform` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0=所有,1=IOS,2=安卓',
`template` tinyint(4) NOT NULL DEFAULT '1' COMMENT '模板標識',
`form_json` text NOT NULL COMMENT '表單JSON',
`form_data` text NOT NULL COMMENT '表單數據',
`description` varchar(255) NOT NULL DEFAULT '' COMMENT '描述',
`app_version` varchar(15) NOT NULL DEFAULT '' COMMENT 'app版本',
`app_version_compare` varchar(10) NOT NULL DEFAULT '' COMMENT 'app版本比較符號',
`operator` varchar(20) NOT NULL DEFAULT '' COMMENT '編輯人',
`create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
`update_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新時間',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '狀態,1=有效,-1=刪除',
PRIMARY KEY (`id`),
KEY `index_code` (`code`),
KEY `index_update_at_platform` (`update_at`,`platform`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
表單配置JOSN內容:
[
{
el: "input",
type: "textarea",
name: "名字",
field: "name",
value: "6666",
rule: [
{
required: true,
message: "請輸入活動名稱",
trigger: "blur"
}
]
},
{
el: "input-number",
type: "",
name: "數字",
field: "number",
value: 1,
min:1,
max:1000,
rule: [
{
required: true,
message: "數字",
trigger: "blur"
}
]
},
{
el: "input",
type: "text",
name: "描述",
field: "desc",
value: "",
rule: [
{
required: true,
message: "請輸入活動名稱",
trigger: "blur"
}
]
},
{
el: "editor",
type: "",
name: "富文本",
field: "editor",
value: "",
rule: [
{
required: true,
message: "請輸入內容",
trigger: "blur"
}
]
},
{
el: "date",
type: "datetimerange",
name: "日期範圍",
field: "datetime",
value: ["2019-01-01 10:00:00", "2019-03-01 08:00:00"],
rule: [
{
required: true,
message: "必須",
trigger: "blur"
}
]
},
{
el: "switch",
type: "",
name: "開關",
field: "open",
value: false,
rule: [
{
required: true,
message: "必須",
trigger: "blur"
}
]
},
{
el: "date",
type: "datetime",
name: "活動時間",
field: "datet",
value: "2019-01-01"
},
{
el: "slider",
type: "",
name: "範圍",
field: "fw",
value: 0,
max: 500
},
{
el: "color",
type: "",
name: "顏色",
field: "color",
value: ""
},
{
el: "radio",
type: "",
name: "類型",
field: "type",
value: 0,
options: [
{
label: "類型1",
value: 1
},
{
label: "類型2",
value: 2
},
{
label: "類型3",
value: 3
}
]
},
{
el: "select",
type: "",
name: "食品",
field: "foods",
value: "黃金糕",
options: [
{
value: 1,
label: "黃金糕"
},
{
value: 2,
label: "雙皮奶"
},
{
value: 3,
label: "蚵仔煎"
},
{
value: 4,
label: "龍鬚麪"
},
{
value: 5,
label: "北京烤鴨"
}
]
},
{
el: "checkbox",
type: "",
name: "城市",
field: "city",
value: [0],
options: [
{
value: 1,
label: "上海"
},
{
value: 2,
label: "深圳"
},
{
value: 3,
label: "北京"
}
]
}
];
vuejs + element ui 表單模板主要代碼(簡單粗暴)
<el-form size="small" :rules="rules" ref="form" :model="form" label-width="80px">
<el-form-item v-for="(item,index) in formData" :key="item.key" :label="item.name" :prop="item.field">
<!--input 輸入框-->
<el-input v-if="item.el==='input'" :type="item.type" style="width:400px" v-model="form[item.field]"></el-input>
<!--input 數字輸入框-->
<el-input-number v-if="item.el==='input-number'" :min="item.min" :max="item.max" v-model="form[item.field]"></el-input-number>
<!--datetime 時間-->
<el-date-picker v-if="item.el==='date'" :type="item.type" v-model="form[item.field]" placeholder="選擇日期時間">
</el-date-picker>
<!--switch 開關-->
<el-switch v-if="item.el==='switch'" v-model="form[item.field]" active-text="" inactive-text="">
</el-switch>
<!--滑塊-->
<el-slider v-if="item.el==='slider'" v-model="form[item.field]" :max="item.max?item.max:100"></el-slider>
<!--顏色選擇-->
<el-color-picker v-if="item.el==='color'" v-model="form[item.field]"></el-color-picker>
<!--單選-->
<el-radio v-if="item.el==='radio'" v-for="(option,index) in item.options" :key="option.key" v-model="form[item.field]" :label="option.value">
{{ option.label }}
</el-radio>
<!--多選-->
<el-checkbox-group v-if="item.el==='checkbox'" v-model="form[item.field]">
<el-checkbox v-for="(option,index) in item.options" :key="option.value" :label="option.value">
{{ option.label }}
</el-checkbox>
</el-checkbox-group>
<!--選擇器-->
<el-select v-if="item.el==='select'" v-model="form[item.field]" placeholder="請選擇">
<el-option v-for="option in item.options" :key="option.value" :label="option.label" :value="option.value">
</el-option>
</el-select>
<!-- 富文本-->
<quill-editor v-if="item.el==='editor'" v-model="form[item.field]"></quill-editor>
</el-form-item>
</el-form>
js代碼:
//富文本組件
Vue.use(VueQuillEditor);
$vm = new Vue({
el: "#app",
data: {
template: "1",
form: {},
rules: {},
formData: {},
},
methods: {
useTemplate: function () {
switch (this.template) {
case "1": {
var formJson = [];
if (this.config['form_json']) {
formJson = this.config['form_json'];
} else if (templateOneJson) {
formJson = templateOneJson;
}
editorJson(formJson);
this.createForm(formJson);
return
}
}
},
//預覽
review: function () {
var jsonData = editor.get();
this.createForm(jsonData);
},
//根據配置的JSON,解析出構造表單需要的Vue數據
getData: function (json) {
var data = {
//表單數據
form: {},
//表單驗證規則
rules: {},
//表單控件配置
formData: {}
};
//構造數據
for (var index in json) {
var item = json[index];
data.form[item.field] = item.value;
if (item.rule) data.rules[item.field] = item.rule;
}
data.formData = json;
return data;
},
//創建表單Vue對象
formVue: function (data) {
Vue.set($vm, "form", data.form);
Vue.set($vm, "rules", data.rules);
Vue.set($vm, "formData", data.formData);
// $vm.$forceUpdate();
},
//根據配置JSON生成Form表單
createForm: function (json) {
var data = this.getData(json);
console.log(data);
this.formVue(data);
}
}
});
前端部分因爲基於原有項目技術背景拓展,用最原始的link引入方式,而且沒有拉到前端同學參與,前端部分如果可以把後臺功能進行前後端分離,然後基於組件化封裝那就最好不過了,存後端童鞋折騰想想就好,low了點,能用哈,不過不影響基本實現思路可借鑑參考
總結
當你在開發產品需求時候,除了要解決眼前的問題,是否有思考過之前或者將來也會遇到很多類似的問題。把你的解決方案從解決一個問題擴展到解決一類問題是一項非常重要的能力,也往往是區分新人與資深技術人員的一條分界線
首發於Github🌈大話WEB開發,歡迎Star 🥰