前些日子写完了第一篇设计模式Go设计模式(1)-语法。本篇文章主要讲解遇到项目之后如何做面向对象分析与设计。这篇文章的很多思路都来自于王争的[设计模式之美],不过我会按照自己的经验和构思来进行讲解。
很多同学都看过设计模式相关的书籍,但是经常很难用到实际工作中,其中一个原因可能是没有想过如何将具体的业务转化为面向对象的流程。一旦我们熟知这个流程,就能从面向过程思维进入到面向对象思维,然后就可以用学到的设计思想对业务进行设计与优化。
业务
作为一个努力向上的程序员,一天leader告诉你:“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发,争取尽快上线。”
面向对象分析(OOA)
面向对象分析的产出是详细的需求描述。
第一轮基础分析
第一轮分析主要是分析一下具体需求是什么,可以采取什么技术方案解决。
通过和Leader细聊后,发现是自己组维护的后台系统需要提供几个API接口供其他组调用,这些接口返回的数据比较敏感,必须做安全校验。
突然碰到这个问题可能有点懵,可以思考一下有什么方案,如果没有思路,可以和同事讨论一下,也可以看看团队以前是怎么做的,或者网上搜索一下常规方案是怎样的。
公司内部常用的API鉴权方案是X5协议,查看网上信息,一般用签名方案。
X5协议示例:
appid和appkey
appid
和appkey
在使用前向管理员申请appkey
仅在计算时使用,如有泄露可随时更换。
接口传输数据$sendData格式定义:
<?php
array(
'header'=>array(
'appid'=>$appid,
'url'=>$url,
'method'=>$method,
'sign'=>$sign,
),
'body'=>$body,
);
?>
sign字段算法
将appid
,对应接口请求报文json串,appkey
先后拼接,对其执行32位MD5,之后转为大写
$sign=strtoupper(md5($appid.$reqBody.$appkey))
拼接请求报文
将已有参数按如下格式拼接,之后进行base64编码,即可得到最终的requestbody。
$encRequest=base64_encode(json_encode($reqStream));
根据目前的情况,选择X5协议实现鉴权。主要是因为这是公司内部协议,每个组都比较熟悉,今后对接、联调、扩展都相对容易一些。
第二轮分析优化
这一轮需要对技术方案细化,并出流程图。
一个比较简单的方案是我们过一下整个流程,然后对流程中的各个细节进行提问。
- 调用方申请appid和appkey
- 调用方使用appid和appkey,计算出sign,并对请求参数进行base64编码
- 调用方向服务方发送请求
- 服务方将数据解码,重新计算sign,判断sign是否相等
仔细分析流程,我们需要考虑如下几个问题:
appid和appkey如何申请和存放
- 随着调用方增多,我们需要查看有哪些appid、被分配给哪些组
- appid和appkey方便修改,最好调用方和服务方能同时生效
- 是否需要针对不同API设置不同appid和appkey
很多系统或者接口都有鉴权需求
- 如何设计的更加通用,对业务侵入性最低
- 是否需要提供SDK,供其它系统使用
- 是否需要考虑重放攻击
这么一想,如果按照最全的方式来做,工作量还是挺大的。鉴于实际情况,我们先完成简单版,完全版可以等业务量起来时优化。所以对于上面的问题,我们做如下选择
- appid和appkey存放到服务端配置文件中,调用方自己维护申请到的appid和appkey,按照系统维度提供appid和appkey。后期可使用配置中心、ETCD或者Redis来管理appid和appkey。
- 本次只需要要供本系统使用,但设计的需要通用,对服务侵入性要低。后期调用方增多后,可提供SDK。
- 因为都是内网调用,相对安全,先不考虑重放攻击。
流程图
重点回顾
有时候需求比较抽象、模糊,需要自己去挖掘做取舍,产生清晰、可落地的需求定义。需求分析是一个迭代过程,可以现有一个大体的方案,然后一步一步细化。在这个过程中,可以先列出大体的流程,然后多提问题,多回答这些问题,最后一定要有产出。
说来也有意思,前些日子安排一个同学做了相似的工作,但紧紧是完成任务而已,而没有去思考通用性、扩展性这些问题。其实也比较容易理解,仿照前人做的东西写要容易的多,但这样就很难提升自己的能力和系统架构了。也希望大家今后做项目的时候能够多思考。
面向对象设计
面向对象设计的产出是类。
划分职责进而识别出有哪些类
实现这一步,可以根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,可否应该归为同一个类。
功能点列表:
- 利用appid和appkey,对请求参数进行加密生成sign
- 将请求数据进行base64编码
- 使用base64对请求数据进行解码,获取到appid
- 从存储中取出appid对应的appkey
- 重新计算sign,判断两个sign是否匹配
1、5和sign有关,2、3和编码、解析有关,4和存储有关。所以可以粗略得到三个核心类。AuthToken、ApiRequest、CredentialStorage。AuthToken负责实现1、5这两个操作;ApiRequest负责2、3两个操作;CredentialStorage负责4这个操作。
当然,这是一个初步的类的划分,其他一些不重要的、边边角角的类,我们可能暂时没法一下子想全,但这也没关系,面向对象分析、设计、编程本来就是一个循环迭代、不断优化的过程。
定义类及其属性和方法
对于方法的识别,识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。对于属性的识别,把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。
现在我们来看下,每个类都有哪些属性和方法。我们还是从功能点列表中挖掘。
AuthToken
AuthToken类相关的功能点有两个:
- 利用appid和appkey,对请求参数进行加密生成sign
- 判断两个sign是否匹配
动词有:生成、匹配
名词有:sign(appid和appkey从业务上来说不属于AuthToken,可以当做参数传入)
type Header struct {
AppId string
Sign string
}
type Data struct {
Header Header
Body string
}
type AuthToken struct {
sign string
}
func CreateAuthToken() *AuthToken {
return &AuthToken{}
}
func (authToken *AuthToken) Create(appId string, appKey string, body string) string {
h := md5.New()
h.Write([]byte(appId + body + appKey))
authToken.sign = strings.ToUpper(fmt.Sprintf("%x", h.Sum(nil)))
return authToken.sign
}
func (authToken *AuthToken) Match(token *AuthToken) bool {
if authToken.sign == token.sign {
return true
}
return false
}
ApiRequest
ApiRequest类相关功能点有两个:
- 进行base64编码,生成请求数据
- 进行base64解码,获取appid和sign
动词有:编码、解码
type ApiRequest struct {
appId string
sign string
data *Data
}
func CreateApiRequest() *ApiRequest {
return &ApiRequest{}
}
func (apiRequest *ApiRequest) Encode(data string) string {
return base64.StdEncoding.EncodeToString([]byte(data))
}
func (apiRequest *ApiRequest) Decode(data string) (appId string, sign string, err error) {
bytes, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return
}
apiRequest.data = &Data{}
if err := json.Unmarshal(bytes, apiRequest.data); err != nil {
return "", "", err
}
apiRequest.appId = apiRequest.data.Header.AppId
apiRequest.sign = apiRequest.data.Header.Sign
return apiRequest.appId, apiRequest.sign, nil
}
func (apiRequest *ApiRequest) GetAppid() string {
return apiRequest.appId
}
func (apiRequest *ApiRequest) GetSign() string {
return apiRequest.sign
}
CredentialStorage
CredentialStorage相关功能点只有一个:
- 从存储中取出appid对应的appkey
因为后期获取数据位置会变,所以最好设计为接口,基于接口而非具体的实现编程。
type CredentialStorage interface {
GetAppkeyByAppid(appId string) string
}
type CredentialStorageConfig struct {
}
func (config *CredentialStorageConfig) GetAppkeyByAppid(appId string) string {
if appId == "test" {
return "test"
}
return "test"
}
定义类与类之间的交互关系
类之间的交互关系可以简化为四种:泛化、实现、组合、依赖
泛化:可以理解为继承关系
实现:一般指接口实现类之间的关系
组合:包括聚合、组合、关联等,一般指类中包含其它类
依赖:只要两个类有任何关系,就认为是依赖关系
所以CredentialStorage和CredentialStorageConfig是实现关系。
将类组装起来并提供执行入口
接下来我们需要将类组装起来,让整个代码跑起来。
我们封装所有的实现细节,设计了一个最顶层的ApiAuthencator类,暴露一组给外部调用者使用的API接口,作为触发执行鉴权逻辑的入口。
type ApiAuthencator struct {
credentialStorage CredentialStorage
}
func CreateApiAuthenCator(cs CredentialStorage) *ApiAuthencator {
return &ApiAuthencator{credentialStorage: cs}
}
func (apiAuthencator *ApiAuthencator) Auth(data string) (bool, error) {
//1.解析数据
apiRequest := CreateApiRequest()
appId, sign, err := apiRequest.Decode(data)
//fmt.Println(appId, sign, apiRequest.data)
if err != nil {
return false, fmt.Errorf("Decode failed")
}
//2.获取appId对应的appkey
appKey := apiAuthencator.credentialStorage.GetAppkeyByAppid(appId)
//3.重新计算sign
authToken := CreateAuthToken()
newSign := authToken.Create(appId, appKey, apiRequest.data.Body)
if sign == newSign {
return true, nil
}
return false, nil
}
面向对象编程
面向对象设计完成之后,我们已经定义清晰了类、属性、方法、类之间的交互,并且将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现。
在面向对象分析的时候,会完成部分编码工作。面向对象编程过程中需要将代码进行完善。不过因为这个业务相对简单,所以现在只写一下main函数,看一下执行效果。
func main() {
//客户端
appId := "test"
appKey := "test"
sendData := &Data{
Header: Header{
AppId: appId,
},
Body: "for test",
}
authToken := CreateAuthToken()
sign := authToken.Create(appId, appKey, sendData.Body)
sendData.Header.Sign = sign
sendDataMarshal, _ := json.Marshal(sendData)
sendDataString := CreateApiRequest().Encode(string(sendDataMarshal))
//fmt.Println(sign, sendData, string(sendDataMarshal), string(sendDataString))
//服务端
apiAuthenCator := CreateApiAuthenCator(new(CredentialStorageConfig))
auth, err := apiAuthenCator.Auth(sendDataString)
if err != nil {
fmt.Println(err.Error())
return
}
if auth == false {
fmt.Println("auth failed")
return
}
fmt.Println("auth success")
return
}
优点
虽然使用面向对象编写流程比面向过程负责的多,但按照套路走,即便是没有太多设计经验的初级工程师,都可以按部就班地参照着这个流程来做分析、设计和实现。
面向对象最重要的一点是:能够把扩展点提前准备好,今后有变更时只需要更改少量代码。
面向对象的设计没有最好,只有更好,它是需要根据发展不断迭代重构的过程。
对于鉴权需求,最可能变更的是获取appkey方式变化,但因为使用了接口,后期变更只需要编写新的获取appkey的类,然后更改CreateApiAuthenCator(new(CredentialStorageConfig))代码即可。
面向对象要使用的好
- 需要对业务熟悉,能够预料到哪些是未来会变化的点
- 要埋下合适的扩展点,这需要了解一些原则和设计模式
总结
多年的工作经验告诉我,编码过程中一定要善于使用面向对象思想,否则系统会越来越臃肿,越来越难以维护。这篇文章阐述了使用面向对象方法的套路,按照这个套路走,不断提升自己,让自己成为更优秀的人。
完整代码可查看:https://github.com/shidawuhen/asap/blob/master/controller/design/2design.go
资料
- [设计模式之美]
- 接口鉴权
最后
大家如果喜欢我的文章,可以关注我的公众号(程序员麻辣烫)
我的个人博客为:https://shidawuhen.github.io/
往期文章回顾: