羊了个羊科技通关攻略

最近羊了个羊比较火,但是难度非常高,打了几天几十盘都通不过,所以犯了职业病,想看看有没有科技手段,实践有效后整理出来方便大家科技通关。

原理

科技通关的原理比较简单,游戏每天都有两幅地图,第一个地图是练手的可以无脑通过,第二个是难度爆表的版本,所以只要能够把第二个地图改成第一个,就可以实现通关。

准备工作

实现科技通关需要一个 Web 调试代理 App,在 iOS 上可以用 HTTP Catcher(需要内购),Storm Sniffer(三天试用),Android 上也可以找类似的软件。以 HTTP Catcher 为例,安装好之后需要安装并启用 Root 证书,以实现 HTTPS 解密。

步骤

首先需要启动 HTTP Catcher,打开羊了个羊进入游戏开始挑战,然后返回 HTTP Catcher,筛选 JSON 类型的请求,找到包含 map_info_ex 的请求。

点进这个请求里的 Response,可以看到返回内容里有个 map_md5 的列表,里面有两个 md5 值,分别对应第一个地图和第二个地图,我们要做的就是把返回值里第二个地图的 md5 替换成第一个的。

接下来返回上一个界面,左滑选择更多,新建重写,在弹出的界面中新增规则。

按下图的选择 Response 和 Body,将第二张地图的 md5(可以提前复制好)填入 Find,将第二张地图的 md5 填入 Replace,然后一路保存。

接下来重新启动 HTTP Catcher,回到羊了个羊重新开始游戏,第二关就变成和第一关一样简单的地图了。

基于Clean Architecture的Go项目架构实践

经过这些年的发展,Go语言已经成为一门被广泛使用在各个领域的编程语言。从k8s、docker等基础组件,到业务领域的微服务,都可以用Go构建。在构建这些Go项目时,采用哪种架构模式和代码布局,是一个仁者见仁智者见智的事情。有Java Spring经验的可能会采用MVC模式,有Python Flask经验的可能会采用MTV模式。加上Go语言领域并没有出现主流的企业级开发框架,很多项目甚至没有明确的架构模式。

Clean Architecture

Clean Architecture是Uncle Bob提出的适用于复杂业务系统的架构模式,其核心思想是将业务复杂度与技术复杂度解藕,相比于MVC、MTV等模式,Clean Architecture除了进行分层,还通过约定依赖原则,明确了与外部依赖的交互方式,以及外部依赖与业务逻辑的边界。感兴趣的朋友可以直接阅读作者原文https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

由于Clean Architecture具有脱离语言和框架的灵活性,作者在提出时也没有规定实现细节,给Clean Architecture的落地带来了困难,接下来以一个例子来说明如何在Go项目中应用Clean Architecture的思想。

布局

作为一个Go项目,不管用哪种架构模式,建议都建立app和scripts这两个路径。app存放启动Go项目的入口文件,通常是main.go。而scripts可以放一些构建和部署时候用到的脚本。

1
2
3
4
5
6
7
8
9
10
clean_architecture_demo
├── README.md
├── app
│   └── main.go
├── scripts
│   ├── build.sh
│   └── run.sh
├── go.mod
├── go.sum
└── usecases

接下来是代码部分,分为entities、usecases、adapters三个部分。

  • entities:存储领域实体。用一个博客系统举例,领域实体可能有用户(user)和文章(article)
  • usecases:存储业务逻辑。用博客系统举例,可能会有用户相关的业务逻辑(signup_user、signin_user、add_user、delete_user)和文章相关的业务逻辑(add_article、show_article、delete_article)
  • adapters:存储适配器逻辑。适配器是连接业务逻辑与外部依赖的层,博客以Web形式提供服务,就需要一个http_adapter来封装Web服务;同时保存文章到数据库,需要封装一个db_adapter来连接。

下面是项目的布局结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
clean_architecture_demo
├── README.md
├── adapters
│   ├── api
│   ├── db
│   └── log
├── app
│   └── main.go
├── scripts
│   ├── build.sh
│   └── run.sh
├── entities
│   ├── article.go
│   └── user.go
├── go.mod
├── go.sum
└── usecases

数据流向

用一个查询文章的请求来描述一下调用链路。

  • 用户通过HTTP服务的调用WebAdapter的ShowArticleHandler方法
  • 由于是文章相关的逻辑,ShowArticleHandler调用ArticleUsecase的ShowArticle方法
  • 需要从DB中查询文章,ArticleUsecase会调用DBAdapter的GetArticle方法
  • DBAdapter的GetArticle从MySQL中查询出文章内容返回给ArticleUsecase
  • ArticleUsecase返回给WebAdapter
  • WebAdapter通过HTTP服务返回给用户

代码示例

为了更清晰的说分层和架构,我在Github上发布了一个示例项目,感兴趣的朋友可以直接去看源码:https://github.com/simpleapples/go-clean-architecture

结论

由于Clean Architecture没有规定实现细节,所以上述的分层和布局方式只是一种参考,还有众多的实践方式。例如Adapter层可以根据外部依赖的类型细分成平行的Presenter+Gateway层,在复杂项目中,更细致的分层可以把代码拆的更细致,大家可以根据自己的项目规模来调整分层和布局,这里就不做赘述了。

Python跨服务传递作用域的坑

背景

在一个古老的系统中,有这样一段代码:

1
2
3
4
5
6
7
8
9
scope = dict(globals(), **locals())
exec(
"""
global_a = 123
def func_a():
print(global_a)
"""
, scope)
exec("func_a()", scope)

第一段用户代码定义了函数,第二段用户代码执行函数(不要问为什么这么做,因为用户永远是正确的)。第一个代码段执行后,func_a和global_a都会被加入作用域scope,由于第二个代码段也使用同一个scope,所以第二个代码段调用func_a是可以正确输出123的。

但是使用exec执行用户代码毕竟不优雅,也很危险,于是把exec函数封装在了一个Python沙箱环境中(简单理解就是另一个Python服务,将code和scope传给这个服务后,服务会在沙箱环境调用exec(code,scope)执行代码),相当于每一次对exec调用都替换成了对沙箱服务的RPC请求。

于是代码变成了这个样子:

1
2
3
4
5
6
7
8
9
scope = dict(globals(), **locals())
scope = call_sandbox(
"""
global_a = 123
def func_a():
print(global_a)
"""
, scope)
call_sandbox("func_a()", scope)

作用域跨服务传递问题

由于多次RPC调用需要使用同一个作用域,所以沙箱服务返回了新的scope,以保证下次调用时作用域不会丢失。但是执行代码会发现第二次call_sandbox调用时候,会返回错误:

global name ‘global_a’ is not defined

首先怀疑第一次调用后scope没有更新,但是如果scope没有更新,应该会报找不到func_a才对,这个报错说明,第二次调用时候,作用域里的func_a是存在的,但是func_a找不到变量global_a。通过输出第二次call_sandbox前的scope,会发现global_a和func_a都是存在的:

1
2
3
4
print(scope.keys())
# ['__name__', '__doc__', '__package__', '__loader__', '__spec__', '__file__', '__cached__',
# '__builtins__', 'global_a', 'func_a']
call_sandbox("func_a()", scope)

证明在第二次call_sandbox时,scope被正确的传入了,没有报找不到func_a也印证了这个结论。在func_a里获取并输出一下globals()和locals():

1
2
3
4
def func_a():
inner_scope = dict(globals(), **locals()
print(inner_scope.keys())
# ['__builtins__']

可以看到在func_a外作用域是正常的,但是func_a内的作用域就只有__builtins__了,相当于作用域被清空了。猜测是函数的caller指向的是沙箱环境内的作用域,当scope回传回来后,caller没有更新,所以在函数内找不到函数外的作用域,查看一下Python函数的魔术方法:

   

发现有一个__globals__变量,指向的就是所在作用域,相当于函数的caller,通过如下代码验证调用沙箱服务后的scope里的func_a的__globals__是否和当前作用域的一样:

1
scope["func_a"].__globals__ == globals()  # False

确实不一样,接下来试试把scope[“func_a”].__globals__置为globals(),应该就可以跑通了。

优化作用域更新逻辑

到这里问题的根源已经搞清了:

  • 第一个exec语句和第二个exec语句分别在Python服务A和B中执行,第一个exec语句中定义的func_a所在的作用域是服务A(func_a.globals == A)
  • 在scope回传到服务B后,global_a和func_a被拷贝到了服务B所在作用域,但是func_a.__globals__还是指向服务A的作用域,所以出现可以调用到func_a但在func_a里找不到global_a
  • 将func_a.__globals__置为B,就可以使代码在服务B正确执行

如文档所述,函数__globals__是一个只读变量,所以不能直接赋值,需要通过拷贝函数的方式实现,定义一个拷贝函数的方法:

1
2
3
4
5
6
7
8
9
10
11
12
import copy
import types
import functools
def copy_func(f, globals=None, module=None):
if globals is None:
globals = f.__globals__
g = types.FunctionType(f.__code__, globals, name=f.__name__,
argdefs=f.__defaults__, closure=f.__closure__)
g = functools.update_wrapper(g, f)
if module is not None:
g.__module__ = module
return g

更新调用沙箱后回传的scope,如果scope中的value是一个function,就通过复制的方式更新它的__globals__为scope:

1
2
3
4
5
6
7
8
9
10
11
12
scope = dict(globals(), **locals())
scope = call_sandbox(
"""
global_a = 123
def func_a():
print(global_a)
"""
, scope)
for k, v in scope:
if isinstance(v, types.FunctionType):
scope[k] = copy_func(v, scope, __name__)
call_sandbox("func_a()", scope)

重新运行,两个call_sandbox都可以正常执行,问题解决。

参考文档

https://docs.python.org/3/reference/datamodel.html

https://stackoverflow.com/questions/49076566/override-globals-in-function-imported-from-another-module

https://stackoverflow.com/questions/2904274/globals-and-locals-in-python-exec/2906198

利用AWS Lambda和iOS捷径实现手机一键开小区门禁

我住的小区使用了一个叫守望领域的智能门禁系统,可以通过手机App开小区门禁和单元门,但是用App开门需要经过四五步:打开App→进入开门界面→找到需要开的门→点击开门。

加上戴口罩时候解锁手机需要输入密码,导致整个流程非常耗时,经常需要站在小区门口和单元门口操作半天,有一段时间我甚至养成了携带实体门禁卡的习惯,实体门禁卡开门要快很多。

最近又开始忘带门禁卡,苦恼之余发现iOS在锁屏界面右划可以免解锁直接进入spotlight界面,这个界面可以添加捷径,如果能写一个捷径去调用守望领域App的API开门,就可以实现手机免解锁一键开门。

查找 API

首先需要通过Charles之类的软件查找App调用的API,配置Charles查看App请求的方式不再赘述,Google一下可以看到很多教程。直接看结果Charles的结果,可以看到api.lookdoor.cn是这个软件所请求的API域名。

打开软件发的请求非常多,经过操作和请求的对比可以看到,发送开门指令调用的API是:/func/hjapp/house/v1/pushOpenDoorBySn.json?equipmentId=xxxxxx 这个路径。

详细查看这个请求可以发现,equipmentId指的就是小区门的Id,接口使用cookie做认证,只要将cookie带上就可以模拟开门指令。

第一次尝试

打开iOS捷径App,创建一个新捷径,App调用API使用了POST请求,搜索Get contents of这个动作来实现发送POST请求。

通过Charles找到要开的门的URL填入,Method选择POST,Headers里填入Cookie进行认证,内容直接从Charles复制就可以,尝试运行,it works!

接下来把这个捷径添加到Spotlight界面,锁屏界面右划点一下,就可以实现一键开小区门禁,和打开App的四五步操作相比,确实省时省力。拿着新配好的捷径去上班,下班回到小区想试一把一键开门,结果又被困到门口了,上午还正常的捷径竟然失效了,打开一看API报登录超时,有可能是Cookie里的SESSION_ID过期了。

分析登录过程

再次用Charles抓包,分析登录相关的API,会发现主要是这两个:

  • /func/hjapp/user/v2/getPasswordAndKey.json:获取AES Key的API
  • /func/hjapp/user/v2/login.json?password=xxxxxx:登录API

通过分析,用时序图来表示这部分的交互逻辑:

登录过程清楚了,但是其中使用AES_KEY对密码进行加密的配置还是不清楚的,使用一个工具来尝试通过密文和AES_KEY来解密:http://tool.chacuo.net/cryptaes

输入密钥和密文,使用各种配置进行解密,当能够解出内容的时候,证明我们找到了加密的配置,可以看到BlockSize=128,padder使用的是pkcs7padding,加密模式是ECB。解密出来的字符并不是我们的密码,看着像是md5过的,用 echo -n xxxxxx | md5sum 把密码md5一下,对上了。看来服务端校验的是单次md5后的密码。

到这里登录逻辑已经搞清了,但是iOS捷径无法实现AES加密,单纯依托捷径来实现开门已经不可行了,需要搭建一个后端服务来计算密文。既然躲不过麻烦要搭建服务,不如把登录、开门整个流程都放在服务上,这样iOS捷径只需要一个请求就可以完成开门动作了。

考虑到登录开门的逻辑很简单,也就是3个HTTP请求+AES加密,直接在裸服务器上从0搭建步骤多成本高,要自己申请虚机、部署HTTP Server、Web App,还需要申请SSL证书,不仅初次搭建要搞个一两天,后续对机器和证书的维护也需要大量时间,成本极高。

最好是有服务能直接托管一段Python代码,第一时间想到的是Leancloud,一个Serverless服务提供商,但是实操过程中发现,由于政策要求Leancloud已经不提供域名了,绑定自己的域名也需要进行备案。这意味着只能选择一家海外Serverless服务商,看来看去AWS Lambda应该可以满足要求,试一下。

使用 AWS Lambda 搭建服务

AWS Lambda是一个Serverless服务,可以直接托管一段函数,省去配置服务和基础设施的麻烦。搭建一个Python的Serverless服务需要准备这么几件事:

  • 新建函数,编写代码
  • 添加API Gateway Trigger,确保函数可以通过HTTP请求调用
  • 配置函数的运行环境,增加一个层(Layer),这个层里打包进AES加密需要的cryptography和HTTP请求需要的requests

1. 函数代码

首先上代码,需要填写自己的手机号、md5后的密码、设备ID(可以用Charles获取)等字段,粘贴到Lambda的在线编辑器中。

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
import json
import requests
import base64
import urllib.parse
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding

PHONE = ''
PASSWORD_MD5 = ''
DEVICE_ID = ''

def encrypt(key, msg):
cipher = Cipher(algorithms.AES(str.encode(key)), modes.ECB())
encryptor = cipher.encryptor()
padder = padding.PKCS7(128).padder()
msg = padder.update(str.encode(msg)) + padder.finalize()
ct = encryptor.update(msg) + encryptor.finalize()
return base64.b64encode(ct)

def lambda_handler(event, context):
resp = requests.post('https://api.lookdoor.cn:443/func/hjapp/user/v2/getPasswordAesKey.json?')
cookie = resp.headers['set-cookie']
aes_key = resp.json()['data']['aesKey']
password_encypted = urllib.parse.quote_plus(encrypt(aes_key, PASSWORD_MD5))

url = f'https://api.lookdoor.cn:443/func/hjapp/user/v2/login.json?password={password_encypted}&deviceId={DEVICE_ID}&loginNumber={PHONE}&equipmentFlag=1'
requests.post(url, headers={'cookie': cookie})

equipment_id = event['queryStringParameters']['equipment_id']
url = f'https://api.lookdoor.cn:443/func/hjapp/house/v1/pushOpenDoorBySn.json?equipmentId={equipment_id}'
resp = requests.post(url, headers={'cookie': cookie})
return resp.json()

代码首先通过API获取AES_KEY和SESSION_ID,然后使用AES_KEY对密码进行加密,接下来调用登录接口将获取的SESSION_ID绑定到当前账户,接下来根据请求传入的设备ID(门的ID)来发送开门指令。

点击Deploy部署,然后运行测试,会出现超时的报错,这是因为Lambda函数默认的执行器内存大小是128MB,超时时间是3s,在配置页面把内存改大一些,超时时间设置为10s就可以了。

2. 添加 API Gateway Trigger

一个Lambda函数可以被多种形式触发执行,因为要使用捷径通过HTTP请求调用,所以加一个API Gateway Trigger,添加后会自动为函数生成一个URL,通过这个URL就可以直接调用函数。

3. 添加包含依赖的 Layer

代码中使用了 requests 和 cryptography 这两个第三方库,Lambda不支持使用pip直接安装这些依赖,而是需要我们在把依赖打成zip包上传成为容器的一层Layer,添加到函数镜像中。需要注意的是,Lambda函数执行的环境是Linux,对于cryptography这个库需要打包Linux版的才可以正常使用。

由于日常使用的是Mac,所以在AWS上申请一台Ubuntu 20的EC2实例,登录实例后使用如下命令安装依赖,并打包成zip文件:

1
2
3
4
mkdir python
pip install -t python cryptography
pip install -t python requests
zip -r python/*

在AWS上创建一个新的Layer,并将生成的python.zip上传到Layer上。尝试通过URL访问写好的Lambda函数,可以看到开门指令已经成功下发。

配置iOS捷径

打开iOS捷径App,创建一个新捷径,搜索Get contents of这个动作,填入Lambda函数的URL和门的ID。由于API Gateway并没有配置认证,所以其他参数默认即可。如果有安全方面的顾虑,可以自己实现一个简单的Token认证或添加Lambda提供的JWT认证。点击执行,接口返回成功,证明整个流程已经跑通,以后就可以用这个捷径给自己和外卖小哥开门了。

总结

一开始本想用自定义一个iOS捷径的方式来实现一键开门禁,但为了实现SESSION_ID自动更新,不得不基于AWS Lambda搭了一个后端服务来模拟App的行为,所幸AWS Lambda提供了低成本的构建方案,包括搭建服务和配置SSL证书都可以几乎0成本的完成,免费套餐政策也能让这个服务长期跑着而不产生任何实际花费。

规避 Go 中的常见并发 bug

Understanding Real-World Concurrency Bugs in Go这篇论文中,几名研究人员分析了常见的Go并发bug,并在最流行的几个Go开源项目中进行了验证。本文梳理了论文中提到的常见的bug并给出解决方法的分析。

论文中对bugs进行了分类,分为阻塞式和非阻塞式两种:
阻塞式:goroutine发生阻塞无法继续执行(例如死锁)
非阻塞式:不会阻塞执行,但存在潜在的数据冲突(例如并发写)

阻塞式bug

阻塞式bug发生的根因有两种,一种是共享内存(例如卡在了意图保护共享内存的锁操作上),一种是消息传递(比如等待chan)。同时研究发现共享内存和消息传递导致的bug数量不想上下,但是共享这种方法的使用量比消息传递使用的更频繁,所以也得出了共享内存方式更不容易导致bug的结论。

读写锁优先级导致的死锁

在Go中的写锁优先级高于读锁优先级,假设一个goroutine(goroutine A)连续获取两次读锁,而另一个goroutine(goroutine B)在gouroutine A两次获取读锁中间获取了写锁,就会导致死锁的发生。论文中没有针对这个bug给出示例代码,我写了一个简单的代码示意一下。

1
2
3
4
5
6
7
8
func gouroutine1() {
m.RLock()
m.RLock()
}

func gouroutine2() {
m.WLock()
}

f1和f2都在goroutine中执行,当f1执行完第一个l.RLock()语句后,假设这时f2的m.WLock执行,由于写锁是排它的,WLock本身被f1的第一个m.RLock()阻塞,写锁操作本身又会阻塞f1中的第二个m.RLock

WaitGroup误用导致的死锁

这种情况就是比较典型的WaitGroup的误用了,提前执行group.Wait()会导致部分group.Done()无法执行到,进而导致程序被阻塞。

1
2
3
4
5
6
7
8
9
var group sync.WaitGroup
group.Add(len(pm.plugins))
for _, p := range pm.plugins {
go func(p *plugin) {
defer group.Done()
}
group.Wait() // blocked
}
// group.Wait() should be here

for循环内的group.Wait()执行到的时候,循环内的部分goroutine还没有被创建出来,其中的group.Done()也就永远没法执行到,所以会导致永远阻塞在这一句,正确的写法是将group.Wait()移到for循环外。

Channel的误用

Channel是go支持并发的一个非常重要的特性,Channel虽然在很多场景下非常解决问题,但是误用也是不容易发现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
func goroutine1() {
m.Lock()
ch <- request // blocked
m.Unlock()
}

func goroutine2() {
for {
m.Lock() // 阻塞
m.Unlock()
request <- ch
}
}

这段代码的业务语义是goroutine1会通过ch接收goroutine2发送的消息,但是当goroutine1执行到ch <- request时候会阻塞并等待ch,此时由于goroutine1没有释放锁,goroutine2的m.Lock()也会阻塞,形成死锁。

特殊库的误用

1
2
3
4
hctx, hcancel := context.WithCancel(ctx)
if timeout > 0 {
hctx, hcancel = context.WithTimeout(ctx, timeout)
}

除了显式的使用channel,go提供了一些lib来在goroutine之间传递消息,上面代码在执行hctx, hcancel := context.WithCancel(ctx)时会创建一个goroutine出来,而当timeout>0时又回创建新的channel赋给同一个变量hcancel,这会导致第一行创建出的channel不会被关闭,也不能再给这个channel发消息。

非阻塞式bug

和阻塞式bug类似,非阻塞式bug也由共享内存和消息传递引起:当试图保护一个共享变量失败时候,或消息传递使用不当时候,都可能造成非阻塞式的bug。

匿名函数

虽然论文中将这一类错误归结为匿名函数的不正确使用,但实际上产生这类bug的原因是工程师忽略了实际上在跨goroutine共享的变量。

1
2
3
4
5
6
for i := 17; i <= 21; i++ { // write
go func() { /* Create a new goroutine */
apiVersion := fmt.Sprintf("v1.%d", i) // read
...
}()
}

如这段代码(也经常出现在面试中),由于变量i在匿名函数构建出的goroutine和主goroutine共享,又不能保证goroutine什么时候执行,所以goroutine中拿到的i并不确定(大概率这几个循环创建出的goroutine拿到的都是21)。

WaitGroup的误用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (p *peer) send() {
p.mu.Lock()
defer p.mu.Unlock()
switch p.status {
case idle:
go func() {
p.wg.Add(1)
...
p.wg.Done()
}()
case stopped:
}
}

func (p * peer) stop() {
p.mu.Lock()
p.status = stopped
p.mu.Unlock()
p.wg.Wait()
}

上面这段代码中,由于不能保证send方法的goroutine什么时候执行,所以可能导致stop函数的p.wg.Wait()在send函数的p.wg.Add(1)之前执行。

特殊库的误用

诸如context这样被设计会在多个goroutine间传递数据的库,在使用时也需要特别注意,可能会导致数据竞争。

Channel的误用

1
2
3
4
5
select {
case <- c.closed:
default:
close(c.closed)
}

由于default语句可能被多次触发,导致一个channel可能被多次关闭,进而造成panic。

1
2
3
4
5
6
7
8
ticker := time.NewTicker()
for{
f() // heavy function
select {
case <- stopCh: return
case <- ticker:
}
}

对于上面这段代码,当f是一个耗时函数时,很可能出现一次for循环后stopCh和ticker两个case同时满足,这时是没法确认先进哪个case的。

特殊库的误用

1
2
3
4
5
6
7
8
9
10
timer := time.NewTimer(0)
if dur > 0 {
timer = time.NewTimer(dur)
}

select {
case <- timer.C:
case <- ctx.Done():
return nil
}

上面这段代码中,第一行创建的timer由于超时时间是0,所以会立刻触发select中的第一个case,导致和期望不符合的行为。

总结

Go的特性使得线程的创建和数据传递都非常容易,但是容易的背后线程间通信的那些坑依然是存在的,论文认为go的消息传递机制会导致更多的bug出现。在我看来,go的消息传递机制相比于传统的共享内存机制,相当于多了一层逻辑层面的封装,这种特性有时会让传统的多线程编程经验不能直接发挥价值,但是只要把握住底层的机制,可以很快积累基于go的语言特性的并发编程经验。