场景:查找第一张专辑

iTunes 是苹果公司提供的内容商店服务,在里面可以购买世界各地的电影、音乐等数字内容。同时,iTunes 还提供了一个公开的可免费调用的内容查询 API。下面这个脚本就通过调用该 API 实现了查找歌手的第一张专辑的功能:

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
ITUNES_API_ENDPOINT = 'https://itunes.apple.com/search'

def command_first_album():
if not len(sys.argv) == 2:
print(f'usage: python {sys.argv[0]} {{SEARCH_TERM}}')
sys.exit(1)

term = sys.argv[1]
resp = requests.get(
ITUNES_API_ENDPOINT,
{
'term': term,
'media': 'music',
'entity': 'album',
'attribute': 'artistTerm',
'limit': 200,
},
)
try:
resp.raise_for_status()
except HTTPError as e:
print(f'Error: failed to call iTunes API, {e}')
sys.exit(2)
try:
albums = resp.json()['results']
except JSONDecodeError:
print(f'Error: response is not valid JSON format')
sys.exit(2)
if not albums:
print(f'Error: no albums found for artist "{term}"')
sys.exit(1)

sorted_albums = sorted(albums, key=lambda item: item['releaseDate'])
first_album = sorted_albums[0]
release_date = first_album['releaseDate'].split('T')[0]

print(f"{term}'s first album: ")
print(f" * Name: {first_album['collectionName']}")
print(f" * Genre: {first_album['primaryGenreName']}")
print(f" * Released at: {release_date}")

if __name__ == '__main__':
command_first_album()

函数的长度、圈复杂度、嵌套层级都在合理范围内,但是,除了这些维度外,评价函数好坏还有一个重要标准:函数内的代码是否在同一抽象层内。command_first_album() 显然不符合这个标准,在函数内部,不同抽象级别的代码随意混合在了一起:

  • 函数代码的说明性不够:如果只是简单读一遍 command_first_album(),很难搞清楚它的主流程是什么,因为里面的代码五花八门,什么层次的信息都有;
  • 函数的可复用性差:假如现在要开发新需求——查询歌手的所有专辑,你无法复用已有函数的任何代码;

要优化这个函数,我们需要重新梳理程序的抽象级别,在我看来,这个程序至少可以分为以下三层:

  1. 专辑数据层:调用 API 获取专辑信息;
  2. 第一张专辑层:找到第一张专辑;
  3. 用户界面层:处理用户输入、输出结果;
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
ITUNES_API_ENDPOINT = 'https://itunes.apple.com/search'

class GetFirstAlbumError(Exception):
"""获取第一张专辑失败"""

class QueryAlbumsError(Exception):
"""获取专辑列表失败"""

def command_first_album():
if not len(sys.argv) == 2:
print(f'usage: python {sys.argv[0]} {{SEARCH_TERM}}')
sys.exit(1)

artist = sys.argv[1]
try:
album = get_first_album(artist)
except GetFirstAlbumError as e:
print(f"error: {e}", file=sys.stderr)
sys.exit(2)

print(f"{artist}'s first album: ")
print(f" * Name: {album['name']}")
print(f" * Genre: {album['genre_name']}")
print(f" * Released at: {album['release_date']}")

def get_first_album(artist):
try:
albums = query_all_albums(artist)
except QueryAlbumsError as e:
raise GetFirstAlbumError(str(e))

sorted_albums = sorted(albums, key=lambda item: item['releaseDate'])
first_album = sorted_albums[0]
release_date = first_album['releaseDate'].split('T')[0]
return {
'name': first_album['collectionName'],
'genre_name': first_album['primaryGenreName'],
'release_date': release_date,
}

def query_all_albums(artist):
resp = requests.get(
ITUNES_API_ENDPOINT,
{
'term': artist,
'media': 'music',
'entity': 'album',
'attribute': 'artistTerm',
'limit': 200,
},
)
try:
resp.raise_for_status()
except HTTPError as e:
raise QueryAlbumsError(f'failed to call iTunes API, {e}')
try:
albums = resp.json()['results']
except JSONDecodeError:
raise QueryAlbumsError('response is not valid JSON format')
if not albums:
raise QueryAlbumsError(f'no albums found for artist "{artist}"')
return albums

if __name__ == '__main__':
command_first_album()

在设计函数时,请时常记得检查函数内代码是否在同一个抽象级别,如果不是,那就需要把函数拆成更多小函数。只有保证抽象级别一致,函数的职责才更简单,代码才更易读、更易维护。