JustAuth实战系列(第7期):数据模型设计 - 领域对象的建模艺术

115 阅读12分钟

引言

在OAuth集成框架中,数据模型是整个系统的核心。它不仅要承载业务数据,还要处理不同平台间的差异,保证数据的完整性和一致性。JustAuth通过精心设计的数据模型,成功抽象了平台的用户信息,实现了统一的API接口。

本期我们将深入分析JustAuth的数据模型设计,从领域建模原则到具体实现细节,探索如何设计出既灵活又稳定的数据模型。

一、领域建模原则

1.1 充血模型 vs 贫血模型的选择

在DDD(领域驱动设计)中,充血模型和贫血模型是两种不同的建模方式:

贫血模型(Anemic Domain Model)

  • 对象只包含数据,不包含业务逻辑
  • 业务逻辑在Service层处理
  • 优点是简单直观,缺点是对象缺乏行为

充血模型(Rich Domain Model)

  • 对象既包含数据,也包含业务逻辑
  • 对象具有自我管理的能力
  • 优点是对象更加内聚,缺点是复杂度较高

JustAuth选择了贫血模型,这是基于以下考虑:

// JustAuth的AuthUser模型 - 贫血模型设计
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthUser implements Serializable {
    private String uuid;
    private String username;
    private String nickname;
    // ... 其他属性
    
    // 没有业务方法,只有数据
}

选择贫血模型的原因

  1. 跨平台兼容性:不同平台的用户信息差异很大,难以在模型层统一业务逻辑
  2. 序列化友好:贫血模型更容易进行JSON序列化/反序列化
  3. 框架定位:JustAuth定位为工具框架,而非业务系统
  4. 扩展性:让使用者可以根据业务需求扩展模型

1.2 数据传输对象的设计原则

JustAuth的数据模型设计遵循了以下原则:

1.2.1 最小化接口原则

// AuthUser只暴露必要的属性,避免信息泄露
public class AuthUser {
    private String uuid;        // 业务主键
    private String username;    // 用户名
    private String nickname;    // 昵称
    // 不暴露敏感信息如密码等
}

1.2.2 向后兼容性原则

// 通过Builder模式支持灵活的对象构建
@Builder
public class AuthUser {
    // 新增字段不会破坏现有代码
    private boolean snapshotUser; // 新增字段
}

1.2.3 领域完整性保证

// 通过关联关系保证数据的完整性
public class AuthUser {
    private AuthToken token;        // 关联令牌信息
    private JSONObject rawUserInfo; // 保留原始数据
}

二、核心模型分析

2.1 AuthUser - 用户信息聚合

AuthUser是JustAuth的核心模型,它抽象了不同平台的用户信息:

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthUser implements Serializable {
    /**
     * 用户第三方系统的唯一id。在调用方集成该组件时,可以用uuid + source唯一确定一个用户
     */
    private String uuid;
    
    /**
     * 用户名
     */
    private String username;
    
    /**
     * 用户昵称
     */
    private String nickname;
    
    /**
     * 用户头像
     */
    private String avatar;
    
    /**
     * 用户网址
     */
    private String blog;
    
    /**
     * 所在公司
     */
    private String company;
    
    /**
     * 位置
     */
    private String location;
    
    /**
     * 用户邮箱
     */
    private String email;
    
    /**
     * 用户备注(各平台中的用户个人介绍)
     */
    private String remark;
    
    /**
     * 性别
     */
    private AuthUserGender gender;
    
    /**
     * 用户来源
     */
    private String source;
    
    /**
     * 用户授权的token信息
     */
    private AuthToken token;
    
    /**
     * 第三方平台返回的原始用户信息
     */
    private JSONObject rawUserInfo;
    
    /**
     * 微信公众号 - 网页授权的登录时可用
     * 微信针对网页授权登录,增加了一个快照页的逻辑,快照页获取到的微信用户的 uid oid 和头像昵称都是虚拟的信息
     */
    private boolean snapshotUser;
}

2.1.1 设计亮点分析

1. 业务主键设计

private String uuid;  // 第三方平台的唯一标识
private String source; // 平台来源标识

// 组合键:uuid + source 可以唯一确定一个用户

2. 关联关系处理

private AuthToken token;        // 关联令牌对象
private JSONObject rawUserInfo; // 保留原始数据,便于扩展

3. 平台特性支持

private boolean snapshotUser; // 支持微信快照页的特殊逻辑

2.1.2 字段映射策略

不同平台的用户信息字段差异很大,JustAuth采用了灵活的映射策略:

// GitHub平台实现示例
@Override
public AuthUser getUserInfo(AuthToken authToken) {
    JSONObject object = JSONObject.parseObject(response);
    
    return AuthUser.builder()
        .rawUserInfo(object)                    // 保留原始数据
        .uuid(object.getString("id"))           // GitHub的id映射到uuid
        .username(object.getString("login"))    // GitHub的login映射到username
        .avatar(object.getString("avatar_url")) // GitHub的avatar_url映射到avatar
        .blog(object.getString("blog"))         // GitHub的blog映射到blog
        .nickname(object.getString("name"))     // GitHub的name映射到nickname
        .company(object.getString("company"))   // GitHub的company映射到company
        .location(object.getString("location")) // GitHub的location映射到location
        .email(object.getString("email"))       // GitHub的email映射到email
        .remark(object.getString("bio"))        // GitHub的bio映射到remark
        .gender(AuthUserGender.UNKNOWN)         // GitHub不提供性别信息
        .token(authToken)                       // 关联令牌信息
        .source(source.toString())              // 设置来源标识
        .build();
}

2.2 AuthToken - 令牌值对象

AuthToken封装了OAuth流程中的各种令牌信息:

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthToken implements Serializable {
    // 标准OAuth2.0字段
    private String accessToken;
    private int expireIn;
    private String refreshToken;
    private int refreshTokenExpireIn;
    private String uid;
    private String openId;
    private String accessCode;
    private String unionId;
    
    // Google特有字段
    private String scope;
    private String tokenType;
    private String idToken;
    
    // 小米特有字段
    private String macAlgorithm;
    private String macKey;
    
    // 企业微信特有字段
    private String code;
    private boolean snapshotUser;
    
    // Twitter特有字段
    private String oauthToken;
    private String oauthTokenSecret;
    private String userId;
    private String screenName;
    private Boolean oauthCallbackConfirmed;
    
    // Apple特有字段
    private String username;
    
    // 钉钉特有字段
    private String corpId;
}

2.2.1 设计特点

1. 兼容性设计

  • 包含标准OAuth2.0字段
  • 通过扩展字段支持各平台特有属性
  • 使用包装类型避免基本类型的默认值问题

2. 时间处理策略

private int expireIn;              // 过期时间(秒)
private int refreshTokenExpireIn;  // 刷新令牌过期时间(秒)

// 注意:使用int而不是long,因为OAuth2.0标准中expire_in通常是int类型

3. 平台差异化处理

// 不同平台的令牌结构差异很大,通过扩展字段支持
private String oauthToken;        // Twitter OAuth1.0a
private String oauthTokenSecret;  // Twitter OAuth1.0a
private String idToken;           // Google OpenID Connect
private String macKey;            // 小米MAC认证

2.3 AuthCallback - 回调参数封装

AuthCallback封装了OAuth回调时的各种参数:

@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthCallback implements Serializable {
    /**
     * 访问AuthorizeUrl后回调时带的参数code
     */
    private String code;
    
    /**
     * 访问AuthorizeUrl后回调时带的参数auth_code,该参数目前只使用于支付宝登录
     */
    private String auth_code;
    
    /**
     * 访问AuthorizeUrl后回调时带的参数state,用于和请求AuthorizeUrl前的state比较,防止CSRF攻击
     */
    private String state;
    
    /**
     * 华为授权登录接受code的参数名
     */
    private String authorization_code;
    
    /**
     * Twitter回调后返回的oauth_token
     */
    private String oauth_token;
    
    /**
     * Twitter回调后返回的oauth_verifier
     */
    private String oauth_verifier;
    
    /**
     * 苹果仅在用户首次授权应用程序时返回此值
     */
    private String user;
    
    /**
     * 苹果错误信息,仅在用户取消授权时返回此值
     */
    private String error;
    
    /**
     * 统一的code获取方法
     */
    public String getCode() {
        return StringUtils.isEmpty(code) ? auth_code : code;
    }
}

2.3.1 设计亮点

1. 参数统一化

public String getCode() {
    return StringUtils.isEmpty(code) ? auth_code : code;
}
// 支付宝使用auth_code,其他平台使用code,通过此方法统一处理

2. 平台兼容性

  • 支持不同平台的回调参数差异
  • 通过字段扩展支持新平台的特殊参数

3. 安全性考虑

private String state; // CSRF防护参数

2.4 AuthResponse - 统一响应封装

AuthResponse提供了统一的响应格式:

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse<T> implements Serializable {
    /**
     * 授权响应状态码
     */
    private int code;
    
    /**
     * 授权响应信息
     */
    private String msg;
    
    /**
     * 授权响应数据,当且仅当 code = 2000 时返回
     */
    private T data;
    
    /**
     * 是否请求成功
     */
    public boolean ok() {
        return this.code == AuthResponseStatus.SUCCESS.getCode();
    }
}

2.4.1 设计特点

1. 泛型设计

public class AuthResponse<T> implements Serializable {
    private T data; // 支持任意类型的响应数据
}

2. 状态判断方法

public boolean ok() {
    return this.code == AuthResponseStatus.SUCCESS.getCode();
}
// 提供便捷的状态判断方法

三、枚举类型设计

3.1 AuthUserGender - 性别枚举

性别信息在不同平台中的表示差异很大,JustAuth通过枚举统一处理:

@Getter
@AllArgsConstructor
public enum AuthUserGender {
    MALE("1", "男"),
    FEMALE("0", "女"),
    UNKNOWN("-1", "未知");
    
    private String code;
    private String desc;
    
    /**
     * 获取用户的实际性别,常规网站
     */
    public static AuthUserGender getRealGender(String originalGender) {
        if (null == originalGender || UNKNOWN.getCode().equals(originalGender)) {
            return UNKNOWN;
        }
        String[] males = {"m", "男", "1", "male"};
        if (Arrays.asList(males).contains(originalGender.toLowerCase())) {
            return MALE;
        }
        return FEMALE;
    }
    
    /**
     * 获取微信平台用户的实际性别,0表示未定义,1表示男性,2表示女性
     */
    public static AuthUserGender getWechatRealGender(String originalGender) {
        if (StringUtils.isEmpty(originalGender) || "0".equals(originalGender)) {
            return AuthUserGender.UNKNOWN;
        }
        return getRealGender(originalGender);
    }
}

3.1.1 设计亮点

1. 多平台兼容

  • 提供通用的性别解析方法
  • 针对特殊平台(如微信)提供专门的解析方法

2. 容错处理

UNKNOWN("-1", "未知") // 提供容错值,处理无法解析的性别信息

3. 静态工具方法

public static AuthUserGender getRealGender(String originalGender)
public static AuthUserGender getWechatRealGender(String originalGender)
// 提供便捷的转换方法

四、JSON序列化策略

4.1 Fastjson的使用技巧

JustAuth使用Fastjson进行JSON序列化,这带来了以下优势:

1. 性能优势

// Fastjson的性能优于Jackson和Gson
JSONObject object = JSONObject.parseObject(response);
String jsonString = JSONObject.toJSONString(authUser);

2. 灵活性

// 支持动态字段访问
JSONObject rawUserInfo = object.getJSONObject("user");
String customField = object.getString("custom_field");

3. 兼容性

// 保留原始数据,便于处理平台特有的字段
private JSONObject rawUserInfo; // 存储完整的原始响应

4.2 序列化兼容性处理

1. 字段映射处理

// 不同平台的字段名差异
"avatar_url" -> "avatar"  // GitHub
"profile_image_url" -> "avatar"  // Twitter
"headimgurl" -> "avatar"  // 微信

2. 数据类型转换

// 处理不同平台的数据类型差异
String id = object.getString("id");     // 字符串ID
Long numericId = object.getLong("id");  // 数字ID

3. 空值处理

// 安全的字段获取
String email = object.getString("email");
if (StringUtils.isEmpty(email)) {
    email = null; // 统一空值处理
}

4.3 敏感信息保护

1. 令牌信息保护

// 在日志中脱敏处理
@Override
public String toString() {
    return "AuthToken{" +
        "accessToken='" + (accessToken != null ? "***" : null) + '\'' +
        ", refreshToken='" + (refreshToken != null ? "***" : null) + '\'' +
        // ... 其他字段
        '}';
}

2. 用户信息保护

// 避免在日志中输出敏感信息
private String email; // 邮箱信息需要谨慎处理
private String phone; // 手机号等敏感信息

五、实战案例:设计更完善的用户模型

5.1 现有模型的不足

分析JustAuth的AuthUser模型,我们可以发现一些可以改进的地方:

1. 缺乏验证逻辑

// 当前模型没有验证逻辑
public class AuthUser {
    private String email; // 没有邮箱格式验证
    private String uuid;  // 没有UUID格式验证
}

2. 缺乏业务方法

// 当前模型是纯数据对象
public class AuthUser {
    // 没有提供业务方法
    // 如:isValid(), getDisplayName(), getAvatarUrl()等
}

3. 扩展性有限

// 难以支持动态字段
public class AuthUser {
    // 字段是固定的,难以支持平台特有的字段
}

5.2 改进方案设计

5.2.1 增强的AuthUser模型

@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnhancedAuthUser implements Serializable {
    // 基础字段
    private String uuid;
    private String username;
    private String nickname;
    private String avatar;
    private String email;
    private AuthUserGender gender;
    private String source;
    private AuthToken token;
    
    // 扩展字段
    private Map<String, Object> extendedFields;
    private JSONObject rawUserInfo;
    
    // 验证方法
    public boolean isValid() {
        return StringUtils.isNotEmpty(uuid) && StringUtils.isNotEmpty(source);
    }
    
    public boolean hasValidEmail() {
        return StringUtils.isNotEmpty(email) && EmailValidator.isValid(email);
    }
    
    // 业务方法
    public String getDisplayName() {
        return StringUtils.isNotEmpty(nickname) ? nickname : username;
    }
    
    public String getAvatarUrl() {
        if (StringUtils.isEmpty(avatar)) {
            return getDefaultAvatarUrl();
        }
        return avatar;
    }
    
    public String getDefaultAvatarUrl() {
        return "https://www.gravatar.com/avatar/" + 
               DigestUtils.md5Hex(StringUtils.defaultString(email, uuid)) + 
               "?d=identicon";
    }
    
    // 扩展字段操作
    public void setExtendedField(String key, Object value) {
        if (extendedFields == null) {
            extendedFields = new HashMap<>();
        }
        extendedFields.put(key, value);
    }
    
    public Object getExtendedField(String key) {
        return extendedFields != null ? extendedFields.get(key) : null;
    }
    
    // 平台特定方法
    public boolean isWechatUser() {
        return "wechat".equals(source) || "wechat_open".equals(source);
    }
    
    public boolean isGithubUser() {
        return "github".equals(source);
    }
    
    // 序列化优化
    @Override
    public String toString() {
        return "EnhancedAuthUser{" +
            "uuid='" + uuid + '\'' +
            ", username='" + username + '\'' +
            ", nickname='" + nickname + '\'' +
            ", source='" + source + '\'' +
            ", email='" + (hasValidEmail() ? "***" : email) + '\'' +
            ", isValid=" + isValid() +
            '}';
    }
}

5.2.2 验证器设计

public class EmailValidator {
    private static final String EMAIL_PATTERN = 
        "^[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})$";
    
    public static boolean isValid(String email) {
        if (StringUtils.isEmpty(email)) {
            return false;
        }
        return email.matches(EMAIL_PATTERN);
    }
}

public class UuidValidator {
    public static boolean isValid(String uuid) {
        if (StringUtils.isEmpty(uuid)) {
            return false;
        }
        // 根据平台特性验证UUID格式
        return uuid.matches("^[a-zA-Z0-9_-]+$");
    }
}

5.2.3 工厂模式应用

public class AuthUserFactory {
    
    public static EnhancedAuthUser createFromPlatform(String source, JSONObject rawData) {
        EnhancedAuthUser user = new EnhancedAuthUser();
        user.setSource(source);
        user.setRawUserInfo(rawData);
        
        // 根据平台特性设置字段
        switch (source.toLowerCase()) {
            case "github":
                return createGithubUser(rawData, user);
            case "wechat":
                return createWechatUser(rawData, user);
            default:
                return createGenericUser(rawData, user);
        }
    }
    
    private static EnhancedAuthUser createGithubUser(JSONObject data, EnhancedAuthUser user) {
        user.setUuid(data.getString("id"));
        user.setUsername(data.getString("login"));
        user.setNickname(data.getString("name"));
        user.setAvatar(data.getString("avatar_url"));
        user.setEmail(data.getString("email"));
        user.setExtendedField("blog", data.getString("blog"));
        user.setExtendedField("company", data.getString("company"));
        user.setExtendedField("location", data.getString("location"));
        user.setExtendedField("bio", data.getString("bio"));
        return user;
    }
    
    private static EnhancedAuthUser createWechatUser(JSONObject data, EnhancedAuthUser user) {
        user.setUuid(data.getString("openid"));
        user.setNickname(data.getString("nickname"));
        user.setAvatar(data.getString("headimgurl"));
        user.setGender(AuthUserGender.getWechatRealGender(data.getString("sex")));
        user.setExtendedField("province", data.getString("province"));
        user.setExtendedField("city", data.getString("city"));
        user.setExtendedField("country", data.getString("country"));
        return user;
    }
}

5.3 测试用例设计

@Test
public void testEnhancedAuthUserValidation() {
    // 测试有效用户
    EnhancedAuthUser validUser = EnhancedAuthUser.builder()
        .uuid("12345")
        .username("testuser")
        .source("github")
        .email("test@example.com")
        .build();
    
    assertTrue(validUser.isValid());
    assertTrue(validUser.hasValidEmail());
    
    // 测试无效用户
    EnhancedAuthUser invalidUser = EnhancedAuthUser.builder()
        .username("testuser")
        .source("github")
        .build();
    
    assertFalse(invalidUser.isValid());
}

@Test
public void testPlatformSpecificMethods() {
    EnhancedAuthUser wechatUser = EnhancedAuthUser.builder()
        .source("wechat")
        .build();
    
    assertTrue(wechatUser.isWechatUser());
    assertFalse(wechatUser.isGithubUser());
}

@Test
public void testExtendedFields() {
    EnhancedAuthUser user = new EnhancedAuthUser();
    user.setExtendedField("custom_field", "custom_value");
    
    assertEquals("custom_value", user.getExtendedField("custom_field"));
}

六、模型演进的兼容性问题

6.1 版本兼容性策略

1. 字段添加策略

// 新增字段使用包装类型,避免基本类型的默认值问题
private String newField;        // 新增字段,默认为null
private Integer newNumericField; // 数字字段使用包装类型

2. 字段废弃策略

@Deprecated
private String oldField; // 标记废弃字段,但不立即删除

// 提供替代方法
public String getNewField() {
    return newField != null ? newField : oldField; // 向后兼容
}

3. 序列化兼容性

// 使用@JsonIgnoreProperties(ignoreUnknown = true)忽略未知字段
@JsonIgnoreProperties(ignoreUnknown = true)
public class AuthUser {
    // 新增字段不会影响反序列化
}

6.2 数据迁移策略

1. 渐进式迁移

// 支持新旧字段并存
public class AuthUser {
    private String oldField; // 旧字段
    private String newField; // 新字段
    
    public String getField() {
        return newField != null ? newField : oldField;
    }
}

2. 配置驱动迁移

// 通过配置控制字段映射
@Configuration
public class FieldMappingConfig {
    @Value("${auth.field.mapping.enabled:false}")
    private boolean fieldMappingEnabled;
    
    public String mapField(String oldField, String newField) {
        return fieldMappingEnabled ? newField : oldField;
    }
}

七、最佳实践总结

7.1 数据模型设计原则

  1. 单一职责原则:每个模型只负责一个领域概念
  2. 开闭原则:对扩展开放,对修改关闭
  3. 依赖倒置原则:依赖抽象而非具体实现
  4. 接口隔离原则:提供最小化的接口

7.2 跨平台兼容性设计

  1. 统一抽象:找到不同平台的共同点进行抽象
  2. 扩展机制:通过扩展字段支持平台特有功能
  3. 容错处理:提供默认值和容错机制
  4. 原始数据保留:保留原始响应数据,便于扩展

7.3 性能优化考虑

  1. 序列化优化:选择合适的序列化库
  2. 内存管理:及时清理不需要的数据
  3. 缓存策略:对频繁访问的数据进行缓存
  4. 延迟加载:按需加载用户详细信息

7.4 安全性考虑

  1. 敏感信息保护:对令牌等敏感信息进行脱敏
  2. 输入验证:对用户输入进行严格验证
  3. 权限控制:根据权限返回不同的用户信息
  4. 审计日志:记录关键操作的审计日志

八、学习收获

通过本期对JustAuth数据模型设计的深入分析,我们学到了:

  1. 领域建模的艺术:如何在复杂业务场景中设计合适的数据模型
  2. 跨平台兼容性:如何抽象不同平台的差异,提供统一的接口
  3. 扩展性设计:如何设计可扩展的模型,支持未来的需求变化
  4. 性能与安全:如何在保证性能的同时确保数据安全
  5. 版本兼容性:如何保证模型演进时的向后兼容性

这些设计原则和实践经验,不仅适用于OAuth集成框架,也可以应用到其他需要处理复杂数据模型的场景中。

九、下期预告

下一期我们将深入分析JustAuth的缓存架构设计,探索状态管理的安全与性能平衡。我们将学习:

  • 多层缓存接口设计
  • 并发安全设计
  • 内存泄漏防护机制
  • 分布式缓存集成方案

敬请期待!