2024年6月

Redis作为企业级应用中广泛使用的高性能键值存储数据库,其性能优化是一个复杂且多面的话题。以下是V 哥整理的一些关键的优化点和相应的解决方案,提供给兄弟们参考。

Redis的性能优化涉及到硬件选择、配置调整、客户端优化、持久化策略等多个层面。

1. 硬件优化

解决方案:选择更快的CPU、更多的内存、更快的磁盘(SSD推荐)和足够的网络带宽。

2. 合理的实例部署

解决方案:根据业务访问模式,决定是使用单实例、主从复制、哨兵系统还是Redis集群。

3. 连接数优化

解决方案:调整redis.conf中的maxclients参数,以适应业务需求。

示例配置:

maxclients 10000

4. 命令优化

解决方案:避免使用耗时的命令,如KEYS、FLUSHDB等,使用SCAN替代。

5. 使用连接池

解决方案:使用客户端连接池减少连接建立和销毁的开销。

示例代码(Java Jedis连接池):

JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(100);
poolConfig.setMaxIdle(10);
poolConfig.setMinIdle(5);
JedisPool jedisPool = new JedisPool(poolConfig, "localhost", 6379);
Jedis jedis = jedisPool.getResource();
// 执行命令
jedis.set("foo", "bar");
// 关闭连接
jedis.close();

6. Pipelining批量命令

解决方案:使用Pipeline批量执行命令,减少网络延迟。

示例代码(Java Jedis Pipeline):

Jedis jedis = jedisPool.getResource();
Pipeline pipeline = jedis.pipeline();
pipeline.set("foo", "bar");
pipeline.get("foo");
List<Object> results = pipeline.syncAndReturnAll();
jedis.close();

7. 键值对设计

解决方案:选择合适的数据类型,使用散列(Hash)存储相关联的字段。

示例代码:

// 使用Hash存储用户信息
hset "user:1000" "name" "John Doe"
hset "user:1000" "email" "john@example.com"

8. 内存优化

解决方案:使用内存淘汰策略,如volatile-lru或allkeys-lru。

示例配置:

maxmemory-policy allkeys-lru

9. 持久化策略

解决方案:根据数据的重要性选择合适的持久化方式(RDB、AOF或两者结合)。

示例配置:

appendonly yes
appendfsync everysec

10. 禁用持久化

解决方案:对于可以容忍数据丢失的场景,可以完全禁用持久化。

示例配置:

save ""
appendonly no

11. Lua脚本

解决方案:使用Lua脚本来打包多个命令,减少网络延迟。

示例代码:

-- Lua脚本,实现原子增减操作
return redis.call('INCR', KEYS[1])

12. 慢查询日志

解决方案:开启慢查询日志,分析慢查询原因。

示例配置:

slowlog-log-slower-than 10000
slowlog-max-len 128

13. 主从复制

解决方案:使用主从复制提高读性能,同时实现数据的热备份。

示例配置:

slaveof <masterip> <masterport>

14. Redis集群

解决方案:使用Redis集群实现数据的自动分区和高可用。

示例命令:

./redis-trib.rb create --replicas 1 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002

15. 监控和报警

解决方案:使用Redis自带的监控工具或第三方监控系统,实时监控Redis状态。

示例命令:

redis-cli info

16. 禁用THP

解决方案:禁用Transparent HugePages,避免内存页管理的性能损耗。

示例命令:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

17. 操作系统优化

解决方案:调整操作系统参数,如文件描述符限制、TCP缓冲区大小等。

示例命令:

sysctl -w net.core.somaxconn=1024
ulimit -n 4096

18. 网络优化

解决方案:优化TCP堆栈参数,如TCP接收和发送缓冲区大小。

示例命令:

sysctl -w net.ipv4.tcp_rmem='4096 87380 6291456'
sysctl -w net.ipv4.tcp_wmem='4096 16384 4194304'

19. 数据压缩

解决方案:对于大体积的数据,使用数据压缩算法减少存储大小和传输时间。

示例:使用ZIPLIST编码的数据结构存储小对象。

20. 优化键设计

解决方案:设计具有前缀的键名,便于管理和迁移。

示例:

// 使用命名空间来区分不同的数据类型
set user:1000:name "John Doe"
set user:1000:email "john@example.com"

21. 避免大Key和大Value

解决方案:大Key和大Value会影响Redis的性能和稳定性,应尽量避免。

示例:将大的列表或集合分割成多个小的集合。

22. 使用二进制序列化

解决方案:使用二进制序列化协议提高数据传输效率。

示例:使用MSGPACK或PROTOBUF序列化Java对象。

23. 优化数据访问模式

解决方案:根据业务特点,优化数据的访问模式,如使用缓存预热、缓存雪崩的解决方案等。

24. 合理的数据过期策略

解决方案:为数据设置合理的过期时间,避免过期数据占用内存。

示例配置:

expire user:1000:email 86400

25. 减少网络延迟

解决方案:优化网络环境,使用QoS策略减少网络延迟。

26. 使用SSD而不是HDD

解决方案:使用固态硬盘(SSD)代替机械硬盘(HDD),提高磁盘I/O性能。

27. 优化持久化日志

解决方案:调整AOF持久化的策略,比如使用everysec或no选项。

示例配置:

appendfsync no

28. 使用Redis 4.0以上的版本

解决方案:新版本的Redis提供了更多的功能和性能改进,如增加了模块系统、支持多线程等。

29. 避免使用阻塞命令

解决方案:在可能的情况下,避免使用可能导致阻塞的命令,如BLPOP、BRPOP等。

30. 定期进行性能评估

解决方案:定期对Redis实例进行性能评估,根据评估结果调整配置。

示例工具:使用redis-benchmark工具进行基准测试。

31. 使用Redisson客户端

解决方案:对于Java应用,使用Redisson客户端可以提供更高级的功能,如分布式锁、原子操作等。

示例代码(Redisson配置):

Config config = new Config();
SingleServerConfig singleServerConfig = config.useSingleServer();
singleServerConfig.setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

32. 避免全量扫描

解决方案:在可能的情况下,避免使用KEYS命令进行全量扫描,这会导致性能急剧下降。

33. 优化数据迁移

解决方案:在进行数据迁移时,使用MIGRATE命令,它可以原子性地迁移数据。

示例命令:

MIGRATE "127.0.0.1" 6379 "127.0.0.1" 6380 "key" 0 5000 REPLACE

34. 优化日志级别

解决方案:根据需要调整日志级别,避免冗余日志占用过多磁盘空间和CPU资源。

示例配置:

loglevel warning

35. 优化Redis配置文件

解决方案:定期审查和优化redis.conf配置文件,以匹配当前的业务需求。

最后

以上是Redis企业级性能优化的一些关键点和解决方案。

在实施这些优化措施时,V 哥强调一下,需要考虑到业务的具体需求和Redis实例的当前状态,以确保优化措施能够带来性能上的提升,同时避免引入新的问题。此外,对于任何重要的配置更改,都应该先在测试环境中进行验证,以确保优化措施的有效性和系统的稳定性。

BusinessLayerValidation介绍

BusinessLayerValidation,即业务层验证,是指在软件应用程序的业务逻辑层(Business Layer)中执行的验证过程。业务逻辑层是应用程序架构中的一个关键部分,负责处理与业务规则和逻辑相关的操作。业务层验证的主要目的是确保数据在业务规则和逻辑上的有效性,从而维护数据的完整性和一致性。

  • 业务层验证的主要目的是确保数据在业务规则和逻辑上的有效性。这包括检查数据是否符合特定的业务规则、数据完整性检查、数据一致性检查等。
  • 通过在业务层进行验证,可以确保数据在整个应用程序中的正确性和一致性,而不仅仅是在用户界面层。

在WPF中如何实现BusinessLayerValidation

在WPF Samples中有一个Demo提到了BusinessLayerValidation。

该Demo结构如下所示:

image-20240625143242336

实现的效果如下所示:

当输入值是字符时会提示不是正确的形式,当输入小于0或大于150的数时会有一个自定义的提示。

IDataErrorInfo接口

在WPF中实现业务逻辑层的验证可以使用IDataErrorInfo接口与INotifyDataErrorInfo 接口。这个Demo使用的是IDataErrorInfo接口。IDataErrorInfo接口主要用于在数据绑定场景中提供自定义的验证错误信息。IDataErrorInfo接口包含两个成员:

string IDataErrorInfo.Error { get; }

这个属性返回一个字符串,表示整个对象的错误信息。如果对象没有错误,应该返回一个空字符串或null。

string IDataErrorInfo.this[string columnName] { get; }

这个索引器用于返回指定属性的错误信息。columnName参数表示属性的名称,索引器应该返回该属性的错误信息。如果属性没有错误,应该返回一个空字符串或null。

通过实现IDataErrorInfo接口,可以为数据对象提供详细的验证错误信息,这些信息可以在用户界面中显示,帮助用户理解和纠正输入错误。

定义一个实现该接口的Person类:

 public class Person : IDataErrorInfo
 {
     public int Age { get; set; }
     public string Error => null;

     public string this[string name]
     {
         get
         {
             string result = null;

             if (name == "Age")
             {
                 if (Age < 0 || Age > 150)
                 {
                     result = "Age must not be less than 0 or greater than 150.";
                 }
             }
             return result;
         }
     }
 }

定义资源

  <local:Person x:Key="Data"/>

现在xaml中定义了一个Person对象为资源,键名为Data。

<!--The tool tip for the TextBox to display the validation error message.-->
<Style x:Key="TextBoxInError" TargetType="TextBox">
    <Style.Triggers>
        <Trigger Property="Validation.HasError" Value="true">
            <Setter Property="ToolTip"
                    Value="{Binding RelativeSource={x:Static RelativeSource.Self},
                                    Path=(Validation.Errors)[0].ErrorContent}"/>
        </Trigger>
    </Style.Triggers>
</Style>

为TextBox控件定义了一个键名为TextBoxInError的样式(Style)。

触发器(Trigger)

在WPF中,触发器(Trigger)是一种用于在特定条件满足时改变控件外观或行为的机制。Trigger通常用于Style

或ControlTemplate中,以便在属性值变化或事件发生时自动应用视觉效果或行为。

WPF 中的Trigger可以分为以下几类:

  • Property Trigger
  • Data Trigger
  • Event Trigger
  • MultiTrigger
  • MultiDataTrigger

Property Trigger
是最常用的触发器类型。它根据依赖属性的值来应用视觉效果或行为。

示例:

<Style TargetType="Button">
  <Style.Triggers>
    <Trigger Property="IsMouseOver" Value="True">
      <Setter Property="Background" Value="Yellow"/>
    </Trigger>
  </Style.Triggers>
</Style>

在这个例子中,当鼠标悬停在按钮上时,按钮的背景色会变为黄色。

Data Trigger
类似于Property Trigger,但它用于绑定数据上下文中的属性,而不是控件本身的依赖属性。

示例:

<Style TargetType="TextBlock">
  <Style.Triggers>
    <DataTrigger Binding="{Binding Path=IsEnabled}" Value="False">
      <Setter Property="Foreground" Value="Gray"/>
    </DataTrigger>
  </Style.Triggers>
</Style>

在这个例子中,当数据上下文中的IsEnabled属性为False时,文本块的前景色会变为灰色。

Event Trigger
用于在特定事件发生时执行动画或命令。

示例:

<Button Content="Click Me">
  <Button.Triggers>
    <EventTrigger RoutedEvent="Button.Click">
      <BeginStoryboard>
        <Storyboard>
          <DoubleAnimation Storyboard.TargetProperty="Opacity" From="1" To="0" Duration="0:0:1"/>
        </Storyboard>
      </BeginStoryboard>
    </EventTrigger>
  </Button.Triggers>
</Button>

在这个例子中,当按钮被点击时,按钮的透明度会从 1 变为 0,持续 1 秒钟。

MultiTrigger

MultiDataTrigger
允许你指定多个条件,只有当所有条件都满足时,才会应用视觉效果或行为。

示例:

<Style TargetType="TextBox">
  <Style.Triggers>
    <MultiTrigger>
      <MultiTrigger.Conditions>
        <Condition Property="IsFocused" Value="True"/>
        <Condition Property="Text" Value=""/>
      </MultiTrigger.Conditions>
      <Setter Property="BorderBrush" Value="Red"/>
    </MultiTrigger>
  </Style.Triggers>
</Style>

在这个例子中,当文本框获得焦点且文本为空时,文本框的边框颜色会变为红色。

通过使用这些触发器,可以创建动态和响应式的用户界面,根据不同的条件自动调整控件的外观和行为。

通过回顾WPF中的这些触发器,再来看看现在遇到的触发器:

 <Style.Triggers>
        <Trigger Property="Validation.HasError" Value="true">
            <Setter Property="ToolTip"
                    Value="{Binding RelativeSource={x:Static RelativeSource.Self},
                                    Path=(Validation.Errors)[0].ErrorContent}"/>
        </Trigger>

这是属性触发器,当Validation.HasError属性的值为true时,会将ToolTip的值设置为(Validation.Errors)[0].ErrorContent的值。

之前我们已经接触过StaticSource这表示是静态资源,现在这里是RelativeSource,让我们来了解一下它吧。

RelativeSource是WPF中的一个绑定标记扩展,用于指定数据绑定的源相对于绑定目标的位置。它允许你在绑定表达式中引用相对于当前控件或数据上下文的另一个对象,而不是直接指定一个固定的源对象。

RelativeSource可以用于以下几种模式:

  • Self
    :绑定到控件自身的一个属性。
  • TemplatedParent
    :绑定到应用控件模板的控件。
  • PreviousData
    :在数据模板中绑定到前一个数据项。
  • FindAncestor
    :绑定到可视树中的祖先控件。

可以创建更灵活和动态的数据绑定,特别是在处理复杂的用户界面和控件模板时。

再来看文本框部分:

 <StackPanel Margin="20">
     <TextBlock>Enter your age:</TextBlock>
     <TextBox Style="{StaticResource TextBoxInError}">
         <TextBox.Text>
             <!--By setting ValidatesOnExceptions to True, it checks for exceptions
             that are thrown during the update of the source property.
             An alternative syntax is to add <ExceptionValidationRule/> within
             the <Binding.ValidationRules> section.-->
             <Binding Path="Age" Source="{StaticResource Data}"
                      ValidatesOnExceptions="True"
                      UpdateSourceTrigger="PropertyChanged">
                 <Binding.ValidationRules>
                     <!--DataErrorValidationRule checks for validation 
                         errors raised by the IDataErrorInfo object.-->
                     <!--Alternatively, you can set ValidationOnDataErrors="True" on the Binding.-->
                     <DataErrorValidationRule/>
                 </Binding.ValidationRules>
             </Binding>
         </TextBox.Text>
     </TextBox>
     <TextBlock>Mouse-over to see the validation error message.</TextBlock>
 </StackPanel>

首先为文本框设置了键名为TextBoxInError的样式。

 <TextBox.Text>   
 <Binding Path="Age" Source="{StaticResource Data}"
          ValidatesOnExceptions="True"
          UpdateSourceTrigger="PropertyChanged">
      <Binding.ValidationRules>
                 <DataErrorValidationRule/>
      </Binding.ValidationRules>
             </Binding>
  </TextBox.Text>

ValidatesOnExceptions属性指定了是否在数据绑定过程中捕获并处理异常。ValidatesOnExceptions="True"

表示如果在数据绑定过程中发生了异常(例如,数据源的Age属性在设置值时抛出了异常),这个异常将会被捕获并用于验证目的,通常用于显示错误信息或阻止无效数据的输入。

  <Binding.ValidationRules>
        <DataErrorValidationRule/>
  </Binding.ValidationRules>

Binding.ValidationRules是一个集合属性,用于存储数据绑定的验证规则。这些验证规则用于在数据绑定过程中检查数据的合法性。DataErrorValidationRule是WPF中预定义的一种验证规则。它用于检查数据对象是否实现了

IDataErrorInfo接口,并根据该接口提供的信息进行验证。当数据对象实现了IDataErrorInfo接口时,DataErrorValidationRule会调用接口中的方法来获取错误信息,并在数据绑定过程中应用这些错误信息。

验证类型

默认验证
:当你输入 "a" 时,WPF尝试将 "a" 转换为整数,但由于 "a" 不是有效的整数,默认验证机制会生成一个错误,提示 "不是正确的格式"。

自定义验证
:在你输入一个有效的整数后,WPF会继续检查这个值是否符合你的自定义验证规则(例如,年龄必须在0到150之间)。如果值不符合规则,你会看到 "Age must not be less than 0 or greater than 150" 的提示。

在WPF中,数据绑定的验证过程通常遵循以下顺序:

数据转换(Data Conversion)

如果绑定的数据需要从一种类型转换为另一种类型(例如,从字符串转换为整数),WPF会首先尝试进行类型转换。如果转换失败,会抛出一个异常,这个异常可以被捕获并用于验证目的。

验证规则(Validation Rules)

如果绑定的ValidationRules集合中定义了任何自定义的验证规则,WPF会按照这些规则的顺序依次执行它们。每个验证规则可以返回一个ValidationResult对象,指示数据是否有效以及相关的错误信息。

异常验证(Exception Validation)

如果绑定的ValidatesOnExceptions属性设置为True,WPF会在数据绑定的过程中捕获任何抛出的异常,并将这些异常转换为验证错误。这通常用于捕获数据转换或数据设置过程中的异常。

IDataErrorInfo 验证

如果数据对象实现了IDataErrorInfo接口,WPF会调用该接口的方法来获取错误信息。DataErrorValidationRule

可以用于在数据绑定过程中应用这些错误信息。

INotifyDataErrorInfo 验证

如果数据对象实现了INotifyDataErrorInfo接口,WPF会监听该接口的事件,并在数据发生错误时获取错误信息。这种验证方式提供了更灵活和异步的验证机制。

这些验证步骤并不是严格按顺序执行的,而是根据绑定的配置和数据对象的实现情况来决定。例如,如果绑定的

ValidationRules集合中包含了DataErrorValidationRule,那么IDataErrorInfo验证会在验证规则执行的过程中被触发。

WPF中的验证顺序是灵活的,并且可以根据具体的绑定配置和数据对象的实现情况来调整。通常情况下,数据转换和自定义验证规则会在异常验证和IDataErrorInfo验证之前执行。

代码来源

[WPF-Samples/Data Binding/BusinessLayerValidation at main · microsoft/WPF-Samples (github.com)](
https://github.com/microsoft/WPF-Samples/tree/main/Data
Binding/BusinessLayerValidation)

大家好,本文分享的是如何生成简单动画让图形动起来。

在可视化展现中,动画它是强化数据表达,吸引用户的重要技术手段。

在具体实现动画之前,我们先来了解一下动画的三种形式,分别是固定帧动画、增量动画和时序动画。

graph LR
A[动画的三种形式] --> B[固定帧动画]
A --> D[增量动画]
A --> E[时序动画]
B --> F[使用已生成的静态图像,将图像依次播放]
D --> C[动态绘制图像]
E --> C

固定帧动画的实现,是使用已生成的静态图像,然后将这些图像依次播放,而后面两种,增量动画和时序动画,都是需要动态绘制图像。可想而知,后面这两种动画形式会更灵活一些。

接下来,我们就来了解如何在HTML/CSS和Shader中实现动画效果。

HTML/CSS

首先,我们来了解如何在HTML/CSS中实现动画。

固定帧动画

先来看固定帧动画的一个例子,这个代码实现的是一个飞动的小鸟。

e.g.动态的小鸟

<!-- 固定帧动画 -->
<div v-show="checkedTab === 0" style="position: relative;">
  <div class="fixed-frame"></div>
</div>
/*固定帧动画*/
.fixed-frame {
  position: absolute;
  left: 100px;
  top: 100px;
  width: 86px;
  height: 60px;
  zoom: 0.5;
  background-repeat: no-repeat;
  background-image: url("@/assets/bird.png");
  background-position: -178px -2px;
  animation: flappy .5s step-end infinite;
}
@keyframes flappy {
  0%  {background-position: -178px -2px;}
  33% {background-position: -90px -2px;}
  66% {background-position: -2px -2px;}
}

image

很显然,在实现这个固定帧动画之前,我们需要预先准备好静态图片,这个例子中我们使用的是雪碧图,也叫CSS精灵,是将小图合并在一起形成的图片,在这里我们设置background-image来指定背景图,然后通过animation动态修改background-position来逐帧切换,最终形成一个动态的效果。当然如果我们使用的是多张图片,直接切换background-image也是可以的。

其中
step-end
会使 keyframes 动画到了定义的关键帧处直接突变,没有变化的过程。

通过这个例子我们能发现,固定帧动画实现起来非常简单,比较适合的场景是提供现成图片的动画帧图像,如果要去动态绘制图像,就不太合适。如果要生成动态绘制的图像,也就是非固定帧动画,通常会使用另外两种方式。

增量动画

先来看增量动画,其实从名称上看,我们就能有一个大致的概念,增量嘛,就是增加数量,所以增量动画就是在动画的每一帧给属性一个增量。

下面是一个简单的旋转方块的动画例子,是一个旋转的蓝色方块。

<!-- 增量动画 -->
<div style="position: relative;">
  <div class="increase-frame" ref="increaseRef"></div>
</div>
/*增量动画*/
.increase-frame {
  position: absolute;
  left: 100px;
  top: 100px;
  width: 100px;
  height: 100px;
  background-color: blue;
  transform-origin: 50% 50%;
}
let rotation = 0;
requestAnimationFrame(function update() {
  increaseRef.value.style.transform = `rotate(${rotation ++}deg)`;
  requestAnimationFrame(update);
});

image

以上动画实现的关键逻辑就在于修改rotation的值,在每次绘制的时候将它加1。

这种绘制方式实现起来也比较简单,但是它不太容易去控制动画的细节,比如动画周期、变化率、轨迹等等;而且它定义的是状态变化,也就是根据上一刻的状态来计算得到下一刻的状态,这种方式在Shader中实现起来并不太方便,需要像上篇所提到的那样,去使用后期通道来进行处理,很显然,这样做会比较繁琐。

所以如果是比较复杂的动画,我们一般通过定义时间和动画函数来实现,也就是通过时序动画的方式来实现动画效果。

时序动画

关于如何去实现时序动画,我们也直接来看个例子。

e.g.旋转的蓝色方块

const startAngle = 0;
const T = 2000; // 周期。旋转这一周的时间
let startTime = null;
function update() {
  startTime = startTime === null ? Date.now() : startTime;
  const p = (Date.now() - startTime) / T;
  const angle = startAngle + p * 360;
  timeOrderRef.value.style.transform = `rotate(${angle}deg)`;
  requestAnimationFrame(update);
}
update();

这段代码中,我们定义了三个变量,startAngle是起始旋转角度,T是旋转周期,代表完成一次动画、一次旋转需要的时间,startTime表示每一次动画的开始时间。

在update函数中,我们通过
Date.now() - startTime
去得到当前经过的时间,然后除以周期T,就能得到旋转进度 p ,最后根据起始旋转角度和进度 p,计算得到旋转角度angle,并且赋值给transform属性,这样就实现了旋转动画。

image

根据这个例子,我们可以将时序动画的实现总结为三个步骤:

第一步,定义初始时间和周期;

第二步,在update中计算当前经过的时间和进度;

第三步,通过进度来更新动画元素的属性。

时序动画的优点是,可以更直观、精确地控制动画的周期(也是速度)等参数;它的缺点就是写法相对比较复杂,但是因为它的优点、可以更好控制动画的效果,所以在动画实现中最为常用。

标准动画模型

既然时序动画是
最常用的
动画实现形式,那么我们可以把它的三个步骤抽象成标准的动画模型,来方便后续的动画实现。

  • 首先,定义一个类、Timing用于处理时间

    /**
     * 用于处理动画的时间
     */
    export class Timing {
        constructor({duration, iterations = 1} = {}) {
            this.startTime = Date.now();
            this.duration = duration; // 周期
            this.iterations = iterations; // 重复次数
        }
    
        /**
         * 动画经过的时间
         * @returns {number}
         */
        get time() {
            return Date.now() - this.startTime;
        }
    
        /**
         * 动画进度
         * @returns {number|number}
         */
        get p() {
            // 动画持续了几个周期
            const progress = Math.min(this.time / this.duration, this.iterations);
            // 动画已结束:进度1
            // 动画未结束:0~1
            return this.isFinished ? 1 : progress % 1;
        }
    
        /**
         * 动画是否已结束
         * @returns {boolean}
         */
        get isFinished() {
            // 动画持续了几个周期是否已达到指定次数
            return this.time / this.duration >= this.iterations;
        }
    }
    

    这几个方法都很容易理解。

  • 然后,实现一个Animator类,用于控制动画过程。

    export class Animator {
        constructor({duration, iterations}) {
            this.timingParam = {duration, iterations};
        }
    
        /**
         * 执行动画
         * @param target
         * @param update
         * @returns {Promise<unknown>}
         */
        animate(target, update) {
            let frameIndex = 0; // 帧序号
            const timing = new Timing(this.timingParam);
    
            return new Promise(resolve => {
                function next() {
                    // 通过执行update更新动画
                    if(update({target, frameIndex, timing}) !== false
                        && !timing.isFinished) {
                        requestAnimationFrame(next);
                    } else {
                        resolve(timing);
                    }
                    frameIndex ++;
                }
                next();
            })
        }
    }
    

    animate
    方法,会在执行时创建一个timing对象,最后返回一个promise对象。这里通过执行update更新动画,在动画结束时,resolve这个promise。

现在我们就可以使用这个模型,来尝试实现动画效果了。来看下面这个例子。

在这个例子中,我们让每个方块转动的周期是1秒,一共旋转1.5个周期(也就是540度)。

<div class="container">
  <div class="block"></div>
  <div class="block"></div>
  <div class="block"></div>
  <div class="block"></div>
</div>
const blocks = document.querySelectorAll('.block');
const animator = new Animator({duration: 1000, iterations: 1.5});
(async function() {
  let i = 0;
  while(true) {
    await animator.animate(blocks[i++ % 4], ({target, timing}) => {
      target.style.transform = `rotate(${timing.p * 360}deg)`;
    });
  }
}());
.container {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  width: 300px;
}
.block {
  width: 100px;
  height: 100px;
  margin: 20px;
  flex-shrink: 0;
  transform-origin: 50% 50%;
  &:nth-child(1) {background-color: red;}
  &:nth-child(2) {background-color: blue;}
  &:nth-child(3) {background-color: green;}
  &:nth-child(4) {background-color: orange;}
}

image

可以看到,这个效果我们很方便地通过前面定义的Animator实现了。

插值与缓动函数

在前面的例子中,我们看到的动画效果都是匀速运动的,图像是匀速变化的,显然在实际中这是不够满足需求的,既然时序动画可以让我们更容易地控制动画的细节,所以它也可以让我们实现一些不规则的运动。

假设已知元素的起始状态、结束状态和运动周期,如果想要让它进行
不规则运动
,我们可以使用插值的方式来控制每一帧的展现。

下面我们来看一个动画:这是一个匀速运动的方块,我们用Animator实现,让这个方块从100px处匀速运动到400px。

const block = document.querySelector('.block');
const animator = new Animator({duration: 3000});
document.addEventListener('click', () => {
  animator.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
      const left = start * (1 - p) + end * p;
      el.style.left = `${left}px`;
    });
});

image

这里我们用了一个线性插值方法:
left = start * (1 - p) + end * p

线性插值可以很方便地实现属性的均匀变化
,所以用它来让方块做匀速运动是非常简单的。

如果要让方块进行非匀速运动,比如匀加速运动,我们仍然可以用线性插值的方式,只不过要对参数 p 做一个函数映射。比如要让方块做初速度为0的匀加速运动,我们可以将 p 映射为p 的平方;如果要让方块做末速度为0的匀减速运动,可以将p映射为p*(2-p)。那为什么是这样映射呢?

这就要提到匀加速和匀减速的物理计算公式了。有些小伙伴很久没接触物理公式,可能会有些遗忘,这里简单回顾一下。

假设,某个物体在做初速度为0的匀加速运动,运动的总时间为T,总位移为S。那么,它的加速度和在 t 时刻的位移的计算公式是这样的:

\[a = \frac{2S}{T^2} \\
S_t = \frac{1}{2}at^2 = S(\frac{t}{T})^2 = Sp^2
\]

所以在匀加速运动中,我们把 p 映射为 p 的平方。

同样的,如果物体在做匀减速运动,那么,它的加速度和在 t 时刻的位移的计算公式是这样的:

\[a = -\frac{2S}{T^2} \\
S_t = \frac{2S}{T}t - S(\frac{t}{T})^2 = Sp(2 - p)
\]

所以在匀减速运动中,我们把 p 映射为 p*(2 - p)。

在实际应用中,我们还可以对p 应用更多映射,来实现不同的动画效果,为了方便实现更多的效果,我们可以抽象出
一个函数来专门处理p的映射,这个函数就叫做缓动函数

我们可以在Timing类中直接增加一个缓动函数easing,在获取p 的时候,直接用
this.easing(progress % 1)
取代
progress %1

现在我们可以来尝试使用下缓动函数。

const animator2 = new Animator({duration: 3000, easing: p => p ** 2});
document.addEventListener('click', () => {
  animator2.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
    const left = start * (1 - p) + end * p;
    el.style.left = `${left}px`;
  });
});

image

缓动函数有很多种,实际中比较常用的是贝塞尔曲线缓动,我们可以使用现成的JavaScript库
bezier-easing
来生成贝塞尔缓动函数,比如:

const animator3 = new Animator({duration: 3000, easing: BesizerEasing(0.5, -1.5, 0.5, 2.5)});
document.addEventListener('click', () => {
  animator3.animate({el: block, start: 100, end: 400}, ({target: {el, start, end}, timing: {p}}) => {
    const left = start * (1 - p) + end * p;
    el.style.left = `${left}px`;
  });
});

image

贝塞尔缓动函数有很多种,大家可以参考
easing.net
这个网站,尝试利用里面提供的缓动函数。

看到这里,关于如何去实现动画,相信大家都有一定的思路了。那么现在我们也可以尝试在Shader中去实现动画效果。

Shader

固定帧动画

首先我们还是先来看固定帧动画的实现。

直接来看具体的例子,还是之前那个飞动的小鸟的例子。

// 片元着色器
varying vec2 vUv;
uniform sampler2D tMap;
uniform float fWidth;
uniform vec2 vFrames[3]; // 3个二维向量,二维向量表示每一帧动画的图片起始x和结束x坐标
uniform int frameIndex;

void main() {
  vec2 uv = vUv;
  for (int i = 0; i < 3; i ++) {
    // 纹理坐标ux.x的取值范围
    // 第0帧:[2/272, 88/272] 约等于 [0.007,0.323]
    // 第1帧:[90/272, 176/272] 约等于 [0.330,0.647]
    // 第2帧:[178/272, 264/272] 约等于 [0.654,0.970]
    uv.x = mix(vFrames[i].x, vFrames[i].y, vUv.x) / fWidth; // vUv 到 uv的映射
    if(float(i) == mod(float(frameIndex), 3.0)) break; // frameIndex除3的余数:0-循环一次;1-循环两次;2-循环三次。(渲染第几帧)
  }

  vec4 color = texture2D(tMap, uv); // 按照uv坐标取色值

  gl_FragColor = color;
}

我们在片元着色器中获取纹理,通过纹理坐标读取图像上的像素信息。

vFrames是一个重要的参数,包含3个二维向量,每一个二维向量表示一帧图片的起始x和结束x坐标。

for循环是main函数中的关键部分,在循环内部,我们用二维向量中的两个坐标,来计算插值,最后除以图片的总宽度,得到一个 vUv 到 uv 坐标映射。

在对纹理进行采样时,我们就用这个uv的坐标值去进行颜色提取。

然后看JavaScript部分的代码:

(async function() {
  renderer.uniforms.tMap = await renderer.loadTexture(birdpng);
  renderer.uniforms.vFrames = [2, 88, 90, 176, 178, 264];
  renderer.uniforms.fWidth = 272;
  renderer.uniforms.frameIndex = 0;
  setInterval(() => {
    renderer.uniforms.frameIndex ++;
  }, 200);
  // 顶点坐标(WebGL画布绘制范围)
  const x = 43 / glRef.value.width; // 每帧的宽度(86/2)
  const y = 30 / glRef.value.height; // 每帧的高度(60/2)
  renderer.setMeshData([{
    positions: [
      [-x, -y],
      [-x, y],
      [x, y],
      [x, -y]
    ],
    attributes: {
      uv: [
        [0, 0],
        [0, 1],
        [1, 1],
        [1, 0]
      ]
    },
    cells: [
      [0, 1, 2],
      [2, 0, 3]
    ]
  }]);
  renderer.render();
}());

我们按照每帧图片的宽高比例设置了顶点坐标的范围,vFrames数组存储的是每一帧图像对应的x坐标范围,动画切换的关键代码就是setInterval中的frameIndex ++。

可以看到在Shader中实现固定帧动画也是比较简单的。

非固定帧动画

对于非固定帧动画,因为时序动画是最常用的实现形式,所以我们直接看时序动画。

大家都知道,在WebGL中有两类着色器,那么对动画的实现应该写在哪类着色器中呢?答案是,两个都可以。

顶点着色器

我们先来看顶点着色器的例子。

attribute vec2 a_vertexPosition;
attribute vec2 uv;
uniform float rotation;

void main() {
  gl_PointSize = 1.0;
  float c = cos(rotation);
  float s = sin(rotation);
  mat3 transformMatrix = mat3(
    c,  s, 0,
    -s, c, 0,
    0,  0, 1
  );
  vec3 pos = transformMatrix * vec3(a_vertexPosition, 1); // 映射新的坐标
  gl_Position = vec4(pos, 1);
}

这段代码中我们要实现的是一个旋转的红色方块。在这里我们用到了旋转矩阵,对于transform不熟悉的小伙伴可以参考我之前的文章
《CSS transform与仿射变换》

在Shader中会绘制出一个红色的正方形,然后三维的齐次矩阵会让这个红色方块旋转起来。我们可以直接通过下面这段JavaScript去动态更新旋转的角度rotation,就能看到动画效果了:

// ...
renderer.uniforms.rotation = 0.0;
requestAnimationFrame(function update() {
  renderer.uniforms.rotation += 0.05;
  requestAnimationFrame(update);
});
// ...

我们也可以使用前面定义的Animator对象去更精确地控制图形的旋转效果。

// ...
renderer.uniforms.rotation = 0.0;
const animator = new Animator({duration: 2000, iterations: Infinity});
animator.animate(renderer, ({target, timing}) => {
  target.uniforms.rotation = timing.p * 2 * Math.PI;
});
// ...

可以看到,这里更新uniform属性和前面更新HTML元素的属性,这两种操作从代码上看很相似。

片元着色器

接着我们来看片元着色器的例子。

varying vec2 vUv;
uniform vec4 color;
uniform float rotation;

void main() {
  vec2 st = 2.0 * (vUv - vec2(0.5));
  float c = cos(rotation);
  float s = sin(rotation);
  mat3 transformMatrix = mat3(
    c, s, 0,
    -s, c, 0,
    0, 0, 1
  );
  vec3 pos = transformMatrix * vec3(st, 1.0); // 坐标系旋转
  float d1 = 1.0 - smoothstep(0.5, 0.505, abs(pos.x)); // abs(x)<0.5 d1=1
  float d2 = 1.0 - smoothstep(0.5, 0.505, abs(pos.y)); // abs(y)<0.5 d2=1
  gl_FragColor = d1 * d2 * color;
}

这段代码中,我们通过距离场着色的方式绘制了正方形,同样传递了rotation来控制方块的旋转角度。

我们能很明显的发现,片元着色器和前面顶点着色器的实现,最终实现的效果上,两个方块的旋转方向不一致。顶点着色器中是逆时针旋转,片元着色器中是顺时针旋转,这是因为在顶点着色器中,我们是直接改变了顶点坐标,通过旋转矩阵的处理映射到了新的顶点,而在片元着色器中的坐标变换,相当于是把坐标系做了旋转,最终绘图的图形是相对于新的坐标系去计算距离场的距离,所以最终就呈现了相反的旋转效果。

选择

那么既然两类着色器都能实现动画效果,在实际使用中我们要怎么选择呢?一般来说,动画如果能使用顶点着色器实现,会尽量在顶点着色器中实现。因为在绘制一帧画面的时候,顶点着色器的运算量会大大少于片元着色器,所以使用顶点着色器消耗的性能更少。

但是假如我们需要绘制更复杂的效果,比如运用大量的重复、随机、噪声,那么使用片元着色器更合适。

所以具体的,还是要根据我们最终想要达到的效果、去选择合适的实现方式。

Shader缓动函数

和HTML/CSS中的例子一样,如果我们想要在Shader中实现非匀速运动,也可以直接使用Animator对象,在JavaScript中使用缓动函数,但是在WebGL中除了这种方式之外,我们也可以选择直接把缓动函数写在Shader中,比如下面这个例子:

// vertex
attribute vec2 a_vertexPosition;
uniform vec4 uFromTo;
uniform float uTime;

float easing(in float p) {
  // return smoothstep(0.0, 1.0, p);
  // return clamp(p * p, 0.0, 1.0); // 匀加速

  return clamp(p * (2.0 - p), 0.0, 1.0); // 0->1->0 // 先减速后加速

  // if(p < 1.0) return clamp(p * (2.0 - p), 0.0, 1.0);
  // else return 1.0;
}

void main() {
  gl_PointSize = 1.0;
  vec2 from = uFromTo.xy;
  vec2 to = uFromTo.zw;
  float p = easing(uTime / 2.0);
  vec2 translation = mix(from, to, p);
  mat3 transformMatrix = mat3(
    1, 0, 0,
    0, 1, 0,
    translation, 1
  );
  vec3 pos = transformMatrix * vec3(a_vertexPosition, 1);
  gl_Position = vec4(pos, 1);
}

可以用
smoothstep(0.0, 1.0, p)
来让方块做平滑变速运动;也可以替换缓动函数,使用比如
clamp(p*p, 0.0, 1.0)

clamp(p*(2.0-p), 0.0, 1.0)
来实现匀加速、匀减速的运动效果。

总结

以上就是关于动画实现的分享,主要介绍了动画的三种实现形式和具体操作,本文中都是比较简单的一些动画例子,希望能给到大家一些启发,去实现更复杂、更有意思的动画效果。

参考代码-HTML/CSS
参考代码-Shader
效果预览-HTML/CSS
效果预览-Shader

1. 环境变量

环境变量是指操作系统中记录一些配置信息的变量,这些变量在不同的程序之间共享,可以被操作系统或者 shell 脚本读取和修改。

环境变量也可以类比为各个语言中的全局变量,其作用域是全局的,所有的代码段或者说作用域都可以直接访问到这个变量。

1.1 查看环境变量

查看你环境变量的命令是
printenv

env

env # 查看全部环境变量,只有这一种方式
printenv # 查看全部环境变量
printenv [变量名] # 查看指定环境变量的值

比如

SHELL=/bin/bash
ROS_VERSION=2
SESSION_MANAGER=local/ruby:@/tmp/.ICE-unix/1804,unix/ruby:/tmp/.ICE-unix/1804
QT_ACCESSIBILITY=1
COLORTERM=truecolor
XDG_CONFIG_DIRS=/etc/xdg/xdg-ubuntu:/etc/xdg
XDG_MENU_PREFIX=gnome-
GNOME_DESKTOP_SESSION_ID=this-is-deprecated
GTK_IM_MODULE=fcitx
PKG_CONFIG_PATH=:/usr/local/lib/pkgconfig
ROS_PYTHON_VERSION=3
LANGUAGE=zh_CN:zh:en_US:en
QT4_IM_MODULE=fcitx
MANDATORY_PATH=/usr/share/gconf/ubuntu.mandatory.path
LC_ADDRESS=zh_CN.UTF-8
GNOME_SHELL_SESSION_MODE=ubuntu
LC_NAME=zh_CN.UTF-8
SSH_AUTH_SOCK=/run/user/1000/keyring/ssh
XMODIFIERS=@im=fcitx
DESKTOP_SESSION=ubuntu
PATH=/opt/ros/foxy/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/usr/local/cuda-11.8/bin

上面就是使用
printenv
命令查看到的环境变量。可以看出其中指定了很多系统重要的变量,如
SHELL
表明了bash所在的路径,因此我们可以直接访问到bash而不用指定路径。而
DESKTOP_SESSION
则表明当前的发行版是Ubuntu。

1.2 设置环境变量

设置环境变量的命令是
export

在设置环境变量前,先明确shell中的变量如何书写。

ENV=value # 等号左边为变量名,右边为变量值

这样就在shell中声明了一个变量。注意在一个shell中声明的变量,只对当前shell有效,不会影响其他shell。此时该变量只是普通的临时变量,不会被保存到环境变量中。

查看shell中的变量可以使用
echo
命令,
echo
可以将变量或者常量的值输出到终端。

echo "test echo"
test echo

因此也可以用来查看变量的值。

echo $ENV # 查看变量值
# 注意变量前要加$来取出其值

使用
export
来将变量导出为环境变量

export ENV # 将变量导出为环境变量

# 或者直接在export命令中指定变量名和值
export ENV=value

这样就可以在其他程序中使用该变量了。已经导出的环境变量可以直接全局赋值更改

ENV=new_value # 全局变量赋值

但是需要注意的是,对于使用命令行
export
导出的环境变量仅对该次会话生效,当关闭终端或者重新登录后,环境变量就会失效。如果想永久生效,需要修改系统配置文件。

1.3 修改配置文件来设置环境变量

在linux系统启动时,会自动按序读取一些配置文件并从中执行相应的shell命令。因此,我们只需要在这些配置文件中添加
export
命令,即可设置永久的环境变量。

在ubuntu(不同发行版有所不同)下,比较常用的有如下几个配置文件:

  • /etc/profile.d目录下的.sh文件和~/.bashrc 文件 对用户的shell进行初始化和环境变量设置
  • .
    /.bash_profile.d目录下的文件和
    ./bash_login 文件用于个别用户的shell环境初始化 用户登录时会读取内容

因此我们可以在这些文件中添加
export
命令来设置环境变量。

这里在选择在/etc/profile.d/目录下新建一个文件,叫做
source_env.sh
,并添加如下内容:

image

然后保存退出,执行

source /etc/profile.d/source_env.sh
# 这样可以直接生效,不需要重启系统

接着使用
env
或者
printenv
命令查看环境变量,就可以看到我们刚才设置的环境变量。

image
image

可以看到环境变量MYTEST已经被设置成功,并且可以被其他程序使用。

推荐使用新建文件的方式来添加自己的环境变量,这样可以避免对系统的其他部分造成影响,更加美观以及方便管理。

1.4 常用环境变量

对于比较常用的环境变量,这里给出一个,
PATH
环境变量

我们使用
printenv
查看
PATH
环境变量的值

printenv PATH
/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/usr/local/cuda-11.8/bin

可以看到其中是一串以冒号分隔的目录路径,这些目录包含了一些系统命令的可执行文件路径,如
ls

cd
等常用命令,正是由于这些路径被记录在PATH环境变量中,我们才可以直接在命令行中使用这些命令。

可以使用
witch
+ 空格 + 命令名 来查看某个命令的具体路径

which ls
/bin/ls

可以看到
ls
命令的具体路径是
/bin/ls
,而这个路径正是PATH环境变量中第一个目录/usr/bin中的一个。

因此在使用一些命令时报出
command not found
错误,可能是因为该命令的路径没有被添加到PATH环境变量中。可以使用
locate
命令来搜索带有某个关键字的文件路径,然后手动添加到PATH环境变量中。

locate ls # locate会搜索系统中所有文件名包含ls字符串的文件路径
/bin/ls 

然后将该路径添加到PATH环境变量中即可。

1.5 命令的别名

在shell中可以使用alias(翻译过来就是别名)来查看当前目录下所有命令的别名,也可以设置新的别名。

查看当前shell下所有命令的别名

alias

设置新的别名

alias 别名='原命令'

比如

alias ll='ls -l' # 设置ll命令的别名为ls -l
alias lll = 'ls -alh' # 设置lll命令的别名为ls -alh

这样就可以使用新的别名来代替原命令,如
ll
命令等价于
ls -l

lll
命令等价于
ls -alh

但是注意,设置的别名只对当前shell有效,不会影响其他shell。依旧可以将其加入到配置文件中,让其永久生效。

若要移除别名,可以使用
unalias
命令。

unalias 别名

比如

unalias ll

这样
ll
命令的别名就被移除了。
若想移除该shell下所有别名,可以使用
unalias -a
命令。

unalias -a

当然,
unalias
命令仅在该shell下有效,不会影响其他shell。

2. 输入与输出

linux最核心的思想就是一切皆文件,因此输入输出的控制也是由对应的文件来实现的。

2.1 终端的标准输入输出

在linux中,我们使用终端(terminal)来与计算机进行交互。终端的输入输出可以分为两种:

  • 标准输入输出(stdin/stdout):stdin(标准输入)是指键盘输入,stdout(标准输出)是指终端输出。
  • 错误输出(stderr):stderr(标准错误输出)是指终端输出错误信息。

打开一个终端,执行如下命令:

ls  -l /dev/{stdin,stdout,stderr}

lrwxrwxrwx 1 root root 15 6月  19  2024 /dev/stderr -> /proc/self/fd/2
lrwxrwxrwx 1 root root 15 6月  19  2024 /dev/stdin -> /proc/self/fd/0
lrwxrwxrwx 1 root root 15 6月  19  2024 /dev/stdout -> /proc/self/fd/1

可以看到在该终端下的stdin,stdout,stderr软链接到了/proc/self/fd/0,1,2三个文件上。而继续执行如下命令:

ls -l /proc/self/fd
lrwxrwxrwx 1 root root 0 6月  19  2024 /proc/self -> 10810

可以看到/proc/self目录下有一个软链接,指向当前进程的进程号。因此这样就可以知道当前进程的标准输入输出文件号。

2.2 管道

linux中的管道(pipe)是实现进程间通信的一种方式,它允许将一个进程的输出作为另一个进程的输入。

其语法如下:

命令1 | 命令2

其中,命令1的输出会作为命令2的输入。

以上一节的例子为例,我们可以编写一个简单的shell来尝试管道的功能

# in pipe.sh
echo "该进程的进程号为$$"   # echo $$ 输出当前终端的进程号
# in get.sh
read line # read + 变量名 可以从标准输入读取内容并赋值给变量
echo "读取到的行是:$line"

编写完这两个shell文件后赋予执行权限

chmod +x pipe.sh get.sh

然后运行

./pipe.sh |./get.sh

可以看到终端输出

该进程的进程号为10810

在使用管道符时,linux会隐式的创建一个临时的管道文件用来存储命令的输出,此时标准输出会软链到管道文件上,而标准输入则会软链到管道文件中读取内容。

当然,我们也可以显式的创建管道文件,称为具名管道

mkfifo  管道名 # 创建一个管道文件

这个管道文件是进程间通用的,可以被多个进程共享来读取。

可以使用cat命令或者echo >> 来读取和写入管道文件。

2.3 文件重定向

我们再理清2.1节中的一些概念。

  • 对于每个终端,会有属于自己的进程号,并将其的输入输出以及错误输出文件号记录在/proc/self目录下。
  • 而对于每个进程,其输入输出以及错误输出文件号被分配为0, 1, 2三个文件。
  • 在不加更改的情况下,0, 1, 2都会软链到/dev/stdin, /dev/stdout, /dev/stderr三个文件上,来实现终端上的。

而linux中文件的重定向可以理解为可以不将0, 1, 2描述符链接到标准的输入输出{stdin,stdout,stderr}三兄弟,而是将其连接到其他位置上。

linux中的重定向简单的分为种

  • 输出重定向,包含输出和错误输出
  • 输入重定向

2.3.1 输出重定向

输出重定向分为两种:

  • 覆盖输出(覆盖掉原来的内容)
  • 追加输出(在原来的内容后面追加)
命令 > 文件名  # 覆盖输出
命令 >> 文件名 # 追加输出

比如123.txt文件内容为123

cat 123.txt > 456.txt
cat 456.txt
123
cat 123.txt > 456.txt
cat 456.txt
123
cat 123.txt >> 456.txt
cat 456.txt
123
123

cat命令可以读取文件内容,并将其标准输出到终端,此时文件符1软链至stdout

1->stdout

但是此时我们使用重定向符号,改变了文件符1的定向,在运行时相当于

1->456.txt

运行后文件符1重新软链到stdout。这就是输出重定向。

2.3.X 错误输出重定向

有时我们并不期望命令或可执行文件的标准输出,而是希望检测其运行状态,获取其退出时的错误输出,因此需要将错误输出重定向到文件。

错误输出重定向的语法如下:

命令 2> 文件名
命令 2>> 文件名 # 追加错误输出
# 也就是在标准输出符后面加上2,表示将错误输出重定向到文件

同时shell支持将错误输出与标准输出一同重定向到同一个文件,语法如下:

命令 &> 文件名

2.3.2 输入重定向

输入重定向也有两种

  • 覆盖输入(覆盖掉原来的内容)
  • 从标准输入读取内容,直到遇到结束符
命令 < 文件名  # 覆盖输入
命令 << 结束符 # 从标准输入读取内容,直到遇到结束符

给出一个shell文件 read.sh

read line
echo "读取到的行是:$line"

前面说过,read命令旨在从终端的标准输入中读取内容,并将其赋值给变量。

赋予read.sh权限后,运行如下命令

chmod +x read.sh
read.sh < 123.txt
读取到的行是:123

可以看到,read命令读取了123.txt文件的内容,并将其赋值给变量line。

与输出重定向类似,输入重定向会改变文件符0的定向。
在重定向前,文件符0软链至stdin

0->stdin

在重定向后,文件符0重新软链至123.txt

0->123.txt

因此,read命令不再从stdin中读取内容,而是从123.txt文件中读取内容。

还有一种用法就是从标准输入读取内容直到遇到结束符,并将其作为命令的输入。

cat << 结束符 # 从标准输入读取内容直到遇到结束符

比如

cat << EOF
hello 
1
2
EOF
# 读取结束
# 输出到终端
hello 
1
2

借助此种方法可以有这样的作用

cat << EOF > 123.txt

首先我们将输入重定向到直到遇到EOF才结束输入,然后将输出重定向到123.txt文件,这样就实现了检测关键字结束输入并将输入保存到指定文件。

错误输出重定向的语法用到的时候再去搜吧。

补充一个管道与输入重定向的错误区分。

# 假设存在a.txt且为空

echo "hello" >> a.txt
# 将echo的输出重定向到a.txt文件,并追加到文件末尾

echo "hello" | a.txt
# 管道运算符允许一个命令的输出作为另一个命令的输入,但是a.txt并没有接受输入的属性或语句,因此会报错

# 可以这样写
echo "hello" | cat >> a.txt

最后提一嘴,输入输出的重定向指的是将命令的标准输入输出重定向到文件,是命令与文件间的操作,因此不存在命令间的重定向,想要实现两个命令间的通信请使用管道。

2.3.3 命令展开

命令展开是指将命令的输出作为参数传递给其他命令的一种方式。

其中常用的展开有下面几种

  • 通配符展开
ls * # 实际上是将*展开为当前目录的所有文件名作为参数传递给ls命令
ls ~ # 实际上是将~展开为家目录的绝对路径作为参数传递给ls命令
# echo命令使用*会打印当前目录的所有文件,使用~会打印家目录的绝对路径
  • 算数表达式展开,
    $((表达式))
echo $((3+5)) # 输出8
  • 变量展开,
    $变量名
name="Ruby"
echo $name # 输出Ruby
# 变量的访问实际上也是一种展开
  • 花括号展开,
    {字符1,字符2,字符3,...}
echo {a,b,c} # 输出a b c

# 使用花括号+字符串
echo alpha_{a,b,c}.txt # 输出alpha_a.txt alpha_b.txt alpha_c.txt
# 可以这样使用
touch alpha_{a,b,c}.txt # 创建三个文件,alpha_a.txt alpha_b.txt alpha_c.txt

# 同时花括号支持范围展开
echo {1..10} # 输出1 2 3 4 5 6 7 8 9 10
echo {A..Z}  # 输出A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
# 实际上是在{字符1..字符2}的范围内使用Ascill码进行范围展开

# 结合这个用法可以批量创建连续文件
touch 2024-{1..12}.txt
# 创建了2024-1到2024-12的12个文件
  • 命令展开
echo `ls *`
echo `cat 123.txt`
# or 
echo $(ls *)
echo $(cat 123.txt)
# 这种方法会将命令的输出展开为字符串,因此可以赋值操作
result=$(ls *) #or result=`ls *`
echo $result
  • 引号内展开

在shell中,单引号括起来的字符串相当于python中的原始字符串,只会原样输出引号内的字符,不会进行任何格式化操作。而使用双引号括起来的字符串则会进行变量展开、命令展开、算数表达式展开等操作。

name="Ruby"
echo 'Hello, $name!' # 输出Hello, $name!
echo "Hello, $name!" # 输出Hello, Ruby!
echo "`ls /root/a.txt`" 
# or echo $(ls /root/a.txt)
# 输出/root/a.txt
# 在双引号中可以使用\对特殊字符进行转义
echo "Hello, \$name!" # 输出Hello, $name!

注意,无论单双引号,都不支持通配符以及花括号展开。因此类似于
echo "ls *"
会输出
ls *
而不是打印当前目录的所有文件。

3. shell脚本编程

3.1 一个简单的shell脚本

如前面所见,shell脚本的扩展名一般设为.sh,即便不加此扩展名也可以执行,但是约定使用.sh来标识该本件为一个shell脚本文件。

几乎所有在命令行中能执行的shell命令都可以在shell脚本中使用。

给出一个简单的shell脚本,test.sh

脚本的第一行一般使用固定格式
#!bin/bash

#!
加脚本解释器的绝对路径来指定使用哪个解释器来执行脚本。

#!/bin/bash

echo "Hello,World!"

然后赋予执行权限并执行

chmod +x test.sh
# 这里有两种执行shell脚本的方法

# 1. 直接执行
./test.sh

# 2. 指定解释器执行
bash test.sh

# 输出
Hello,World!

第一种方法中,会根据脚本第一行指定的解释器来对脚本进行解释执行,而第二种方法则是无论指定解释器为何,都将使用bash解释器来执行脚本。

3.2 变量

shell中的变量和python感觉差不多。

变量的定义为

# 变量名=变量值, 如

name="RubyRose" 
# 变量名的规范与大多语言相同
# 注意变量名与变量值间的等于号不能有空格

通常使用全大写的变量来表示一个常量,如

PI=3.141592653589793

想要使用echo输出变量值到终端,只需在变量名前加
$
符号

echo $name
# 若不加$,echo会默认将所有输入作为字符串输出

echo name
# 输出name

同时可以直接对变量名重新赋值来更改其值

name="Ruby"

对于变量有几个关键字

# readonly用来指定一个变量是只读不可改的,仅在声明时赋一次值
readonly name
name="else" # 这里会报错,因为name已经被声明为只读不可改

# unset用来删除一个变量,或者说来重置一个变量,变量的值会变为空
unset name
echo $name # 输出为空

值得注意的是,在赋值时,不加引号也会默认值为字符串值

alpha=rose
# 等价于
alpha="rose"

也就是说,shell中默认的变量类型为字符串。

3.3 字符串操作

在shell的打印输出(echo)中,会遇到一些格式化字符串的需求

name=Ruby

# 1. 字符串拼接,使用双引号
echo "Hello, $name!"
# 输出Hello, Ruby!

# 有时会有这样的需求
echo "Hello, $nameBBB"
# 此时变量名与字符串重合,而我们并没有定义nameBBB变量,输出空。
# 因此可以使用{}包裹变量名来解决这个问题
echo "Hello, ${name}BBB"


# 2. 原始字符串,使用单引号
echo 'Hello, $name!'
# 输出Hello, $name!,相当于python中的r"name"

# 3. 获取字符串长度
echo ${#name}
# 输出4

3.4 变量类型指定

shell中变量类型有三种

3.2节中已经讲过,shell中的变量默认为字符串类型,也就是说在不使用关键字指定的前提下,无法进行数值运算。

num1=10
num2=20
sum=$num1+$num2
echo $sum 
# 输出字符串10+20

因此在声明一些变量时,可以使用delcare关键字来指定变量的类型。

使用时的语法如下

declare [+/-] [变换属性] 变量名

# - 表示为变量增加属性
# + 表示为变量删除属性
# 变换属性有如下几种

# -i 整型
# -l 小写字母
# -u 大写字母
# -a 数组类型
# -A 关联数组类型(HashMap)
# -x 环境变量
# -r 只读变量
# -g 全局变量,shell中不加指定也默认为全局变量
# -p 显示变量类型

一些演示例子如下

  1. 声明整型变量
declare -i num=10
declare -i num_=20
declare -i sum=$num+$num_
echo $sum # 输出30

值得一提的是,shell会将赋值尽量的转换到赋值对象所需要的类型,因此对于一个已经声明的整型变量,允许如下操作。

declare -i num
str1=10
str2=20 # 记得吗,str1和str2默认为字符串
num=$str1+$str2
echo $num # 输出30

同理也有

declare -i num=10
declare -i num_=10
str=$num+$num_ # str默认字符串,因此整型转换到字符串进行字符串拼接
echo $str # 输出10+10
  1. 声明大小写字母变量
declare -l Lowname=RUBY
declare -u Upname=ruby
echo $Lowname # 输出ruby
echo $Upname # 输出RUBY

shell会将赋值的字符串根据变量声明时的属性来仅保留大写或者小写

  1. 声明数组以及关联数组

数组也是shell中的一种基本类型,其可以使用declare -a来声明,亦可以直接声明,访问其元素与正常数组类似。

# 元素用小括号包裹,用空格分隔
declare -a arr=(1 2 3 4 5)
# or
arr=(1 2 3 4 5)
# or 直接声明
arr[0]=1 # 这里直接声明了一个数组,只有一个元素1


# 访问元素
echo $arr # 数组名指向第一个元素,输出1
echo ${arr[2]} # 输出1
echo ${arr[@]} or echo ${arr[*]} # 输出1 2 3 4 5

# 数组长度
echo ${#arr[@]} # 输出5,语法与输出字符串长度类似

# 添加元素
arr[5]=6 # 直接从最后一个元素的后面的索引赋值即可添加
echo ${arr[*]} # 输出1 2 3 4 5 6
# or
arr+=(6 7 8) # 也可以使用+=来添加元素或者一段数组
echo ${arr[*]} # 输出1 2 3 4 5 6 6 7 8

# 数组拼接
arr1=(1 2 3)
arr2=(4 5 6)
arr3=(${arr1[*]} ${arr2[*]}) # 注意有空格
echo ${arr3[*]} # 输出1 2 3 4 5 6

关于关联数组,shell中没有直接的声明方式,需要使用declare -A来声明,其用法与python中的Dict类似,赋值时以
[键]=值
的方式进行。

# 元素用小括号包裹,用空格分隔
declare -A arr=([id1]=11 [id2]=12 [id3]=13)
# or
declare -A arr
arr[id1]=11
arr[id2]=12
arr[id3]=13
# 即关联数组支持声明后直接使用键值对来添加元素,对于已经存在的键,会覆盖原值,对于不存在的键,会新增键值对。

# 访问元素
echo ${arr[id1]} # 输出11
echo ${arr[@]} or echo ${arr[*]} # 输出11 12 13,即只会输出值

# 想要遍历输出关联数组的键,可以加`!`
echo ${!arr[*]} # 输出id1 id2 id3

# 关联数组长度
echo ${#arr[@]} # 输出3
  1. 声明环境变量
declare -x MYENV=123
echo $MYENV # 输出123
printenv MYENV # 输出123,说明确实导出到了环境变量
  1. 声明只读变量
declare -r name=ruby
# 等价于 readonly name=ruby
name=else # 报错,不可修改
  1. 显示变量类型
declare -p [...变量名] # 输出变量类型,可跟多个变量名

可以通过组合使用这些关键字来声明各种变量类型。

declare -ai arr=(1 2 3)
# or
declare -a -i arr=(1 2 3)
# 声明一个整型数组arr
arr[0]+=1
echo ${arr[0]} # 输出2

上述的声明后的变量可以使用
+[属性]
来取消其属性。

declare -l Lowname
Lowname=RUBY
echo $Lowname # 输出ruby
declare +l Lowname # 取消小写字母属性
Lowname=BIG
echo $Lowname # 输出BIG,属性失效

但是注意, +r 选项并不能取消变量的只读属性,对一个只读变量使用 +r 选项会报错。

也可以使用typeset命令来声明变量类型,其语法与declare基本一致。

3.4.X 算数运算

因为shell中变量类型默认为字符串,因此无法直接进行数值运算。

a=10
b=20
sum=$a+$b
echo $sum # 输出字符串10+20,而非30

shell中提供了一些命令来进行数值运算。

在此之前,简单了解一下内建命令与外部命令的区别。

  • 内建命令是在编译时与shell一起编译生成可执行文件的命令,自身已集成在shell内部,不需要访问外部的可执行文件来执行。
  • 外部命令则是指需要调用外部可执行文件来执行的命令,如ls、cp、mv等。

可以通过
type
命令来查看某个命令是否为内建命令,而且使用
which
命令来查看内建命令时,一般找不到其路径,因为已经集成在shell内部。

type mv
mv 是 /usr/bin/mv # 说明mv是外部命令
which mv
/usr/bin/mv

type cd
cd 是 shell 内建
which cd 
# 输出空,内建命令找不到路径

下面开始介绍常用的算数运算命令。

  1. 括号实现
# 使用$((...))来包裹算数表达式
result=$((10+20))
echo $result # 输出30

#or 
num1=10
num2=20
result=$(($num1*$num2))
echo $result # 输出200


#支持整数加(+),减(-),乘(*),除(/),乘方(**),取模(%)运算

#也支持位运算如左移(<<)、右移(>>)、按位与(&)、按位或(|)、按位异或(^)、逻辑非(~)

仅可以实现整数运算,非整除会截断为整数。

  1. expr命令
# expr命令可以进行算数运算,其语法如下
expr [数] [运算符] [数]

# 其中数字和运算符各作为参数传入,需要用空格分隔。

# such as
expr 10 + 20 # 输出30
expr 10 / 20 # 输出0,因为除法运算结果为浮点数
expr 5 * 2   # 报错,因为*号是shell中的特殊字符,可以使用转义字符\来表示
expr 5 \* 2  # 输出10,因为\*表示*号本身

# 也可以赋值给变量
sum = $(expr 10 + 20)
# or
sun = `expr 10 + 20`
echo $sum # 输出30

# 注意,expr实现的运算仅有+-*/%

  1. bc命令
    在终端中输入
    bc
bc

会出现以下
image

可以看出bc是linux中的一个可执行程序,用来进行算术运算。
其中支持加(+)减(-)乘(*)除(/)乘方(^)以及取非(!)运算。
但是仅可以执行表达式,不能直接赋值给变量。

1+2
3
5^2
25
!0
1
!5
0
quit # 输入quit退出bc

上面的方法仅支持整数运算。想要进行浮点数运算,可以使用-l选项来实现。

bc -l

然后就可以使用浮点数运算了。

6 / 4
1.50000000000000000000
quit
# 小数位有点多,可以直接在bc中使用scale命令来指定小数位数,直接截断,非四舍五入
bc -l
2/3
.666666666666666666666
scale=2 # 指定小数位数为2
2/3
.66
quit

在浮点数运算模式下bc提供了一些函数计算

s(x) 计算 sin(x),以下x皆为弧度表示
c(x) 计算 cos(x)
a(x) 计算arctangent(x)
l(x) 计算ln(x)
e(x) 计算e的x次方,其中e为自然底数
x^y 计算x的y次方
sqrt(x) 计算根号下x
# 直接传入即可

可以看见bc执行后默认由标准输入读取表达式然后将结果到标准输出,因此可以使用管道向其输入表达式。

echo '1+2' | bc # 输出3
echo '6/4' | bc -l # 输出1.5000000000
# 多条命令使用分号;隔开
echo '1+2;2/3' | bc -l
3.0000000000
.66666666666

# 使用这个方法可以指定小数位数
echo 'scale=2;2/3' | bc -l
.66

# 在使用一些重定向就可以实现使用bc赋值
echo '1+2' | bc >> a.txt
# 将bc的输出重定向到a.txt文件末尾

# 也可以将bc的输出赋值给变量
result=$(echo '1+2' | bc)
# or 
result=`echo '1+2' | bc`

可以看到使用 $([命令/命令表达式]) or` [表达式] `的方法来将命令的输出赋值给变量。

  1. let
    let命令一般与赋值操作结合实现数值运算。
# 使用let来进行数值运算
num1=10
num2=20
let sum=$num1+$num2
echo $sum # 输出30
# let 支持省略$来进行数值运算,则上述等同
let sum=num1+num2
echo $sum # 输出30

let支持加(+)减(-)乘(*)除(/)乘方(**)取模(%)运算
不支持位运算。
支持+=,-=等类似运算,支持自增自减运算。

num=0
let num++ # 自增
echo $num # 输出1
let num-- # 自减
echo $num # 输出0
let num+=10 # 加法赋值
echo $num # 输出10
# 对于并非使用let声明的变量,也可以使用let来进行数值运算
num=10
let num+=10
echo $num # 输出20

# 对于非数字的变量,let默认其值为0
alpha="b"
let alpha++ # 自增
echo $alpha # 输出1
alpha="b"
let alpha+=10
echo $alpha # 输出10

3.5 命令状态及其状态运算

3.5.1 命令状态码

在shell中,每条命令执行完毕后,都会返回一个退出状态(exit status)码,用来表示命令执行成功与否。其取值范围为0-255,0表示成功,非0表示失败,因此在使用退出状态码时大部分时间仅需要关注0与非0即可。

当执行完命令后,可以通过
echo $?
来打印出上一条命令的退出状态码。

ls a.txt # a.txt存在
echo $? # 输出0

ls b.txt # b.txt不存在
echo $? # 输出1

注意的是,与大多数语言不同,shell中表示成功的退出状态码为0(true),表示失败的退出状态码为非0(false)。

在编写shell脚本时,执行脚本后的退出状态码默认为脚本最后一个语句的退出状态码。

#!/bin/bash
ls a.txt
ls b.txt

此脚本执行后,如果b.txt不存在,则退出状态码为1,否则为0。

当然,我们也可以通过
exit
命令来指定退出状态码。

#!/bin/bash
ls a.txt    
exit 0 # 表示成功

这样不论a.txt是否存在,脚本都会返回0作为退出状态码。0可以换成其他数字。

需要注意的是,exit后的语句不会执行,脚本执行到exit即结束。

3.5.2 test测试命令

test命令是一个用于条件判断的命令,其语法如下

test [表达式] -[选项] [表达式]
# or
test -[选项] [表达式]

其中选项有如下(真的很多)

# 数值比较,左边相对于右边

-eq	    #等于则为真(equal)
-ne	    #不等于则为真(not equal)
-gt	    #大于则为真(greater than)
-ge	    #大于等于则为真(greater than or equal)
-lt	    #小于则为真(less than)
-le	    #小于等于则为真(less than or equal)
# such as
test 10 -eq 10 # 该语句的状态码为0
# 字符串比较,左边在右边中出现则为真

=	    #等于则为真
!=	    #不相等则为真
-z 字符串 	#字符串的长度为零则为真
-n 字符串 	#字符串的长度不为零则为真
# such as
test "abc" = "abc" # 该语句的状态码为0
name=Ruby
test -z "$name" # 该语句的状态码为0,因为name的长度为3
test -n "$name" # 该语句的状态码为0,因为name的长度不为0
# 这里建议将字符串用双引号括起来,防止空变量名的情况发生。
如
test -n $non # 由于non变量为空,相当于test -z ,而该语句的状态码为0,因此本该输出0,实际输出1。
# 文件测试,文件测试命令用于测试文件是否存在、是否可读、是否可写、是否可执行等。
-e 文件名	#如果文件存在则为真
-r 文件名	#如果文件存在且可读则为真
-w 文件名	#如果文件存在且可写则为真
-x 文件名	#如果文件存在且可执行则为真
-s 文件名	#如果文件存在且至少有一个字符则为真
-d 文件名	#如果文件存在且为目录则为真
-f 文件名	#如果文件存在且为普通文件则为真
-c 文件名	#如果文件存在且为字符型特殊文件则为真
-b 文件名	#如果文件存在且为块特殊文件则为真
# such as
# 假设a.txt存在且可读
test -e a.txt # 该语句的状态码为0,因为a.txt存在
test -r a.txt # 该语句的状态码为0,因为a.txt存在且可读

上述这些选项前可以将

作为参数传入来对其逻辑取反,即本来

test ! -e a.txt # 此时若a.txt存在,则该语句的状态码为1,否则为0
#等价与[ ! -e a.txt ]
test "abc" != "abc" # 状态码为1,因为"abc"等于"abc",只有不相等时才为真,状态吗为0。

这些选项之后用到再来查阅即可,太多了不可能一下记住。

当然shell提供了一种关于test更简单的写法

[ [表达式] -[选项] [表达式] ]
# or
[ -[选项] [表达式] ]
# 注意中括号与语句间均有一个空格
# such as
[ "abs" = "abs" ]
[ -z "$name" ]

3.5.3 逻辑运算符

与C++以及Python等语言类似,shell提供了一些逻辑运算符来进行条件判断,包括逻辑与(&&)、逻辑或(||)以及逻辑非(!)。

在了解shell中的逻辑运算符前,先了解两个shell中的内建命令
true

false

true # 该命令的退出状态码恒为0
false # 该命令的退出状态码恒为非0
  1. 逻辑与
[表达式] && [表达式]
# or
[ [表达式] -a [表达式] ] # -a = and

该运算符用于判断两个表达式是否都为真,如果两个表达式的退出状态码都为0,则返回退出状态码为0,否则返回退出状态码为1,相应的,也会有逻辑短路的现象。

[ "abc" = "def" -a touch yes.txt ]
# 等价与 [ "abc" = "def" ] && touch yes.txt

# 显然字符串abc不等于def,因此无论后面是什么,都会返回1,同时后面的表达式也不会被执行,因此不会创建yes.txt文件。
  1. 逻辑或
[表达式] || [表达式]
# or
[ [表达式] -o [表达式] ] # -o = or

该运算符用于判断两个表达式是否至少有一个为真,如果两个表达式的退出状态码有一个为0,则返回退出状态码为0,否则返回退出状态码为1,相应的,也会有逻辑短路的现象。

[ "abc" = "abc" -o touch yes.txt ]
# 等价与 [ "abc" = "abc" ] || touch yes.txt
# 显然字符串abc等于abc,无论后面是什么,都会返回0,因此后面的表达式touch yes.txt不会被执行,不会创建yes.txt文件。  
  1. 逻辑非
! [表达式]
# or
[ ! [表达式] ]
[ [表达式] ! -[选项] [表达式]]

该运算符用于对表达式取反,如果表达式的退出状态码为0,则返回退出状态码为1,否则返回退出状态码为0。

[! -e a.txt ]
# 等价与 -e a.txt 为假,即a.txt不存在,则返回0,否则返回1。
!ls a.txr
# 若a.txt存在,则返回1,否则返回0。
[ "abc" != "abc" ]
# 显然abc等于abc,!=不成立,因此返回1。

通过上述的逻辑运算符,我们可以写出一些复杂的条件判断语句,应用其短路特性达到if-else的效果。

[ -e a.txt ] && rm a.txt
# 若a.txt存在,则删除a.txt,否则什么都不做。

[ ! -e a.txt ] && touch a.txt
# 若a.txt不存在,则创建a.txt,否则什么都不做。

在考虑这几个运算符间的优先级时,直接用小括号最省脑子。

3.6 条件语句

3.6.1 if-else语句

shell中提供if-else来实现条件语句,当条件为真(退出状态码为0)时执行if块中的命令,否则执行else块中的命令。
其语法如下
最简单的if-else语句如下

if [条件]
then
    # 条件为真时执行的代码
fi  # 结束if语句

# shell中可以使用;来在一行分割命令,那么上面可以这样写
if [条件]; then echo "条件为真"; fi

带有else的if-else语句如下

if [条件]
then
    # 条件判断1为真时执行的代码
else
    # 条件判断1为假时执行的代码
fi # 结束if语句

或者带有elif的if-else语句如下

if [条件1]
then
    # 条件判断1为真时执行的代码
elif [条件2]
then
    # 条件判断2为真时执行的代码
else
    # 条件判断1和2都为假时执行的代码
fi # 结束if语句

多层嵌套的if-else使用缩进区分

if [条件1]
then
    if [条件2]
    then
        # 条件判断2为真时执行的代码
    fi
else
    if [条件3]
    then
        # 条件判断3为真时执行的代码
    fi
fi # 结束if语句

注意这里的条件为条件块,即使用如下

if [ 5 -gt 3 ]
    echo "5大于3"
else
    echo "5不大于3"
fi
# 可以组合逻辑运算符
num=10
if [ $num -gt 3 -a $num -lt 15 ]
    echo "num大于3且小于15"
else
    echo "num不大于3或大于15"
fi
上面等价与
if [ $num -gt 3 ] && [ $num -lt 15 ]
    echo "num大于3且小于15"
else
    echo "num不大于3或大于15"
fi

对于一些复杂的条件判断,可以使用逻辑运算符的短路特性来简化代码,这里就不举例子了。

3.6.2 case语句

case语句类似与C++中的switch语句,用于多分支条件判断。

case 变量 in
值1)
    # 变量等于值1时执行的代码
    ;;
值2)    
    # 变量等于值2时执行的代码
    ;;
*)
    # 变量不等于值1或2时执行的代码
esac

即对于每种模式,我们使用
值)
来充当类型
case
,然后使用
;;
来表示该模式结束。由于shell中没有default关键字,因此使用通配符
*
来匹配所有其他情况,最后使用
esac
来结束case语句。

很有意思的是,if-else和case语句的结束符都是其反写,即
fi

esac

这里提一嘴,shell中的匹配与正则表达式几乎一致。

case语句也支持使用
|
来用一种模式匹配多个情况。

read -p "请输入数字:" num # read的-p选项用于提示用户输入
case $num in
1|2|3)
    echo "你输入的数字是1、2、3中的一个"
    ;;
4|5|6)
    echo "你输入的数字是4、5、6中的一个"
    ;;
*)
    echo "你输入的数字不是1、2、3、4、5、6中的任何一个"
esac

同时可以使用正则表达式来匹配

read -p "请输入::" str
case $str in
[0-9]*)
    echo "你输入的是数字"
    ;;
[a-zA-Z]*)
    echo "你输入的是字母"
    ;;
*)
    echo "你输入的不是数字也不是字母"
esac

3.7 循环语句

3.7.1 for循环

shell中提供了两种for的写法,一种是类似python的迭代for,一种是类似C语言条件for。

迭代for的语法如下

for 变量 in 值1 值2 值3...
do
   # 循环体
done
# 值1,2,3...也可为可迭代对象或者其他使用空格或者tab分隔的值,亦可以使用一些展开的语法来实现。

# such as 
for i in 1 2 5 
do 
   echo $i
done
# 输出1 2 5

for i in *
do 
   echo "$i"
done
# 打印当前目录下所有文件名

for i in {1..10}
do
   echo $i
done
# 打印1到10

条件for的语法如下

for (( 初始值; 条件; 步进值 ))
do
   # 循环体
done
# 初始值,条件,步进值均为数字,亦可使用一些变量来代替,注意使用双层小括号。

# such as 
for ((i=1; i<=10; i++))
do
   echo $i
done
# 输出1 2 3 4 5 6 7 8 9 10

也可以用来访问数组或者关联数组

arr=(1 2 3 4 5)
for val in ${arr[*]}
do
    echo $val
done
# 输出1 2 3 4 5
declare -A assoc_arr
assoc_arr[key1]=value1
assoc_arr[key2]=value2
for key in ${!assoc_arr[@]}
do
    echo $key : ${assoc_arr[$key]}
done
# 输出key1 : value1 key2 : value2

3.7.2 while与until循环

shell中提供了while和until循环,用于循环执行一段代码,直到条件为真或假。

其中while循环和until循环可以认为是两个相反的例子,while循环仅有表达式的退出状态码为0时(即为真或者说执行成功)去执行循环体,而until循环则是表达式的退出状态码为0时(即为真或者说执行成功)退出执行。

while [ 条件 ]
do
    # 循环体
done

until [ 条件 ]
do
    # 循环体
done

# such as
let cnt=0
while [ $cnt -le 5 ] # 循环条件为cnt小于等于5
do
    echo $cnt
    let cnt++
done
# 输出0 1 2 3 4 5

let cnt=0
until [ $cnt -ge 5 ] # 退出循环条件为cnt大于等于5
do
    echo $cnt
    let cnt++
done
# 输出0 1 2 3 4 5

3.7.3 循环控制语句break与continue

shell提供了向c/c++中类似的break和continue语句,用于控制循环的执行。

break用于跳出当前循环,continue用于跳过当前循环的剩余语句,并开始下一次循环。

let cnt=0
while [ $cnt -le 5]
do 
    echo $cnt
    let cnt++
    if [ $cnt -eq 3 ] # 若cnt等于3
    then
        break # 跳出当前循环
    fi
done
# 输出0 1 2
# 上面的if-else语句可以使用逻辑短路来简化
[ $cnt -eq 3 ] && break

# contine用于跳过当前循环的剩余语句,并开始下一次循环
let cnt=0
while [ $cnt -le 5 ]
do
    let cnt++
    [ $cnt -eq 3 ] && continue # 若cnt等于3,则跳过当前循环的剩余语句,并开始下一次循环
    echo $cnt
done
# 输出0 1 2 4 5

3.8 脚本传参

类似于
ls a.txt
,ls实际上也是一个可执行程序,我们向其中传入了a.txt作为参数。

在自己编写的shell中,我们可以使用
$n
来表示传递给脚本或函数的参数,其中n为参数的位置,从0开始。

# t.sh
#!/bin/bash
echo "第一个参数为:$1"
echo "第二个参数为:$2"
echo "第三个参数为:$3"

chmod +x t.sh # 使脚本可执行
./t.sh a b c # 执行脚本,并传入参数a,b,c
# 输出
# 第一个参数为:a
# 第二个参数为:b
# 第三个参数为:c

其中还有一些特殊符号

$0 运行脚本时提供的路径
$# 传递给脚本或函数的参数个数
$@ 传递给脚本或函数的所有参数
# t.sh
#!/bin/bash
for i in $@
do
    echo $i
done
# 输出所有参数
./t.sh "hello world" 123
# 输出
# hello 
# world
# 123
# 这是由于hello world虽然是一个参数,但是未加双引号的情况下参数会依据空白字符重新分割。因此需要使用双引号将$@括起来。

for i in "$@"
do
    echo $i
done
# 输出所有参数
./t.sh "hello world" 123
# 输出
# hello world
# 123
# "$@"会将每个参数使用双引号括起来,因此不会出现空格被遍历出来。

3.9 函数

shell中提供了函数的概念,可以将一系列命令封装成一个函数,然后在其他地方调用。

函数的写法有三种

# 第一种,使用function关键字来定义函数,同时函数名后面加上()
function 函数名() {
    # 函数体
}

# 第二种,省略function关键字,直接使用函数名加()
函数名() {
    # 函数体
}

# 第三种,使用关键字function来定义函数,省略函数名后的()
function 函数名{
    # 函数体
}

# 这三种写法的花括号都可以另起一行
func()
{
    echo "Hello, World!"
}

# 调用函数直接使用函数名即可
func
# 输出
# Hello, World!

shell中的函数也可以传参和使用返回值来传递数据。

其中传参的写法与向脚本传参的形式类似,而非像其他语言中在小括号中先声明参数。

func()
{
    echo "第一个参数为:$1" # 使用$n的形式获取第n个参数
    echo "第二个参数为:$2"
    echo "第三个参数为:$3"

    echo "所有参数为:$#" # 使用$#获取参数个数
    for i in "$@" # 使用@或*的形式获取所有参数
    do 
        echo $i 
    done
}

而函数的返回值可以通过
return
关键字来实现,执行完函数后使用
$?
来获取函数的退出状态码,即其返回值。

func()
{
    let sum=0
    for num in "$@"
    do
        let sum=sum+num
    done
    return $sum # 返回函数的返回值
}
func {1..5}
echo "函数的返回值为:$?" # 输出函数的返回值
# 输出
# 函数的返回值为:15

但是使用
return
仅能返回一个
int
值,因为本质上执行函数就是执行一个封装好的命令,返回实际上是返回了一个退出状态码,范围在
0-255
之间。因此大于255的返回值会溢出。

由于函数类似于命令,因此可以使用命令的展开来实现任意无限制的返回值。

func()
{
    let sum=0
    for num in "$@"
    do
        let sum=sum+num
    done
    echo "$sum"
}
res=`func {1..100}`
#or
res=$(func {1..100})
echo "函数的返回值为:$res"
# 输出 
# 函数的返回值为:5050

使用这种方法的返回可以不限制返回值的类型。

函数中的变量作用域默认是全局的,可以通过
local
关键字来声明局部变量。

而使用命令展开获取返回值时,即使不使用
local
声明,也无法修改和访问函数内部的变量。

3.10 grep命令

grep
命令是linux中非常常用的命令,用于在指定文本中查找能够匹配上模式的行。模式可以是字符串,亦可以是正则表达式。匹配时区分大小写。

grep [选项] [模式] [文件]
其中常用的选项有
-i:忽略大小写进行匹配。
-v:反向查找,只打印不匹配的行。
-n:显示匹配行的行号。
-r:递归查找子目录中的文件。
-l:只打印匹配的文件名。
-c:只打印匹配的行数。
-w:匹配整个单词。
-q:静默模式,不打印任何信息。可以通过退出状态来判断是否匹配到。
-B 数字 :打印匹配行之前的n行
-A 数字 :打印匹配行之后的n行
-C 数字 :打印匹配行前后各n行

# 当grep匹配到行时,退出状态码为0,否则为1。
# 匹配到多个行时,会将所有匹配的行打印出来。
a.txt
cat1
cat2
cat3
cat4

grep "cat" a.txt
# 输出
# cat1
# cat2
# cat3
# cat4
echo "$?" # 输出0

grep "dog" a.txt
echo "$?" # 输出1

grep -w "cat" a.txt # 匹配整个单词
echo "$?"  # 输出1

使用管道可以结合其他命令的输出来使用
grep

cat a.txt | grep "cat"
# 作用与 grep "cat" a.txt 相同,这里使用管道将cat的输出作为grep的输入

ls * | grep *.txt
# 找到当前目录下所有以.txt结尾的文件名

本文对目前主要的
土壤属性

地表覆盖

数字高程模型

水体水系矢量数据
获取网站加以整理与介绍。

本文为“GIS数据获取整理”专栏中第三篇
独立
博客,因此本文全部标题均由“3”开头。本文对目前主要的土地、土壤、高程、水体数据获取网站加以整理与介绍。

3 土地土壤数据

3.1 土壤属性数据

3.1.1 HWSD Database

HWSD
(Harmonized World Soil Database)即世界土壤数据库,是一个全球性的高分辨率土壤数据集;其是国际应用系统分析研究所(International Institute for Applied Systems Analysis,IIASA)与联合国粮食及农业组织(Food and Agriculture Organization of the United Nations,FAO)在一项针对全球部分地区的土地利用与覆盖变化模拟项目中诞生的产物。其包含丰富的土壤类型、相位、理化性质等信息,包括土壤类型、质地、有机碳含量、
pH
值、养分含量等多项指标,是地球系统研究和地理信息分析中非常重要的数据源;具体请见其官方文档。其空间分辨率为
30
"。

HWSD
数据集被广泛应用于气候变化、水文学、农业、生态等领域的研究,研究人员可以利用
HWSD
提供的丰富土壤信息开展区域和全球尺度的分析与建模。

3.2 土地覆盖数据

3.2.1 FROM-GLC

FROM-GLC(Finer Resolution Observation and Monitoring of Global Land Cover)是清华大学开发的系列土地利用与覆盖产品数据集库,具有不同方法、不同空间分辨率、不同年份繁多的全球土地覆盖数据、不透水面数据等。

3.2.2 资源环境科学与数据中心

资源环境科学与数据中心是中国科学院地理科学与资源研究所下属环境数据共享平台,具有1970年代末期、1980年代、1980年代末期、1995年、2000年、2005年、2015年、2018年、2020年等时期数据,空间分辨率为1 km。

3.2.3 OSM Landuse Landcover

OSM(OpenStreetMap)是一款创建自由、可编辑世界地图的合作项目。但截止2021年03月,OSM地表覆盖数据似乎仅仅可以在线浏览,而不可以直接下载。此外,使用OSM数据时需要严格注意国境线问题。

3.3 地面高度数据

3.3.1 ASTER GDEM

ASTER(Advanced Spaceborne Thermal Emission and Reflection Radiometer)GDEM(Global Digital Elevation Map)是日本经济产业省(the Ministry of Economy, Trade, and Industry,METI)与美国国家航空航天局共同发布的全球数字高程模型。其空间分辨率为30 m。

3.3.2 AW3D30

AW3D30(ALOS Global Digital Surface Model "ALOS World 3D - 30m")是由日本宇宙航空研究开发机构(Japan Aerospace Exploration Agency,JAXA)基于对地测绘卫星ALOS数据所生产的DSM数据。其空间分辨率为30 m,高程精度为5 m。

3.4 水体数据

3.4.1 MERIT Hydro

MERIT Hydro是基于MERIT DEM数据与多种内陆水体地图生产的全球水文数据集,包含流向、流量累积、水文调整高程与河道宽度等参数。其空间分辨率为3"。

3.4.2 ASTWBD

ASTWBD(ASTER Water Body Dataset)是基于ASTER GDEM数据得到的,具有海洋、河流、湖泊等水体数据。

至此,大功告成。