肇鑫的技术博客

业精于勤,荒于嬉

Twitter REST API使用通用流程

步骤

  1. 申请建立新程序(账号需要绑定手机)
  2. 测试OAuth 1.0

各个变量的含义

原文地址:
POST oauth/request_token
Creating a signature

参数 含义
oauth_consumer_key 申请的程序的key
oauth_nonce base64编码的32字节的随机数据。Twitter使用它来查验信息是否属于重复提交
oauth_signature 所有提交的信息的校验码,用于检验内容是否被篡改1
oauth_signature_method 校验码的计算方法,固定为HMAC-SHA1
oauth_timestamp Unix相对于GMT 1970年1月1日的秒数。
oauth_token 代表当前用户的token,首次使用时需要向Twitter申请
oauth_version oauth的版本,目前总是1.0
Consumer secret 你申请App时获得的另外一段文本2
OAuth token secret 调用API后从Twitter网站获得的当前用户的标志
Signing key 计算校验码时的key,由Consumer secret和OAuth token secret加工而成
status 用户发送的tweet的正文

创建校验码的方法

原文地址:
Creating a signature

写在前面

**一定要记住:只有上面原文的例子是真正可用的。**不过这个例子也有一些问题,后面我会提到。

按照流程我们一般会最先到POST oauth/request_token页面去。因为我们不申请授权的话,什么事都做不了。但这里是Twitter官方文档的第一个坑。用来计算oauth_signature需要的key的一部分是Consumer secret,而这个页面根本没有提供它。因此,这个例子里的值是没法计算的。

更坑的是,由于Consumer secretConsumer Key非常像,而后者就是oauth_consumer_key,这个值在这个页面有提供。因此,初学者如我,就很容易把oauth_consumer_key作为Consumer secret来计算oauth_signature。得到的结果自然与例子不同。我在这里浪费了很多的时间。

另外一个页面Authorizing a request也是同样的问题,没有提供Consumer secret,因此也是无法计算的。

结论:强烈建议你在算法设计完成时,使用Creating a signature这个页面提供的例子来检验你的算法是否正确。如果正确了,那么再进行下一步。

需要计算的量

在调用一次API时,需要有很多参数,有一些参数的值是可以直接获得的,比如oauth的版本,signature的校验方式,都是固定的,每次都一样。但也有一些参数需要计算,简单的timestamp的就不提了,下面提几个相对复杂的。

oauth_nonce

oauth_nonce是一个应该每次都不一样的字符串,Twitter通过这个值来验证客户端是否发送了重复的请求。按照官方的说法,这个字符串的计算过程是取一段32个字节的随机数据,然后将这段数据采用base64编码的方式输出为字符串,之后过滤掉其中非词的字符。

and stripping out all non-word characters

这里的非词指的是大小写字母和数字之外的字符。^[a-z|A-Z|0-9]

实现这个的算法有很多,但是有一点我很疑惑。因为官方文档里的几个例子,字符都是42个,但是我按照算法说明生成的,一般都是43个。只有把32个字节,改成31个字节,得到的才是42个。

先说结论,这个字符串是42还是43其实无所谓。后来我的程序确定能通过之后,我发现,有时生成的字符串是45个。也就是说,其实这个字符串,本质上来讲,只要每次不一样就可以,长度其实没有限制。

附上我的算法:

private static func calculateOauthNonce() -> String {
    let uuidString = UUID().uuidString
    let randomStringIn32Bytes = uuidString.substring(to: uuidString.index(uuidString.startIndex, offsetBy: 32))
    let dataOfRandomStringIn32Bytes = randomStringIn32Bytes.data(using: .utf8)!
    let base64EncodedStringOfRandomStringIn32Bytes = dataOfRandomStringIn32Bytes.base64EncodedString()
    
    let modifiedBase64EncodedString = base64EncodedStringOfRandomStringIn32Bytes.unicodeScalars
        .filter {
            CharacterSet.alphanumerics.contains($0)
        }
        .reduce("", { (result, unicodeScalar) -> String in
            let character = Character(unicodeScalar)
            return result + String(character)
        })
    
    return modifiedBase64EncodedString
}

percent encode

在计算headersignature等处,需要使用到percent encode。百分比化,即把不在范围内的字符,转化为带%号的形式。按照官方的规则percent encode的计算方法如下:

let nonePercentedCharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
private func percentedString(_ source:String) -> String {
    return source.addingPercentEncoding(withAllowedCharacters: nonePercentedCharacterSet)!
}

signature base string

signature base string是计算signature的中间步骤。这里有一点要注意,即键值对的key要按照字母顺序排列。这是为了计算校验值的唯一。

这里有一个坑,官方例子提供的值是
POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521

它去掉百分号实际上是

let s2 = "POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521"

print(s2.removingPercentEncoding!)

// POST&https://api.twitter.com/1/statuses/update.json&include_entities=true&oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1318622958&oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&oauth_version=1.0&status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20signed%20OAuth%20request%21

可以看到,url实际是https://api.twitter.com/1/statuses/update.json,但是官方文档中写url是https://api.twitter.com/1.1/statuses/update.json。这里一个是1,一个是1.1,你按照文档的url来算,那么signature肯定就和官方提供的tnnArxj06cWHq44gCs1OSKk/jLY=不一样。

signing key

标准的signing key由两部分组成,百分比化的Consumer secret与百分比化的OAuth token secret,二者按照先后顺序用&链接,需要注意两点:

  1. Consumer secret而不是Consumer Key
  2. &不需要百分比化

当首次申请时,由于没有OAuth token secret,此时的signing key为百分比化的Consumer secret之后加上&

signature

这里直接调用加密的函数就可以,值得说的部分是,官方这里要求使用HMAC-SHA1,但是输出值不是普通的字符串,而是要转成base64编码的字符串输出。网上能找到的例子,一般都只是普通的字符串输出,这里提供我的算法,主要是增加了base64编码的输出,以及一些重构。

import Foundation

enum CryptoAlgorithm {
    case MD5, SHA1, SHA224, SHA256, SHA384, SHA512
    
    var HMACAlgorithm: CCHmacAlgorithm {
        var result: Int = 0
        switch self {
        case .MD5:
            result = kCCHmacAlgMD5
        case .SHA1:
            result = kCCHmacAlgSHA1
        case .SHA224:
            result = kCCHmacAlgSHA224
        case .SHA256:
            result = kCCHmacAlgSHA256
        case .SHA384:
            result = kCCHmacAlgSHA384
        case .SHA512:
            result = kCCHmacAlgSHA512
        }
        return CCHmacAlgorithm(result)
    }
    
    var digestLength: Int {
        var result: Int32 = 0
        switch self {
        case .MD5:
            result = CC_MD5_DIGEST_LENGTH
        case .SHA1:
            result = CC_SHA1_DIGEST_LENGTH
        case .SHA224:
            result = CC_SHA224_DIGEST_LENGTH
        case .SHA256:
            result = CC_SHA256_DIGEST_LENGTH
        case .SHA384:
            result = CC_SHA384_DIGEST_LENGTH
        case .SHA512:
            result = CC_SHA512_DIGEST_LENGTH
        }
        return Int(result)
    }
}

enum HashStyle {
    case string, base64EncodedString
}

extension String {
    func hmac(algorithm: CryptoAlgorithm, key: String, hashStyle:HashStyle = .base64EncodedString) -> String {
        let str = self.cString(using:.utf8)
        let strLen = self.lengthOfBytes(using:.utf8)
        let digestLen = algorithm.digestLength
        let hashBytes = UnsafeMutablePointer<UInt8>.allocate(capacity: digestLen)
        let keyStr = key.cString(using:.utf8)
        let keyLen = Int(key.lengthOfBytes(using:.utf8))
        
        CCHmac(algorithm.HMACAlgorithm, keyStr!, keyLen, str!, strLen, hashBytes)
        
        let digest:String
        switch hashStyle {
        case .string:
            digest = calculateString(from: hashBytes, length: digestLen)
        case .base64EncodedString:
            digest = calculateBase64EncodedString(from: hashBytes, length: digestLen)
        }
        
        hashBytes.deallocate(capacity: digestLen)
        
        return digest
    }
    
    private func calculateString(from hashBytes: UnsafeMutablePointer<UInt8>, length: Int) -> String {
        let string = (0..<length).reduce("") { (result, index) -> String in
            return result + String(hashBytes[index])
        }
        
        return string
    }
    
    private func calculateBase64EncodedString(from hashBytes: UnsafeMutablePointer<UInt8>, length: Int) -> String {
        let data = Data(bytes:hashBytes, count:length)
        
        return data.base64EncodedString()
    }
}

计算header

原文:Authorizing a request
这个没啥好说的,按照说明就行了。header和计算signature不一样,header里的键值对的顺序可以是任意的。

调用API

全都计算好了,就可以申请调用API了。简单的步骤如下:

let url = URL(string: "https://api.twitter.com/oauth/request_token")!

var request = URLRequest(url: url)
request.httpMethod = "POST"

var tt = Twitter()

request.setValue(tt.postOauthRequestToken.header, forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) {
    data, response, error in
    
    if error != nil {
        print("error=\(error!.localizedDescription))")
        return
    }
    
    print("response = \(response!.description)")
    
    let responseString = String(data: data!, encoding: .utf8)!
    print("responseString = \(responseString)")
}
task.resume()

如果看到输出的response里的代码是200,那么就证明你的程序是没有问题的了。

  1. 创建校验码的方法

  2. 需要注意的事,计算校验码时需要的key的一部分是Consumer Secret而不是Consumer Key,Twitter的部分文档的例子里没有强调这一点,并切没有提供例子对应的Consumer Secret,这导致了那些例子你是没法计算的,也就是说例子根本是无效的。研究它们只能浪费时间。这一点后面还会提到。

深入探讨NotificationCenter的addObserver(forName:object:queue:using:)中的queque

先看看下面的代码,这两段notiObserver有区别吗?

override func viewDidLoad() {
    super.viewDidLoad()
    
    let noti = Notification(name: MyNotificationName)
    var v = 0
    let center = NotificationCenter.default
    var notiObserver:NSObjectProtocol! = nil
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: .main) {_ in 
        if v == 10 { center.removeObserver(notiObserver) }
        else {
            v += 1
            center.post(noti)
        }
        print(v)
    }
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: nil) { _ in
        DispatchQueue.main.async {
            if v == 10 { center.removeObserver(notiObserver) }
            else {
                v += 1
                center.post(noti)
            }
            print(v)
        }
    }
    
    center.post(noti)
}

无论你觉得有区别,还是没区别,你可以分别注释掉其中一个然后运行。运行结果是这样的。

上面的那段会输出

10
10
10
10
10
10
10
10
10
10
10

下面那段会输出

1
2
3
4
5
6
7
8
9
10
10

这是为什么呢?我们来看文档。苹果的文档在提到queue是这么说的。

queue
The operation queue to which block should be added.
If you pass nil, the block is run synchronously on the posting thread.

也就是说,它默认是在队列中同步执行。即便我们换成了.main,它也不是我们以为的是异步执行的。实际上,我们可以测试出来,这段代码,在运行时,是堆栈式的。新到的Notification,会进行压栈,优先执行。然后才是旧的Notification没执行完的代码。

如图所示,从左到右是时间轴,依次运行的3段代码,绿色的center.post(noti)导致黄色被执行,黄色的center.post(noti)导致蓝色被执行……,一直到v==10,最后的一段代码print(v),才会从上到下执行下来。

stack
结论:如果我们想要代码符合我们的预期的顺序,应该使用第二种方式,通过GCD,将代码加入到顺序执行的queue中去。

macOS中菜单的处理,以及发送邮件

macOS中菜单的处理

基本原理

在macOS中,菜单项是通过NSResponder来进行传递的。根据的NSResponder文档

NSResponder is an abstract class that forms the basis of event and command processing in AppKit. The core classes—NSApplication, NSWindow, and NSView—inherit from NSResponder, as must any class that handles events.

也就是说,NSApplication, NSWindow, NSView都继承了NSResponder.实际使用中,还需要考虑它们的控制器,即NSWindowControllerNSViewController,以及NSApplication的代理NSApplicationDelegate。结论如下:

  1. 如果你需要同样名字的菜单在不同的情况下,有不同的结果。那么就在NSWindow, NSView或它们的控制器里实现。
  2. 如果你希望菜单的功能一致,就要在NSApplicationNSApplicationDelegate里实现。
  3. 菜单项被点击时,系统会在当前的first responder里进行查找action,找不到就到上一层responder里查找,直到找到或者全部responder查完为止。即顺序为当前视图->当前视图控制器->父视图->父视图控制器->…->当前窗口->当前窗口控制器->当前程序->当前程序代理

发送邮件

让用户通过邮件与开发者联系是常见的功能。代码如下:

//MARK: - Help Menu
extension NSApplication {
    @IBAction func contactDeveloper(_ sender: Any) {
        let mailAddress = "your email address"
        let mailBody = NSLocalizedString("Please use Chinese or English in your mail, if you can.", comment: "mail body")
        let service = NSSharingService(named: NSSharingServiceNameComposeEmail)!
        service.recipients = [mailAddress]
        service.perform(withItems: [mailBody])
    }
}

上面的代码放在AppDelegate.swift的最下面即可。打开故事板,假设你程序的菜单里Help菜单下,有一个叫“Contact Developer”的菜单项,鼠标右键点击这个菜单项,然后拖动它到First Responder,在弹出菜单中选contactDeveloper:就可以了。

menu_action