ROS1中的服务程序编写
(注意:本站所有博客均是记录本人学习过程中的理解,可能存在错误和不完善之处,请仔细甄别)
ROS中的服务
上一章我们讲述了订阅和发布,发布者有一个while循环,订阅者有rospy.spin(),它们维持了节点的活跃,使得节点能够持续不断的工作。但是,我们并不是所有时候都需要在节点间维持长期调用关系,比如我们有一个算术节点,我们在需要算数的时候把数字传给它,平时都是处于空闲状态,那么在它们之间去建立话题就有些“杀鸡用牛刀”了。服务就是用来解决这个问题的。它可以实现只在我需要的时候才调用。服务的主体有两个:服务端(server)和客户端(client)。要启用服务,我们需要编写服务端节点,客户端节点可以单独编写,但更多的是在更复杂的情境下集成调用。接下来我们也会结合具体的例子来分析如何在我们的代码中使用服务。
服务程序编写
我们继续沿用我们上一章的项目:我们上一章构造了一个类似流水线的项目,搭载我们识别一句话是积极还是消极的简单fasttext模型。现在我们把它包装为一个服务端,仅用来接受一个句子,然后返回“positive”或者“negative”。在我们写服务端的代码前,我们需要先定义srv文件:我给的命名为Sentiment.srv,文件内容很简单,如下:
1
2
3
string text
---
string sentiment
srv文件是用来干什么的呢?其实是用来规定服务的输入和响应格式的。我们在订阅和发布中需要指定消息类型,服务也是一样。---是分隔符,它的上方表示的是服务接受的格式,下方表示的是服务响应的格式。比如在我们情绪分析中,需要提供的是字符串类型,输出的positive或者negative也是字符串类型,所以都用string来表示。后面的名字可以自己拟,一般写一些和具体含义一致的名字方便理解。
写好的srv文件需要保存在和包内的src同级的目录srv下(如果不存在需要自己手动创建),只有这样才能在编译阶段被找到。
接着我们看服务端sentiment_server.py的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 文件名: sentiment_server.py
import rospy
import fasttext
import os
import rospkg
# 导入我们为服务定义的文件
# (前提是你已经创建了 Sentiment.srv 并编译过)
from beginner_tutorials.srv import Sentiment, SentimentResponse
# 全局变量来持有加载好的模型
model = None
def handle_sentiment_request(request):
"""
这是服务的核心处理函数。
当客户端调用服务时,这个函数会被ROS自动执行。
"""
global model
# 从请求(request)对象中获取传入的文本
text = request.text.lower().strip()
rospy.loginfo("服务收到了分析请求: '%s'", text)
# 检查模型是否已加载
if not model:
rospy.logerr("错误:AI模型尚未加载!")
# 返回一个包含错误信息的响应
return SentimentResponse("error_model_not_loaded")
# 使用模型进行预测
predictions = model.predict(text)
predicted_label = predictions[0][0]
# 根据预测结果确定最终的响应内容
if predicted_label == '__label__positive':
result = "positive"
else:
result = "negative"
rospy.loginfo("分析结果为: %s", result)
# 将结果打包成一个响应(Response)对象并返回
# 这个返回动作,就是对客户端的“回答”
return SentimentResponse(result)
def sentiment_analysis_server():
"""
服务端的主函数
"""
global model
# 初始化节点,节点名清晰地表示它是一个服务端
rospy.init_node('sentiment_analysis_server_node')
# 加载模型的逻辑 (和之前完全一样)
try:
rospack = rospkg.RosPack()
package_path = rospack.get_path('beginner_tutorials')
model_path = os.path.join(package_path, 'model', "sentiment_classifier.bin")
rospy.loginfo("正在从路径加载模型: %s", model_path)
model = fasttext.load_model(model_path)
rospy.loginfo("模型加载成功!")
except Exception as e:
rospy.logerr("加载模型失败: %s", e)
return
# 创建并发布服务
# rospy.Service('服务名称', 服务类型, 处理函数)
s = rospy.Service('analyze_sentiment', Sentiment, handle_sentiment_request)
rospy.loginfo("情感分析服务已就绪,等待调用...")
# 保持节点运行,等待服务被调用
rospy.spin()
if __name__ == '__main__':
sentiment_analysis_server()
导入:相比于之前的订阅和发布,我们服务多了一行导入
from beginner_tutorials.srv import Sentiment, SentimentResponse。需要注意的是,我们自己命名的srv文件叫做Sentiment.srv,但是导入的时候,是从包名.srv这个文件里面导入。而后面的Sentiment,SentimentResponse的名字都是有讲究的,就是我们自己命名的srv文件的名字以及其名字后面加上Response。这个原理是什么呢?实际上,我们srv文件在编译的时候,会自动的根据我们srv文件内容在devel\lib\python3\dist-packages\beginner_tutorials\srv目录下生成一个类似_Sentiment.py的文件。这个文件中有这两个类:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
class SentimentRequest(genpy.Message): _md5sum = "74697ed3d931f6eede8bf3a8dfeca160" _type = "beginner_tutorials/SentimentRequest" _has_header = False # flag to mark the presence of a Header object _full_text = """string text """ __slots__ = ['text'] _slot_types = ['string'] def __init__(self, *args, **kwds): # 其它代码 class SentimentResponse(genpy.Message): _md5sum = "c4ef057adaa38991c8c91697ccb03f97" _type = "beginner_tutorials/SentimentResponse" _has_header = False # flag to mark the presence of a Header object _full_text = """string sentiment """ __slots__ = ['sentiment'] _slot_types = ['string'] def __init__(self, *args, **kwds): # 其它代码
所以,我们实际上导入的是这个两新生成的类,这两个类的类名都是由我们的srv文件名导出的,我们可以推出来并使用。
服务函数
sentiment_analysis_server():这个函数的前期处理和我们上一章的是一样的,关键是创建并发布服务的代码:rospy.Service('服务名称', 服务类型, 处理函数),可以看到我们还需要提供一个处理函数,当服务被调用的时候,处理函数会接收一个Sentiment并返回一个SentimentResponse。最后我们还是需要一个rospy.spin(),来保持节点运行,确保时刻能为客户端提供服务。处理函数
handle_sentiment_request(request):由我们Sentiment.srv中的定义,我们的输入叫做text,所以request会有一个名叫text的属性,类型为string,我们可以由此获取。对于返回值,我们只要对我们要输出的结果用SentimentResponse()打包就可以了。
类似的,为了展示服务的优点,我们打算建立一个两个服务端对一个客户端的小系统,另一个服务端要简单很多,只需要对输入的text小写转化为大写,其它不变。这个留给大家自行实现。
我们看看客户端(smart_client.py)怎么写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# 文件名: smart_client.py
import rospy
# 我们需要两种服务,所以都导入
from beginner_tutorials.srv import Sentiment, TransformText
def smart_client():
rospy.init_node('smart_client_node')
# --- 连接到两个服务 ---
rospy.loginfo("等待情感分析服务...")
rospy.wait_for_service('analyze_sentiment')
rospy.loginfo("等待文本转换服务...")
rospy.wait_for_service('transform_text')
rospy.loginfo("所有服务均已连接!")
# 创建两个服务的客户端代理
sentiment_proxy = rospy.ServiceProxy('analyze_sentiment', Sentiment)
transform_proxy = rospy.ServiceProxy('transform_text', TransformText)
while not rospy.is_shutdown():
# --- 让用户做选择 ---
print("\n" + "="*30)
print("请选择您需要的服务:")
print("1: 情感分析")
print("2: 文本转为大写")
choice = input("请输入选项 (1 或 2): ")
if choice not in ['1', '2']:
print("无效输入,请输入 1 或 2。")
continue
text_to_process = input("请输入需要处理的文本: ")
# --- 根据选择,调用不同的服务 ---
if choice == '1':
try:
response = sentiment_proxy(text_to_process)
rospy.loginfo(">>> [情感分析服务] 响应: %s", response.sentiment)
except rospy.ServiceException as e:
rospy.logerr("服务调用失败: %s", e)
elif choice == '2':
try:
response = transform_proxy(text_to_process)
rospy.loginfo(">>> [文本转换服务] 响应: %s", response.output_text)
except rospy.ServiceException as e:
rospy.logerr("服务调用失败: %s", e)
if __name__ == '__main__':
smart_client()
- 导入:
from beginner_tutorials.srv import Sentiment, TransformText导入了我们定义的两种服务。我们不需要导入服务响应的类型,我们直接创建的客户端代理就可以帮我们获取并解析返回的信息。 - 函数主体:初始化节点后,我们先使用
rospy.wait_for_service('服务名称')等待服务端就绪。接下来是关键:rospy.ServiceProxy(服务名称,服务类型)会为我们需要的服务创建一个代理。这个代理的本体是一个函数,我们直接可以调用这个函数返回一个response,同样因为我们在srv文件定义了响应值的名称,response.sentiment中存储的就是我们需要的字符串。
我们这个客户端设计可以允许客户自由选择两个服务端的服务,体现了服务的高内聚和模块化。
配置服务环境
默认情况下,我们的package.xml和CMakeLists.txt中是不包含服务的编译配置的,想要使用服务,我们需要以下修改:
1. 修改 package.xml 文件
确保文件中包含以下两行依赖。它们告诉ROS,我们的包在编译时需要消息生成工具,在运行时需要消息运行库。
1
2
<build_depend>message_generation</build_depend>
<exec_depend>message_runtime</exec_depend>
2. 修改 CMakeLists.txt 文件
这是最关键的一步,需要修改三个地方:
第一处: 在 find_package 函数的 COMPONENTS 列表中,加入 message_generation。
1 2 3 4 5 6
find_package(catkin REQUIRED COMPONENTS roscpp rospy std_msgs message_generation )
第二处: 找到 add_service_files 函数(如果被注释了,请取消注释),并加入你所有的 .srv 文件名。
1 2 3 4 5
add_service_files( FILES Sentiment.srv TransformText.srv )
第三处: 找到 generate_messages 函数(如果被注释了,也请取消注释),以确保ROS会处理这些文件。
1 2 3 4
generate_messages( DEPENDENCIES std_msgs )
修改完成后,要记得使用以下指令来更新:
1
2
3
4
5
# 回到工作区根目录
cd ~/catkin_ws
# 编译工作区,这会根据你的.srv文件生成Python代码
catkin_make
编译成功后,每次打开新终端,都要记得 source 环境文件,这样系统才能找到你新生成的服务模块:
1
source devel/setup.bash
启动
接下来就是展示效果的时候了。我们需要启动两个服务端和一个客户端,因此需要打开 4个 独立的终端,并严格按照顺序执行:
▶️ 终端 1:启动 ROS Master
1
roscore
▶️ 终端 2:启动情感分析服务端
1
2
3
4
# 先source环境
source ~/catkin_ws/devel/setup.bash
# 再运行节点
rosrun beginner_tutorials sentiment_server.py
你会看到日志显示 “情感分析服务已就绪…”
▶️ 终端 3:启动文本转换服务端
1
2
3
4
# 同样需要先source
source ~/catkin_ws/devel/setup.bash
# 运行另一个服务节点
rosrun beginner_tutorials text_transformer_server.py
你会看到日志显示 “文本转换服务已就绪…”
▶️ 终端 4:启动我们的智能客户端
1
2
3
4
# 客户端也需要source
source ~/catkin_ws/devel/setup.bash
# 运行客户端节点
rosrun beginner_tutorials smart_client.py
你会看到客户端成功连接到两个服务,并出现一个让你选择的菜单。
现在,你可以在终端4中与系统交互,选择不同的服务并观察终端2和终端3的反应,亲身体验一个模块化的ROS服务系统是如何工作的。
我同样录制了一个演示视频给大家看看效果:
