《优化接口设计的思路》系列:第九篇—用好缓存,让你的接口速度飞起来

一、前言

大家好!我是sum墨,一个一线的底层码农,平时喜欢研究和思考一些技术相关的问题并整理成文,限于本人水平,如果文章和代码有表述不当之处,还请不吝赐教。

作为一名从业已达六年的老码农,我的工作主要是开发后端Java业务系统,包括各种管理后台和小程序等。在这些项目中,我设计过单/多租户体系系统,对接过许多开放平台,也搞过消息中心这类较为复杂的应用,但幸运的是,我至今还没有遇到过线上系统由于代码崩溃导致资损的情况。这其中的原因有三点:一是业务系统本身并不复杂;二是我一直遵循某大厂代码规约,在开发过程中尽可能按规约编写代码;三是经过多年的开发经验积累,我成为了一名熟练工,掌握了一些实用的技巧。

前面的文章都是写接口如何设计、接口怎么验权以及一些接口常用的组件,这篇写点接口性能相关的。接口性能优化有很多途径,比如表建索引、SQL优化、加缓存、重构代码等等,本篇文章主要讲一下我是怎么在项目中使用缓存来提高接口响应速度的。我觉得缓存的使用主要有下面几个方面:

  • 缓存预热
    • 定时任务预热:定时任务在系统低峰期预加载数据到缓存中。
    • 启动预热:系统启动时预加载必要的数据到缓存中。
  • 缓存层次化
    • 多级缓存:实现本地缓存和分布式缓存相结合,例如,先在本地缓存中查询,如果没有再查询Redis等分布式缓存,最后才查询数据库。
    • 热点数据缓存:对频繁访问的数据进行缓存,如用户会话、热门商品信息、高频访问的内容等。

缓存提高接口响应速度主要是上面这些思路,不过我不是来讲概念的,那是面试要用的东西。我要讲的是如何用代码实现这些思路,把它们真正用到项目中来,水平有限,我尽力说,不喜勿喷。

由于文章经常被抄袭,开源的代码甚至被当成收费项,所以源码里面不是全部代码,有需要的同学可以留个邮箱,我给你单独发!

二、缓存预热:手撸一个缓存处理器

上面说了缓存预热主要是定时任务预热、启动预热,那么我们实现这个功能的时候,一般使用ConcurrentHashMapRedis来暂存数据,然后加上SpringBoot自带的@Scheduled定时刷新缓存就够了。虽然这样可以实现缓存预热,但缺陷很多,一旦需要预热的东西多起来就会变得越来越复杂,那么如何实现一个好的缓存处理器呢?接着看!

1、缓存处理器设计

(1)一个好的缓存处理器应该是这样搭建的

  1. DAL实现,产出DAO和DO对象,定义缓存领域模型
  2. 定义缓存名称,特别关注缓存的初始化顺序
  3. 编写数据仓库,通过模型转换器实现数据模型到缓存模型的转化
  4. 编写缓存管理器,推荐继承抽象管理器 {@link AbstractCacheManager}
  5. 根据业务需求,设计缓存数据接口(putAll,get,getCacheInfo等基础API)
  6. 完成bean配置,最好是可插拔的注册方式,缓存管理器和数据仓库、扩展点服务

(2)思路分析

2、代码实现

a. 每个处理器都有缓存名字、描述信息、缓存初始化顺序等信息,所以应该定义一个接口,名字为CacheNameDomain;

CacheNameDomain.java

1package com.summo.demo.cache; 2 3public interface CacheNameDomain { 4 5 /** 6 * 缓存初始化顺序,级别越低,越早被初始化 7 * <p> 8 * 如果缓存的加载存在一定的依赖关系,通过缓存级别控制初始化或者刷新时缓存数据的加载顺序<br> 9 * 级别越低,越早被初始化<br> 10 * <p> 11 * 如果缓存的加载没有依赖关系,可以使用默认顺序<code>Ordered.LOWEST_PRECEDENCE</code> 12 * 13 * @return 初始化顺序 14 * @see org.springframework.core.Ordered 15 */ 16 int getOrder(); 17 18 /** 19 * 缓存名称,推荐使用英文大写字母表示 20 * 21 * @return 缓存名称 22 */ 23 String getName(); 24 25 /** 26 * 缓存描述信息,用于打印日志 27 * 28 * @return 缓存描述信息 29 */ 30 String getDescription(); 31}

b. 可以使用一个枚举类将不同的缓存处理器分开,有利于管理,取名为CacheNameEnum;

CacheNameEnum.java

1package com.summo.demo.cache; 2 3import org.springframework.core.Ordered; 4 5/** 6 * @description 缓存枚举 7 */ 8public enum CacheNameEnum implements CacheNameDomain { 9 /** 10 * 系统配置缓存 11 */ 12 SYS_CONFIG("SYS_CONFIG", "系统配置缓存", Ordered.LOWEST_PRECEDENCE), 13 ; 14 15 private String name; 16 17 private String description; 18 19 private int order; 20 21 CacheNameEnum(String name, String description, int order) { 22 this.name = name; 23 this.description = description; 24 this.order = order; 25 } 26 27 @Override 28 public String getName() { 29 return name; 30 } 31 32 public void setName(String name) { 33 this.name = name; 34 } 35 36 @Override 37 public String getDescription() { 38 return description; 39 } 40 41 public void setDescription(String description) { 42 this.description = description; 43 } 44 45 @Override 46 public int getOrder() { 47 return order; 48 } 49 50 public void setOrder(int order) { 51 this.order = order; 52 } 53}

c. 缓存信息转换工具,以便dump出更友好的缓存信息,取名为CacheMessageUtil;

CacheMessageUtil.java

1package com.summo.demo.cache; 2 3 4import java.util.Iterator; 5import java.util.List; 6import java.util.Map; 7 8/** 9 * @description 缓存信息转换工具,以便dump出更友好的缓存信息 10 */ 11public final class CacheMessageUtil { 12 13 /** 换行符 */ 14 private static final char ENTERSTR = '\n'; 15 16 /** Map 等于符号 */ 17 private static final char MAP_EQUAL = '='; 18 19 /** 20 * 禁用构造函数 21 */ 22 private CacheMessageUtil() { 23 // 禁用构造函数 24 } 25 26 /** 27 * 缓存信息转换工具,以便dump出更友好的缓存信息<br> 28 * 对于List<?>的类型转换 29 * 30 * @param cacheDatas 缓存数据列表 31 * @return 缓存信息 32 */ 33 public static String toString(List<?> cacheDatas) { 34 StringBuilder builder = new StringBuilder(); 35 for (int i = 0; i < cacheDatas.size(); i++) { 36 Object object = cacheDatas.get(i); 37 builder.append(object); 38 39 if (i != cacheDatas.size() - 1) { 40 builder.append(ENTERSTR); 41 } 42 } 43 44 return builder.toString(); 45 } 46 47 /** 48 * 缓存信息转换工具,以便dump出更友好的缓存信息<br> 49 * 对于Map<String, Object>的类型转换 50 * 51 * @param map 缓存数据 52 * @return 缓存信息 53 */ 54 public static String toString(Map<?, ?> map) { 55 StringBuilder builder = new StringBuilder(); 56 int count = map.size(); 57 for (Iterator<?> i = map.keySet().iterator(); i.hasNext();) { 58 Object name = i.next(); 59 count++; 60 61 builder.append(name).append(MAP_EQUAL); 62 builder.append(map.get(name)); 63 64 if (count != count - 1) { 65 builder.append(ENTERSTR); 66 } 67 } 68 69 return builder.toString(); 70 } 71 72}

d. 每个处理器都有生命周期,如初始化、刷新、获取处理器信息等操作,这应该也是一个接口,处理器都应该声明这个接口,名字为CacheManager;

CacheManager.java

1package com.summo.demo.cache; 2 3import org.springframework.core.Ordered; 4 5public interface CacheManager extends Ordered { 6 7 /** 8 * 初始化缓存 9 */ 10 public void initCache(); 11 12 /** 13 * 刷新缓存 14 */ 15 public void refreshCache(); 16 17 /** 18 * 获取缓存的名称 19 * 20 * @return 缓存名称 21 */ 22 public CacheNameDomain getCacheName(); 23 24 /** 25 * 打印缓存信息 26 */ 27 public void dumpCache(); 28 29 /** 30 * 获取缓存条数 31 * 32 * @return 33 */ 34 public long getCacheSize(); 35}

e. 定义一个缓存处理器生命周期的处理器,会声明CacheManager,做第一次的处理,也是所有处理器的父类,所以这应该是一个抽象类,名字为AbstractCacheManager;

AbstractCacheManager.java

1package com.summo.demo.cache; 2 3import org.slf4j.Logger; 4import org.slf4j.LoggerFactory; 5import org.springframework.beans.factory.InitializingBean; 6 7/** 8 * @description 缓存管理抽象类,缓存管理器都要集成这个抽象类 9 */ 10public abstract class AbstractCacheManager implements CacheManager, InitializingBean { 11 12 /** 13 * LOGGER 14 */ 15 protected static final Logger LOGGER = LoggerFactory.getLogger(AbstractCacheManager.class); 16 17 /** 18 * 获取可读性好的缓存信息,用于日志打印操作 19 * 20 * @return 缓存信息 21 */ 22 protected abstract String getCacheInfo(); 23 24 /** 25 * 查询数据仓库,并加载到缓存数据 26 */ 27 protected abstract void loadingCache(); 28 29 /** 30 * 查询缓存大小 31 * 32 * @return 33 */ 34 protected abstract long getSize(); 35 36 /** 37 * @see InitializingBean#afterPropertiesSet() 38 */ 39 @Override 40 public void afterPropertiesSet() { 41 CacheManagerRegistry.register(this); 42 } 43 44 @Override 45 public void initCache() { 46 47 String description = getCacheName().getDescription(); 48 49 LOGGER.info("start init {}", description); 50 51 loadingCache(); 52 53 afterInitCache(); 54 55 LOGGER.info("{} end init", description); 56 } 57 58 @Override 59 public void refreshCache() { 60 61 String description = getCacheName().getDescription(); 62 63 LOGGER.info("start refresh {}", description); 64 65 loadingCache(); 66 67 afterRefreshCache(); 68 69 LOGGER.info("{} end refresh", description); 70 } 71 72 /** 73 * @see org.springframework.core.Ordered#getOrder() 74 */ 75 @Override 76 public int getOrder() { 77 return getCacheName().getOrder(); 78 } 79 80 @Override 81 public void dumpCache() { 82 83 String description = getCacheName().getDescription(); 84 85 LOGGER.info("start print {} {}{}", description, "\n", getCacheInfo()); 86 87 LOGGER.info("{} end print", description); 88 } 89 90 /** 91 * 获取缓存条目 92 * 93 * @return 94 */ 95 @Override 96 public long getCacheSize() { 97 LOGGER.info("Cache Size Count: {}", getSize()); 98 return getSize(); 99 } 100 101 /** 102 * 刷新之后,其他业务处理,比如监听器的注册 103 */ 104 protected void afterInitCache() { 105 //有需要后续动作的缓存实现 106 } 107 108 /** 109 * 刷新之后,其他业务处理,比如缓存变通通知 110 */ 111 protected void afterRefreshCache() { 112 //有需要后续动作的缓存实现 113 } 114}

f. 当有很多缓存处理器的时候,那么需要一个统一注册、统一管理的的地方,可以实现对分散在各处的缓存管理器统一维护,名字为CacheManagerRegistry;

CacheManagerRegistry.java

1package com.summo.demo.cache; 2 3import org.slf4j.Logger; 4import org.slf4j.LoggerFactory; 5import org.springframework.beans.factory.InitializingBean; 6import org.springframework.core.OrderComparator; 7import org.springframework.stereotype.Component; 8 9import java.util.ArrayList; 10import java.util.Collections; 11import java.util.List; 12import java.util.Map; 13import java.util.concurrent.ConcurrentHashMap; 14 15/** 16 * @description 缓存管理器集中注册接口,可以实现对分散在各处的缓存管理器统一维护 17 */ 18@Component 19public final class CacheManagerRegistry implements InitializingBean { 20 21 /** 22 * LOGGER 23 */ 24 private static final Logger logger = LoggerFactory.getLogger(CacheManagerRegistry.class); 25 26 /** 27 * 缓存管理器 28 */ 29 private static Map<String, CacheManager> managerMap = new ConcurrentHashMap<String, CacheManager>(); 30 31 /** 32 * 注册缓存管理器 33 * 34 * @param cacheManager 缓存管理器 35 */ 36 public static void register(CacheManager cacheManager) { 37 String cacheName = resolveCacheName(cacheManager.getCacheName().getName()); 38 managerMap.put(cacheName, cacheManager); 39 } 40 41 /** 42 * 刷新特定的缓存 43 * 44 * @param cacheName 缓存名称 45 */ 46 public static void refreshCache(String cacheName) { 47 CacheManager cacheManager = managerMap.get(resolveCacheName(cacheName)); 48 if (cacheManager == null) { 49 logger.warn("cache manager is not exist,cacheName=", cacheName); 50 return; 51 } 52 53 cacheManager.refreshCache(); 54 cacheManager.dumpCache(); 55 } 56 57 /** 58 * 获取缓存总条数 59 */ 60 public static long getCacheSize(String cacheName) { 61 CacheManager cacheManager = managerMap.get(resolveCacheName(cacheName)); 62 if (cacheManager == null) { 63 logger.warn("cache manager is not exist,cacheName=", cacheName); 64 return 0; 65 } 66 return cacheManager.getCacheSize(); 67 } 68 69 /** 70 * 获取缓存列表 71 * 72 * @return 缓存列表 73 */ 74 public static List<String> getCacheNameList() { 75 List<String> cacheNameList = new ArrayList<>(); 76 managerMap.forEach((k, v) -> { 77 cacheNameList.add(k); 78 }); 79 return cacheNameList; 80 } 81 82 public void startup() { 83 try { 84 85 deployCompletion(); 86 87 } catch (Exception e) { 88 89 logger.error("Cache Component Init Fail:", e); 90 91 // 系统启动时出现异常,不希望启动应用 92 throw new RuntimeException("启动加载失败", e); 93 } 94 } 95 96 /** 97 * 部署完成,执行缓存初始化 98 */ 99 private void deployCompletion() { 100 101 List<CacheManager> managers = new ArrayList<CacheManager>(managerMap.values()); 102 103 // 根据缓存级别进行排序,以此顺序进行缓存的初始化 104 Collections.sort(managers, new OrderComparator()); 105 106 // 打印系统启动日志 107 logger.info("cache manager component extensions:"); 108 for (CacheManager cacheManager : managers) { 109 String beanName = cacheManager.getClass().getSimpleName(); 110 logger.info(cacheManager.getCacheName().getName(), "==>", beanName); 111 } 112 113 // 初始化缓存 114 for (CacheManager cacheManager : managers) { 115 cacheManager.initCache(); 116 cacheManager.dumpCache(); 117 } 118 } 119 120 /** 121 * 解析缓存名称,大小写不敏感,增强刷新的容错能力 122 * 123 * @param cacheName 缓存名称 124 * @return 转换大写的缓存名称 125 */ 126 private static String resolveCacheName(String cacheName) { 127 return cacheName.toUpperCase(); 128 } 129 130 @Override 131 public void afterPropertiesSet() throws Exception { 132 startup(); 133 } 134}

3、使用方式

项目结构如下:

这是完整的项目结构图,具体的使用步骤如下: step1、在CacheNameEnum中加一个业务枚举,如 SYS_CONFIG("SYS_CONFIG", "系统配置缓存", Ordered.LOWEST_PRECEDENCE); step2、自定义一个CacheManager继承AbstractCacheManager,如public class SysConfigCacheManager extends AbstractCacheManager; step3、实现loadingCache()方法,这里将你需要缓存的数据查询出来,但注意不要将所有的数据都放在一个缓存处理器中,前面CacheNameEnum枚举类的作用就是希望按业务分开处理; step4、在自定义的CacheManager类中写自己的查询数据方法,因为不同业务的场景不同,查询参数、数据大小、格式、类型都不一致,所以AbstractCacheManager并没有定义统一的取数方法,没有意义;

下面是一个完整的例子 SysConfigCacheManager.java

1package com.summo.demo.cache.manager; 2 3import java.util.concurrent.ConcurrentHashMap; 4import java.util.concurrent.ConcurrentMap; 5import java.util.concurrent.locks.Lock; 6import java.util.concurrent.locks.ReentrantLock; 7 8import com.summo.demo.cache.AbstractCacheManager; 9import com.summo.demo.cache.CacheMessageUtil; 10import com.summo.demo.cache.CacheNameDomain; 11import com.summo.demo.cache.CacheNameEnum; 12import org.springframework.stereotype.Component; 13 14/** 15 * 系统配置管理器 16 */ 17@Component 18public class SysConfigCacheManager extends AbstractCacheManager { 19 20 /** 21 * 加个锁,防止出现并发问题 22 */ 23 private static final Lock LOCK = new ReentrantLock(); 24 25 /** 26 * 底层缓存组件,可以使用ConcurrentMap也可以使用Redis,推荐使用Redis 27 */ 28 private static ConcurrentMap<String, Object> CACHE; 29 30 @Override 31 protected String getCacheInfo() { 32 return CacheMessageUtil.toString(CACHE); 33 } 34 35 @Override 36 protected void loadingCache() { 37 LOCK.lock(); 38 try { 39 //存储数据,这里就模拟一下了 40 CACHE = new ConcurrentHashMap<>(); 41 CACHE.put("key1", "value1"); 42 CACHE.put("key2", "value2"); 43 CACHE.put("key3", "value3"); 44 } finally { 45 LOCK.unlock(); 46 } 47 48 } 49 50 @Override 51 protected long getSize() { 52 return null == CACHE ? 0 : CACHE.size(); 53 } 54 55 @Override 56 public CacheNameDomain getCacheName() { 57 return CacheNameEnum.SYS_CONFIG; 58 } 59 60 /** 61 * 自定义取数方法 62 * 63 * @param key 64 * @return 65 */ 66 public static Object getConfigByKey(String key) { 67 return CACHE.get(key); 68 } 69}

三、缓存层次化:使用函数式编程实现

1、先举个例子

现有一个使用商品名称查询商品的需求,要求先查询缓存,查不到则去数据库查询;从数据库查询到之后加入缓存,再查询时继续先查询缓存。

(1)思路分析

可以写一个条件判断,伪代码如下:

1//先从缓存中查询 2String goodsInfoStr = redis.get(goodsName); 3if(StringUtils.isBlank(goodsInfoStr)){ 4 //如果缓存中查询为空,则去数据库中查询 5 Goods goods = goodsMapper.queryByName(goodsName); 6 //将查询到的数据存入缓存 7 goodsName.set(goodsName,JSONObject.toJSONString(goods)); 8 //返回商品数据 9 return goods; 10}else{ 11 //将查询到的str转换为对象并返回 12 return JSON.parseObject(goodsInfoStr, Goods.class); 13}

流程图如下

上面这串代码也可以实现查询效果,看起来也不是很复杂,但是这串代码是不可复用的,只能用在这个场景。假设在我们的系统中还有很多类似上面商品查询的需求,那么我们需要到处写这样的if(...)else{...}。作为一个程序员,不能把类似的或者重复的代码统一起来是一件很难受的事情,所以需要对这种场景的代码进行优化。

上面这串代码的问题在于:入参不固定、返回值也不固定,如果仅仅是参数不固定,使用泛型即可。但最关键的是查询方法也是不固定的,比如查询商品和查询用户肯定不是一个查询方法吧。

所以如果我们可以把一个方法(即上面的各种查询方法)也能当做一个参数传入一个统一的判断方法就好了,类似于:

1/** 2 * 这个方法的作用是:先执行method1方法,如果method1查询或执行不成功,再执行method2方法 3 */ 4public static<T> T selectCacheByTemplate(method1,method2)

想要实现上面的这种效果,就不得不提到Java8的新特性:函数式编程

2、什么是函数式编程

在Java中有一个package:java.util.function ,里面全部是接口,并且都被@FunctionalInterface注解所修饰。

Function分类

  • Consumer(消费):接受参数,无返回值
  • Function(函数):接受参数,有返回值
  • Operator(操作):接受参数,返回与参数同类型的值
  • Predicate(断言):接受参数,返回boolean类型
  • Supplier(供应):无参数,有返回值

具体我就不再赘述了,可以参考:https://blog.csdn.net/hua226/article/details/124409889

3、代码实现

核心代码非常简单,如下

1/** 2 * 缓存查询模板 3 * 4 * @param cacheSelector 查询缓存的方法 5 * @param databaseSelector 数据库查询方法 6 * @return T 7 */ 8public static <T> T selectCacheByTemplate(Supplier<T> cacheSelector, Supplier<T> databaseSelector) { 9 try { 10 log.info("query data from redis ······"); 11 // 先查 Redis缓存 12 T t = cacheSelector.get(); 13 if (t == null) { 14 // 没有记录再查询数据库 15 return databaseSelector.get(); 16 } else { 17 return t; 18 } 19 } catch (Exception e) { 20 // 缓存查询出错,则去数据库查询 21 log.info("query data from database ······"); 22 return databaseSelector.get(); 23 } 24}

这里的Supplier<T> 就是一个加了@FunctionalInterface注解的接口。

4、使用方式

使用方式也非常简单,如下

1@Component 2public class UserManager { 3 4 @Autowired 5 private CacheService cacheService; 6 7 public Set<String> queryAuthByUserId(Long userId) { 8 return BaseUtil.selectCacheByTemplate( 9 //从缓存中查询 10 () -> this.cacheService.queryUserFromRedis(userId), 11 //从数据库中查询 12 () -> this.cacheService.queryUserFromDB(userId)); 13 } 14}

这样就可以做到先查询Redis,查询不到再查询数据库,非常简单也非常好用,我常用于查询一些实体信息的场景。不过这里有一个注意的点:缓存一致性。因为有时候底层数据会变化,需要做好一致性,否则会出问题。

四、小结一下

首先,缓存确实可以提高API查询效率,这点大家应该不会质疑,但缓存并不是万能的,不应该将所有数据都缓存起来,应当评估数据的访问频率和更新频率,以决定是否缓存。 其次,在实施缓存策略时,需要平衡缓存的开销、复杂性和所带来的性能提升。此外,缓存策略应该根据实际业务需求和数据特征进行定制,不断调整优化以适应业务发展。 最后,缓存虽好,但不要乱用哦,否则会出现令你惊喜的BUG!😇

点赞
收藏

评论区

加载中...

相关推荐

如何读取redis的手机号验证码数据,实现自动化登录测试

1、案例描述用户选择短信登录!发送短信后,后端接口逻辑已把验证码缓存在redis,!在apifox上定义对应下发登录短信接口,短信登录接口下载地址:这时候问题就来了,怎么样才能让apifox自动获取下发登录短信接口对应的手机号的验证码,自动填充到短信登录接口的code字段?2、解决思路方案一:后端通过接口返回验证码

基于Spring Cache实现Caffeine、jimDB多级缓存实战

在早期参与涅槃氛围标签中台项目中,前台要求接口性能999要求50ms以下,通过设计Caffeine、ehcache堆外缓存、jimDB三级缓存,利用内存、堆外、jimDB缓存不同的特性提升接口性能,内存缓存采用Caffeine缓存,利用WTinyLFU算法获得更高的内存命中率;同时利用堆外缓存降低内存缓存大小,减少GC频率,同时也减少了网络IO带来的性能消耗;利用JimDB提升接口高可用、高并发;后期通过压测及性能调优999性能<20ms

一招MAX降低10倍,现在它是我的了 | 京东云技术团队

性能优化是一场永无止境的旅程。到家门店系统,作为到家核心基础服务之一,门店C端接口有着调用量高,性能要求高的特点。C端服务经过演进,核心接口先查询本地缓存,如果本地缓存没有命中,再查询Redis。本地缓存命中率99%,服务性能比较平稳。

一次接口的性能优化之旅

一、引言在项目开发过程中,我们经常会遇到接口响应慢的问题。这不仅影响了用户体验,还可能降低了系统的吞吐量。为了提高接口性能,我们需要对整个系统进行全面的优化,包括代码层面、数据库、缓存、异步处理等方面。本文将分享一个接口性能优化之旅,希望能帮助大家掌握Pf

一招MAX降低10倍,现在它是我的了| 京东零售技术团队

一.背景性能优化是一场永无止境的旅程。到家门店系统,作为到家核心基础服务之一,门店C端接口有着调用量高,性能要求高的特点。C端服务经过演进,核心接口先查询本地缓存,如果本地缓存没有命中,再查询Redis。本地缓存命中率99%,服务性能比较平稳。随着门店数据

《优化接口设计的思路》系列:第四篇—接口的权限控制

我们在做系统的时候,只要这个系统里面存在角色和权限相关的业务需求,那么接口的权限控制肯定必不可少。但是大家一搜接口权限相关的资料,出来的就是整合Shrio、SpringSecurity等各种框架,然后下面一顿贴配置和代码,看得人云里雾里。实际上接口的权限控制是整个系统权限控制里面很小的一环,没有设计好底层数据结构,是无法做好接口的权限控制的。那么怎么做一个系统的权限控制呢?