基本介绍

我们知道,频繁操作数据库会降低服务器的系统性能,因此通常需要将频繁访问、更新的数据存入到缓存。Halo 项目也引入了缓存机制,且设置了多种实现方式,如自定义缓存、Redis、LevelDB 等,下面我们分析一下缓存机制的实现过程。

自定义缓存

1. 缓存的配置

由于数据在缓存中以键值对的形式存在,且不同类型的缓存系统定义的存储和读取等操作都大同小异,所以本文仅介绍项目中默认的自定义缓存。自定义缓存指的是作者自己编写的缓存,以 ConcurrentHashMap 作为容器,数据存储在服务器的内存中。在介绍自定义缓存之前,我们先看一下 Halo 缓存的体系图:

本人使用的 Halo 1.4.13 版本中并未设置 Redis 缓存,上图来自 1.5.2 版本。

可以看到,作者的设计思路是在上层的抽象类和接口中定义通用的操作方法,而具体的缓存容器、数据的存储以及读取方法则是在各个实现类中定义。如果希望修改缓存的类型,只需要在配置类 HaloProperties 中修改 cache 字段的值:

@Bean
@ConditionalOnMissingBean
AbstractStringCacheStore stringCacheStore() {
AbstractStringCacheStore stringCacheStore;
// 根据 cache 字段的值选择具体的缓存类型
switch (haloProperties.getCache()) {
case "level":
stringCacheStore = new LevelCacheStore(this.haloProperties);
break;
case "redis":
stringCacheStore = new RedisCacheStore(stringRedisTemplate);
break;
case "memory":
default:
stringCacheStore = new InMemoryCacheStore();
break;
}
log.info("Halo cache store load impl : [{}]", stringCacheStore.getClass());
return stringCacheStore;
}

上述代码来自 1.5.2 版本。

cache 字段的默认值为 "memory",因此缓存的实现类为 InMemoryCacheStore(自定义缓存):

public class InMemoryCacheStore extends AbstractStringCacheStore {

    /**
* Cleaner schedule period. (ms)
*/
private static final long PERIOD = 60 * 1000; /**
* Cache container.
*/
public static final ConcurrentHashMap<String, CacheWrapper<String>> CACHE_CONTAINER =
new ConcurrentHashMap<>(); private final Timer timer; /**
* Lock.
*/
private final Lock lock = new ReentrantLock(); public InMemoryCacheStore() {
// Run a cache store cleaner
timer = new Timer();
// 每 60s 清除一次过期的 key
timer.scheduleAtFixedRate(new CacheExpiryCleaner(), 0, PERIOD);
}
// 省略部分代码
}

InMemoryCacheStore 成员变量的含义如下:

  1. CACHE_CONTAINER 是 InMemoryCacheStore 的缓存容器,类型为 ConcurrentHashMap。使用 ConcurrentHashMap 是为了保证线程安全,因为缓存中会存放缓存锁相关的数据(下文中介绍),每当用户访问后台的服务时,就会有新的数据进入缓存,这些数据可能来自于不同的线程,因此 CACHE_CONTAINER 需要考虑多个线程同时操作的情况。
  1. timer 负责执行周期任务,任务的执行频率为 PERIOD,默认为一分钟,周期任务的处理逻辑是清除缓存中已经过期的 key。

  2. lock 是 ReentrantLock 类型的排它锁,与缓存锁有关。

2. 缓存中的数据

缓存中存储的数据包括:

  1. 系统设置中的选项信息,其实就是 options 表中存储的数据。

  2. 已登录用户(博主)的 token。

  3. 已获得文章授权的客户端的 sessionId。

  4. 缓存锁相关的数据。

在之前的文章中,我们介绍过 token 和 sessionId 的存储和获取,因此本文就不再赘述这一部分内容了,详见 Halo 开源项目学习(三):注册与登录Halo 开源项目学习(四):发布文章与页面。缓存锁我们在下一节再介绍,本节中我们先看看 Halo 如何保存 options 信息。

首先需要了解一下 options 信息是什么时候存入到缓存中的,实际上,程序在启动后会发布 ApplicationStartedEvent 事件,项目中定义了负责监听 ApplicationStartedEvent 事件的监听器 StartedListener(listener 包下),该监听器在事件发布后会执行 initThemes 方法,下面是 initThemes 方法中的部分代码片段:

private void initThemes() {
// Whether the blog has initialized
Boolean isInstalled = optionService
.getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false);
// 省略部分代码
}

该方法会调用 getByPropertyOrDefault 方法从缓存中查询博客的安装状态,我们从 getByPropertyOrDefault 方法开始,沿着调用链向下搜索,可以追踪到 OptionProvideService 接口中的 getByKey 方法:

default Optional<Object> getByKey(@NonNull String key) {
Assert.hasText(key, "Option key must not be blank");
// 如果 val = listOptions().get(key) 不为空, 返回 value 为 val 的 Optional 对象, 否则返回 value 为空的 Optional 对象
return Optional.ofNullable(listOptions().get(key));
}

可以看到,重点是这个 listOptions 方法,该方法在 OptionServiceImpl 类中定义:

public Map<String, Object> listOptions() {
// Get options from cache
// 从缓存 CACHE_CONTAINER 中获取 "options" 这个 key 对应的数据, 并将该数据转化为 Map 对象
return cacheStore.getAny(OPTIONS_KEY, Map.class).orElseGet(() -> {
// 初次调用时需要从 options 表中获取所有的 Option 对象
List<Option> options = listAll();
// 所有 Option 对象的 key 集合
Set<String> keys = ServiceUtils.fetchProperty(options, Option::getKey); /*
* options 表中存储的记录其实就是用户自定义的 Option 选项, 当用户修改博客设置时, 会自动更新 options 表,
* Halo 中对一些选项的 value 设置了确定的类型, 例如 EmailProperties 这个类中的 HOST 为 String 类型, 而
* SSL_PORT 则为 Integer 类型, 由于 Option 类中 value 一律为 String 类型, 因此需要将某些 value 转化为指
* 定的类型
*/
Map<String, Object> userDefinedOptionMap =
ServiceUtils.convertToMap(options, Option::getKey, option -> {
String key = option.getKey(); PropertyEnum propertyEnum = propertyEnumMap.get(key); if (propertyEnum == null) {
return option.getValue();
}
// 对 value 进行类型转换
return PropertyEnum.convertTo(option.getValue(), propertyEnum);
}); Map<String, Object> result = new HashMap<>(userDefinedOptionMap); // Add default property
/*
* 有些选项是 Halo 默认设定的, 例如 EmailProperties 中的 SSL_PORT, 用户未设置时, 它也会被设定为默认的 465,
* 同样, 也需要将默认的 "465" 转化为 Integer 类型的 465
*/
propertyEnumMap.keySet()
.stream()
.filter(key -> !keys.contains(key))
.forEach(key -> {
PropertyEnum propertyEnum = propertyEnumMap.get(key); if (StringUtils.isBlank(propertyEnum.defaultValue())) {
return;
}
// 对 value 进行类型转换并存入 result
result.put(key,
PropertyEnum.convertTo(propertyEnum.defaultValue(), propertyEnum));
}); // Cache the result
// 将所有的选项加入缓存
cacheStore.putAny(OPTIONS_KEY, result); return result;
});
}

服务器首先从 CACHE_CONTAINER 中获取 "options" 这个 key 对应的数据,然后将该数据转化为 Map 类型的对象。由于初次查询时 CACHE_CONTAINER 中 并没有 "options" 对应的 value,因此需要进行初始化:

  1. 首先从 options 表中获取所有的 Option 对象,并将这些对象存入到 Map 中。其中 key 和 value 均为 Option 对象中的 key 和 value,但 value 还需要进行一个类型转换,因为在 Option 类中 value 被定义为了 String 类型。例如,"is_installed" 对应的 value 为 "true",为了能够正常使用 value,需要将字符串 "true" 转化成 Boolean 类型的 true。结合上下文,我们发现程序是根据 PrimaryProperties 类(继承 PropertyEnum 的枚举类)中定义的枚举对象 IS_INSTALLED("is_installed", Boolean.class, "false") 来确认目标类型 Boolean 的。

  2. options 表中的选项是用户自定义的选项,除此之外,Halo 中还设置了一些默认的选项,这些选项均在 PropertyEnum 的子类中定义,例如 EmailProperties 类中的 SSL_PORT("email_ssl_port", Integer.class, "465"),其对应的 key 为 "email_ssl_port",value 为 "465"。服务器也会将这些 key - value 对存入到 Map,并对 value 进行类型转换。

以上便是 listOptions 方法的处理逻辑,我们回到 getByKey 方法,当获取到 listOptions 方法返回的 Map 对象后,服务器可以根据指定的 key(如 "is_installed")获取到对应的属性值(如 true)。当用户在管理员后台修改博客的系统设置时,服务器会根据用户的配置更新 options 表,并发布 OptionUpdatedEvent 事件,之后负责处理事件的监听器会将缓存中的 "options" 删除,下次查询时再根据上述步骤执行初始化操作(详见 FreemarkerConfigAwareListener 中的 onOptionUpdate 方法)。

3. 缓存的过期处理

缓存的过期处理是一个非常重要的知识点,数据过期后,通常需要将其从缓存中删除。从上文中的 cacheStore.putAny(OPTIONS_KEY, result) 方法中我们得知,服务器将数据存储到缓存之前,会先将其封装成 CacheWrapper 对象:

class CacheWrapper<V> implements Serializable {

    /**
* Cache data
*/
private V data; /**
* Expired time.
*/
private Date expireAt; /**
* Create time.
*/
private Date createAt;
}

其中 data 是需要存储的数据,createAt 和 expireAt 分别是数据的创建时间和过期时间。Halo 项目中,"options" 是没有过期时间的,只有当数据更新时,监听器才会将旧的数据删除。需要注意的是,token 和 sessionId 均有过期时间,对于有过期时间的 key,项目中也有相应的处理办法。以 token 为例,拦截器拦截到用户的请求后会确认用户的身份,也就是查询缓存中是否具有 token 对应的用户 id,这个查询操作的底层调用的是 get 方法(在 AbstractCacheStore 类中定义):

public Optional<V> get(K key) {
Assert.notNull(key, "Cache key must not be blank"); return getInternal(key).map(cacheWrapper -> {
// Check expiration
// 过期
if (cacheWrapper.getExpireAt() != null
&& cacheWrapper.getExpireAt().before(run.halo.app.utils.DateUtils.now())) {
// Expired then delete it
log.warn("Cache key: [{}] has been expired", key); // Delete the key
delete(key); // Return null
return null;
}
// 未过期返回缓存数据
return cacheWrapper.getData();
});
}

服务器获取到 key 对应的 CacheWrapper 对象后,会检查其中的过期时间,如果数据已过期,那么直接将其删除并返回 null。另外,上文中提到,timer(InMemoryCacheStore 的成员变量)的周期任务也负责删除过期的数据,下面是 timer 周期任务执行的方法:

private class CacheExpiryCleaner extends TimerTask {

    @Override
public void run() {
CACHE_CONTAINER.keySet().forEach(key -> {
if (!InMemoryCacheStore.this.get(key).isPresent()) {
log.debug("Deleted the cache: [{}] for expiration", key);
}
});
}
}

可见,周期任务也是通过调用 get 方法来删除过期数据的。

缓存锁

Halo 项目中的缓存锁也是一个比较有意思的模块,其作用是限制用户对某个功能的调用频率,可认为是对请求的方法进行加锁。缓存锁主要利用自定义注解 @CacheLock 和 AOP 来实现,@CacheLock 注解的定义如下:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface CacheLock { @AliasFor("value")
String prefix() default ""; @AliasFor("prefix")
String value() default ""; long expired() default 5; TimeUnit timeUnit() default TimeUnit.SECONDS; String delimiter() default ":"; boolean autoDelete() default true; boolean traceRequest() default false;
}

各个成员变量的含义为:

  • prefix:用于构建 cacheLockKey(一个字符串)的前缀。

  • value:同 prefix。

  • expired:缓存锁的持续时间。

  • timeUnit:持续时间的单位。

  • delimiter:分隔符,构建 cacheLockKey 时使用。

  • autoDelete:是否自动删除缓存锁。

  • traceRequest:是否追踪请求的 IP,如果是,那么构建 cacheLockKey 时会添加用户的 IP。

缓存锁的使用方法是在需要加锁的方法上添加 @CacheLock 注解,然后通过 Spring 的 AOP 在方法执行前对方法进行加锁,方法执行结束后再将锁取消。项目中的切面类为 CacheLockInterceptor,负责加/解锁的逻辑如下:

Around("@annotation(run.halo.app.cache.lock.CacheLock)")
public Object interceptCacheLock(ProceedingJoinPoint joinPoint) throws Throwable {
// 获取方法签名
// Get method signature
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); log.debug("Starting locking: [{}]", methodSignature.toString()); // 获取方法上的 CacheLock 注解
// Get cache lock
CacheLock cacheLock = methodSignature.getMethod().getAnnotation(CacheLock.class);
// 构造缓存锁的 key
// Build cache lock key
String cacheLockKey = buildCacheLockKey(cacheLock, joinPoint);
System.out.println(cacheLockKey);
log.debug("Built lock key: [{}]", cacheLockKey); try {
// Get from cache
Boolean cacheResult = cacheStore
.putIfAbsent(cacheLockKey, CACHE_LOCK_VALUE, cacheLock.expired(),
cacheLock.timeUnit()); if (cacheResult == null) {
throw new ServiceException("Unknown reason of cache " + cacheLockKey)
.setErrorData(cacheLockKey);
} if (!cacheResult) {
throw new FrequentAccessException("访问过于频繁,请稍后再试!").setErrorData(cacheLockKey);
}
// 执行注解修饰的方法
// Proceed the method
return joinPoint.proceed();
} finally {
// 方法执行结束后, 是否自动删除缓存锁
// Delete the cache
if (cacheLock.autoDelete()) {
cacheStore.delete(cacheLockKey);
log.debug("Deleted the cache lock: [{}]", cacheLock);
}
}
}

@Around("@annotation(run.halo.app.cache.lock.CacheLock)") 表示,如果请求的方法被 @CacheLock 注解修饰,那么服务器不会执行该方法,而是执行 interceptCacheLock 方法:

  1. 获取方法上的 CacheLock 注解并构建 cacheLockKey。

  2. 查看缓存中是否存在 cacheLockKey,如果存在,那么抛出异常,提醒用户访问过于频繁。如果不存在,那么将 cacheLockKey 存入到缓存(有效时间为 expired),并执行请求的方法。

  3. 如果 CacheLock 注解中的 autoDelete 为 true,那么方法执行结束后立即删除 cacheLockKey。

缓存锁的原理和 Redis 的 setnx + expire 相似,如果 key 已存在,就不能再次添加。下面是构建 cacheLockKey 的逻辑:

private String buildCacheLockKey(@NonNull CacheLock cacheLock,
@NonNull ProceedingJoinPoint joinPoint) {
Assert.notNull(cacheLock, "Cache lock must not be null");
Assert.notNull(joinPoint, "Proceeding join point must not be null"); // Get the method
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); // key 的前缀
// Build the cache lock key
StringBuilder cacheKeyBuilder = new StringBuilder(CACHE_LOCK_PREFIX);
// 分隔符
String delimiter = cacheLock.delimiter();
// 如果 CacheLock 中设置了前缀, 那么直接使用该前缀, 否则使用方法名
if (StringUtils.isNotBlank(cacheLock.prefix())) {
cacheKeyBuilder.append(cacheLock.prefix());
} else {
cacheKeyBuilder.append(methodSignature.getMethod().toString());
}
// 提取被 CacheParam 注解修饰的变量的值
// Handle cache lock key building
Annotation[][] parameterAnnotations = methodSignature.getMethod().getParameterAnnotations(); for (int i = 0; i < parameterAnnotations.length; i++) {
log.debug("Parameter annotation[{}] = {}", i, parameterAnnotations[i]); for (int j = 0; j < parameterAnnotations[i].length; j++) {
Annotation annotation = parameterAnnotations[i][j];
log.debug("Parameter annotation[{}][{}]: {}", i, j, annotation);
if (annotation instanceof CacheParam) {
// Get current argument
Object arg = joinPoint.getArgs()[i];
log.debug("Cache param args: [{}]", arg); // Append to the cache key
cacheKeyBuilder.append(delimiter).append(arg.toString());
}
}
}
// 是否添加请求的 IP
if (cacheLock.traceRequest()) {
// Append http request info
cacheKeyBuilder.append(delimiter).append(ServletUtils.getRequestIp());
}
return cacheKeyBuilder.toString();
}

可以发现,cacheLockKey 的结构为 cache_lock_ + CacheLock 注解中设置的前缀或方法签名 + 分隔符 + CacheParam 注解修饰的参数的值 + 分隔符 + 请求的 IP,例如:

cache_lock_public void run.halo.app.controller.content.api.PostController.like(java.lang.Integer):1:127.0.0.1

CacheParam 同 CacheLock 一样,都是为实现缓存锁而定义的注解。CacheParam 的作用是将锁的粒度精确到具体的实体,如点赞请求:

@PostMapping("{postId:\\d+}/likes")
@ApiOperation("Likes a post")
@CacheLock(autoDelete = false, traceRequest = true)
public void like(@PathVariable("postId") @CacheParam Integer postId) {
postService.increaseLike(postId);
}

参数 postId 被 CacheParam 修饰,根据 buildCacheLockKey 方法的逻辑,postId 也将是 cacheLockKey 的一部分,这样锁定的就是 "为 id 等于 postId 的文章点赞" 这一方法,而非锁定 "点赞" 方法。

此外,CacheLock 注解中的 traceRequest 参数也很重要,如果 traceRequest 为 true,那么请求的 IP 会被添加到 cacheLockKey 中,此时缓存锁仅限制同一 IP 对某个方法的请求频率,不同 IP 之间互不干扰。如果 traceRequest 为 false,那么缓存锁就是一个分布式锁,不同 IP 不能同时访问同一个功能,例如当某个用户为某篇文章点赞后,短时间内其它用户不能为该文章点赞。

最后我们再分析一下 putIfAbsent 方法(在 interceptCacheLock 中被调用),其功能和 Redis 的 setnx 相似,该方法的具体处理逻辑可追踪到 InMemoryCacheStore 类中的 putInternalIfAbsent 方法:

Boolean putInternalIfAbsent(@NonNull String key, @NonNull CacheWrapper<String> cacheWrapper) {
Assert.hasText(key, "Cache key must not be blank");
Assert.notNull(cacheWrapper, "Cache wrapper must not be null"); log.debug("Preparing to put key: [{}], value: [{}]", key, cacheWrapper);
// 加锁
lock.lock();
try {
// 获取 key 对应的 value
// Get the value before
Optional<String> valueOptional = get(key);
// value 不为空返回 false
if (valueOptional.isPresent()) {
log.warn("Failed to put the cache, because the key: [{}] has been present already",
key);
return false;
}
// 在缓存中添加 value 并返回 true
// Put the cache wrapper
putInternal(key, cacheWrapper);
log.debug("Put successfully");
return true;
} finally {
// 解锁
lock.unlock();
}
}

上节中我们提到,自定义缓存 InMemoryCacheStore 中有一个 ReentrantLock 类型的成员变量 lock,lock 的作用就是保证 putInternalIfAbsent 方法的线程安全性,因为向缓存容器中添加 cacheLockKey 是多个线程并行执行的。如果不添加 lock,那么当多个线程同时操作同一个 cacheLockKey 时,不同线程可能都会检测到缓存中没有 cacheLockKey,因此 putInternalIfAbsent 方法均返回 true,之后多个线程就可以同时执行某个方法,添加 lock 后就能够避免这种情况。

结语

关于 Halo 项目缓存机制就介绍到这里了,如有理解错误,欢迎大家批评指正 ( ̳• ◡ • ̳)。

Halo 开源项目学习(七):缓存机制的更多相关文章

  1. Halo 开源项目学习(六):事件监听机制

    基本介绍 Halo 项目中,当用户或博主执行某些操作时,服务器会发布相应的事件,例如博主登录管理员后台时发布 "日志记录" 事件,用户浏览文章时发布 "访问文章" ...

  2. Halo 开源项目学习(一):项目启动

    项目简介 Halo 是一个优秀的开源博客发布应用,在 GitHub 上广受好评,正好最近在练习写博客,借此记录一下学习 Halo 的过程. 项目下载 从 GitHub 上拉取项目源码,Halo 从 1 ...

  3. Halo 开源项目学习(四):发布文章与页面

    基本介绍 博客最基本的功能就是让作者能够自由发布自己的文章,分享自己观点,记录学习的过程.Halo 为用户提供了发布文章和展示自定义页面的功能,下面我们分析一下这些功能的实现过程. 管理员发布文章 H ...

  4. Halo 开源项目学习(五):评论与点赞

    基本介绍 博客系统中,用户浏览文章时可以在文章下方发表自己的观点,与博主或其他用户进行互动,也可以为喜欢的文章点赞.下面我们一起分析一下 Halo 项目中评论和点赞功能的实现过程. 发表评论 评论可以 ...

  5. Halo 开源项目学习(二):实体类与数据表

    基本介绍 Halo 项目中定义了一些实体类,用于存储博客中的关键数据,如用户信息.文章信息等.在深入学习 Halo 的设计理念与实现过程之前,不妨先学习一下一个完整的博客系统都由哪些元素组成. 实体类 ...

  6. Halo 开源项目学习(三):注册与登录

    基本介绍 首次启动 Halo 项目时需要安装博客并注册用户信息,当博客安装完成后用户就可以根据注册的信息登录到管理员界面,下面我们分析一下整个过程中代码是如何执行的. 博客安装 项目启动成功后,我们可 ...

  7. 转:从开源项目学习 C 语言基本的编码规则

    从开源项目学习 C 语言基本的编码规则 每个项目都有自己的风格指南:一组有关怎样为那个项目编码约定.一些经理选择基本的编码规则,另一些经理则更偏好非常高级的规则,对许多项目而言则没有特定的编码规则,项 ...

  8. android开源项目学习

    FBReaderJ FBReaderJ用于Android平台的电子书阅读器,它支持多种电子书籍格式包括:oeb.ePub和fb2.此外还支持直接读取zip.tar和gzip等压缩文档. 项目地址:ht ...

  9. [Android 开源项目学习]Android的UITableView(1)

         最近由于项目加急,手里有好多看了差不多的开源项目,其中好多是大家经常用到的.图片的缓存BitmapFun(Android的文档中),AfinalMap,下拉刷新PullToRefresh等等 ...

随机推荐

  1. kafka生产者网络层总结

    1 层次结构 负责进行网络IO请求的是NetworkClient,主要层次结构如下 ClusterConnectionStates报存了每个节点的状态,以node为key,以node的状态为value ...

  2. 如何给 Spring 容器提供配置元数据?

    这里有三种重要的方法给 Spring 容器提供配置元数据. XML 配置文件. 基于注解的配置. 基于 java 的配置.

  3. solr服务的搭建

    首先你需要一台已经搭建好的虚拟机,下面的步骤才可以执行 安装java 安装完Centos6.5的Base Server版会默认安装OpenJDK,首先需要删除OpenJDK 1.查看以前是不是安装了o ...

  4. Markdown语法1

    Markdown是一种轻量级标记语言. Markdown 编写的文档可以导出 HTML .Word.图像.PDF.Epub 等多种格式的文档. Markdown 编写的文档后缀为 .md, .mark ...

  5. .NET Core Ecosystem

    .NET .NET Blog Application Models Web Mobile Desktop Microservices Gaming Machine Learning Cloud Int ...

  6. link和@import的区别浅析

    我们都知道,外部引入 CSS 有2种方式,link标签和@import.它们有何本质区别,有何使用建议,在考察外部引入 CSS 这部分内容时,经常被提起. 如今,很多学者本着知其然不欲知其所以然的学习 ...

  7. Java/C++实现解释器模式---机器人控制程序

    某机器人控制程序包含一些简单的英文指令,其文法规则如下: expression ::= direction action distance | composite composite ::= expr ...

  8. three车辆自由转弯(vue 极品飞车)

    //最近没有时间整理代码,就这样吧 <template> <div> <div id="map"></div> </div&g ...

  9. Java基础之浅谈接口

    前言 前几篇文章我们已经把Java的封装.继承.多态学习完了,现在我们开始比较便于我们实际操作的学习,虽然它也是Java基础部分,但是其实入门容易,精通很难. 我认真的给大家整理了一下这些必须学会.了 ...

  10. Python入门-匿名函数,递归函数,主函数

    1.三目运算符 对简单的条件语句,可以用三元运算简写.三元运算只能写在一行代码里面 # 书写格式 result = 值1 if 条件 else 值2 # 如果条件成立,那么将 "值1&quo ...