使用 Golang 实现 iOS 网络层的 IoC

在 iOS 开发中,将配置类信息与代码混合在一起是很常见的做法,虽然这种方法在开发过程中很方便,但也会带来一些潜在的问题。尽管 iOS 社区提供了 Alamofire 等各种出色的网络工具包,但这种问题在网络层中尤为突出。不过,有多种方法可以在更高层次上有效管理 URL 和 Endpoint,从而减轻其中的一些挑战。

swift 复制代码
AF.request("https://bff.cyou/")
AF.request("https://bff.cyou/post", method: .post)
AF.request("https://bff.cyou/put", method: .put)
AF.request("https://bff.cyou/delete", method: .delete)

例如,通过 extensions 进行管理。那有没有更加优雅的方式呢?

swift 复制代码
extension URL {
    static var recommendations: URL {
        URL(string: "https://ilove.pet/recommendations")!
    }

    static func article(withID id: Article.ID) -> URL {
        URL(string: "https://ilove.pet/articles/\(id)")!
    }
}

控制反转(IoC)是一种设计模式,在这种模式下,计算机程序中自定义编写的部分从通用框架接收控制流。Spring 框架在 Java 编程语言中引入这一概念以及依赖注入(Dependency Injection)方面发挥了关键作用。这种标准化大大改善了 Java 中大型项目的管理。在 iOS 开发领域,使用 Xcode 通过 plist 配置文件管理 URL 的做法也符合控制反转 (IoC) 的核心原则。

1. 使用 yaml 描述配置

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>baseURL</key>
	<string>https://api.bff.cyou</string>
	<key>services</key>
	<array>
		<dict>
			<key>name</key>
			<string>login</string>
			<key>path</key>
			<string>/login</string>
			<key>description</key>
			<string>login</string>
			<key>bypass</key>
			<true/>
      <key>method</key>
      <string>POST</string>
		</dict>
		<dict>
			<key>name</key>
			<string>logout</string>
			<key>path</key>
			<string>/logout</string>
			<key>description</key>
			<string>logout</string>
      <key>method</key>
      <string>POST</string>
		</dict>
	</array>
</dict>
</plist>

以上是一个通过 Property List 定义的 endpoint,可以看到相当的繁琐,我们尝试把它变成 yaml 试试。

yaml 复制代码
baseURL: https://api.bff.cyou
services:
    - { name: login, path: /login, description: login, bypass: true }
    - { name: logout, path: /logout, description: logout }

可以看到,通过 yaml 描述的网络配置文件相当的简洁,便于维护。在 Swift 中可以通过 Yams 这个第三方 framework 解析 yaml 文件。

swift 复制代码
enum ConfigError: Error {
    case NotFound(String)
}

// Use for parse config contents with yaml format.
struct Config: Codable, Equatable {
    struct Environment: Codable, Equatable {
        var env: String
        var url: String
    }
    
    enum Method: String, Codable, Equatable {
        case GET = "GET"
        case POST = "POST"
        case PUT = "PUT"
        case DELETE = "DELETE"
    }
    
    struct Endpoint: Codable, Equatable {
        var name: String
        var path: String
        var description: String?
        var method: Method?
        var baseAPI: String?
        var bypass: Bool?
    }
}

class ConfigManager {
    static let shared = ConfigManager()
    
    fileprivate let configName = "config.yml"
    
    var config: Config!
    
    init() {
        do {
            self.config = try loadConfig()
        } catch {
            fatalError("Can not load config file.")
        }
    }
    
    private func loadConfig() throws -> Config {
        if let path = Bundle.main.path(forResource: configName, ofType: nil) {
            let contents = try String(contentsOfFile: path, encoding: .utf8)
            let config = try YAMLDecoder().decode(Config.self, from: contents)
            return config
        } else {
            throw ConfigError.NotFound("Can not find config file.")
        }
    }
}

2. 网络核心逻辑的处理

通过配置的信息,我们可以写一个简单的网络请求逻辑,只需要传入 endpoint 的路径名称即可,不需要再额外维护。另外,默认情况下的请求 host 也可以直接从配置读取。

swift 复制代码
// Some basic definition has been omitted, complete codes can be found at ...
public class NetworkCore {
    public static let shared = NetworkCore()
    
    var baseURL = ""
    
    private(set) var taskQueue = NSMutableArray()
    
    lazy var sessionManager: Alamofire.Session =  Alamofire.Session(configuration: URLSessionConfiguration.default, startRequestsImmediately: false)
    
    private init() {
        let configManager = ConfigManager.shared
        self.baseURL = configManager.config.baseURL
    }
    
    /**
        Send request.
        Usage exapmle: NetworkCore.shared.request(api: ServiceName.Login, complete: nil)
     */
    @discardableResult
    func request<T, K>(api: T, parameters: K = Alamofire.Empty?.none, headers: HTTPHeaders = HTTPHeaders(), complete: @escaping (DataResponse<Any, Error>) -> Void) -> DataRequest where T: APICovertable, K: Encodable {
        let apiItem: APIItem = api.convert() as! APIItem
        let url: URLConvertible = self.baseURL + apiItem.path
        // Set http headers, token, etc.
        let request = self.sessionManager.request(url, method: apiItem.method, parameters: parameters, headers: headers)
        request.validate().responseData { dataResponse in
            // deal response
        }.resume()
        return request
    }
}

直接运行这个代码会报错,因为我们还没定义ServiceName呢,如果手工创建这个数据结构,看起来会是这样的。

swift 复制代码
/// ServiceName is a enum for all services in this project.
public enum ServiceName: String, Codable, Equatable {
    /// Login
    case Login = "login"
    /// Logout
    case Logout = "logout"
}

可以看到这个 ServiceName 的枚举和yaml中配置完全一致!如果可以自动化的生成这个数据结构就好了。

3. 完成这个拼图!

如果想要自动化的完成这个过程,我们需要使用 Golang 先写一个命令行工具,以实现以下的工作流。

flowchart LR start{start} --> step1[1. 通过配置生成 ServiceName] --> step2[2. 编译项目] --> ed{end}

ioc-script 主要的代码片段:

golang 复制代码
func renderTemplate(config *ConfigModule, out string) error {
	tc := res.Template
	timeSlot := "#CREATE_TIME#"
	contentSlot := "#CONTENT#"
	now := time.Now().Format("2006/01/02 15:04:05")
	tc = strings.Replace(tc, timeSlot, now, -1)

	var content string
	for _, serv := range config.Services {
		if serv.Description != "" {
			content += "\t/// " + serv.Description + "\n"
		} else {
			content += "\n"
		}
		content += fmt.Sprintf("\tcase %s = \"%s\"\n", capitalize(serv.Name), serv.Name)
	}

	tc = strings.Replace(tc, contentSlot, content, -1)

	if err := os.WriteFile(out, []byte(tc), 0755); err != nil {
		return fmt.Errorf("write file %s error. %s", out, err)
	}
	return nil
}

这个 golang 脚本的功能是读取项目中的 config.yml,使用文件内的配置替换预制的脚本,并将包含 ServiceName 的 swift 文件写入指定的路径。

将脚本编译为二进制文件 ioc-script ,放到项目目录中。由于 Swift 编译器要求在编译代码时,Swift 文件不能发生变更,所以有2个时机可以执行这个脚本。

pod install的时候执行,如果你使用了Cocoapods作为包管理器。如以下 Podfile 的一个示例。

ruby 复制代码
post_install do |installer|
  puts "根据配置生成Service.swift"
  system("./scripts/ioc-script -c './ioc-demo/config.yml' -o './ioc-demo/network/Services.swift'")
  puts "生成Service.swift完成!"
end

**新建一个 Aggregate的 Target 执行,如果你使用了 SPM 、 Carthage 或者其他情况。**这个 Target 必须早于项目的编译阶段。在新建的 Target 中,添加一个 Run Script 阶段,执行以下的命令行,其中路径需要根据你项目的具体情况进行修改。

shell 复制代码
./ioc-script/dist/ioc-script -c "./$PROJECT_NAME/config.yml" -o "./$PROJECT_NAME/network/Services.swift"

你需要单独运行一次 ioc-script 这个 Target 以生成一次 ServiceName,方便编码过程。之后,当你构建项目时,这个脚本会确保每次都根据 config.yml 中的内容更新这个类。

在示例的项目中,主要的截图如下。

4. 一些问题

Intel 和 Apple silicon CPU问题

默认情况下,golang 总会根据当前的 CPU 架构编译二进制文件,如果你的团队中同时有 Intel 和 Apple silicon 的设备,这个脚本可能在不同的 CPU 架构下不能正常工作。

解决方案其实很简单,就想在 iOS SDK 开发过程中,我们使用 lipo 将不同架构的 framework 进行合并,现在也可以使用这个命令将 golang 编译产生的二进制文件进行合并操作,只需通过以下的 Makefile 脚本对 golang 代码进行编译。编译产生的 fat binary 即可同时支持 Intel 和 Apple silicon 两种 CPU 架构。

makefile 复制代码
GO ?= $(shell command -v go 2> /dev/null)
GOPATH ?= $(shell go env GOPATH)
GO_BUILD_FLAGS ?=
BINARY_NAME ?= "ioc-script"
LIPO ?= $(shell command -v lipo 2> /dev/null)

.PHONY: dist
dist:
	rm -rf dist
	mkdir -p dist
	@echo Build binary for darwin-amd64
	GOOS=darwin GOARCH=amd64 $(GO) build -o dist/$(BINARY_NAME)-darwin-amd64
	@echo Build binary for darwin-arm64
	GOOS=darwin GOARCH=arm64 $(GO) build -o dist/$(BINARY_NAME)-darwin-arm64
	@echo Combine binaries into universal binary
	# 通过 lipo 命令将 2 个 CPU 架构的二进制包合成为一个 fat binary
	$(LIPO) -create dist/$(BINARY_NAME)-darwin-amd64 dist/$(BINARY_NAME)-darwin-arm64 -output dist/$(BINARY_NAME)
	@echo Clen jobs
	rm -rf dist/$(BINARY_NAME)-darwin-amd64 dist/$(BINARY_NAME)-darwin-arm64

ENABLE_USER_SCRIPT_SANDBOXING 访问权限问题

在 Xcode 中执行脚本报错,查看日志发现 Sandbox: ioc-script(5543) deny(1) file-read-data xxx/ioc-demo/ioc-script/dist/ioc-script 相关信息。

依次执行以下步骤:

  1. 在 macOS 系统设置,隐私与安全性中,打开对于 Xcode 的 完全磁盘访问权限
  2. 在 Xcode 中,在 Project 的 Build Setting 中搜索 ENABLE_USER_SCRIPT_SANDBOXING,修改为 NO

5. 总结

通过以上的代码和配置,我们实现了一个基于 yaml 配置的 iOS 网络层 IoC。本文只是提供了基础的思路和演示代码,实际上在使用的时候,可以基于此方案做更多的延伸,比如可以通过更加复杂的配置管理系统生成配置文件、增加更多的网络层定义与约束等等。

本文采用 golang 用于命令行工具的构建的考虑是,

  1. 直接采用 Shell 语句其实也可以完成文中的逻辑,但是缺乏扩展性,Shell 代码在规模扩大时难以管理。
  2. golang 采用内嵌运行环境的二进制文件部署,不需要额外的运行环境配置,适合团队共享。

本文采用的源码可以在这里找到。

相关推荐
yunteng52117 小时前
通用架构(同城双活)(单点接入)
架构·同城双活·单点接入
麦聪聊数据18 小时前
Web 原生架构如何重塑企业级数据库协作流?
数据库·sql·低代码·架构
程序员侠客行19 小时前
Mybatis连接池实现及池化模式
java·后端·架构·mybatis
bobuddy20 小时前
射频收发机架构简介
架构·射频工程
桌面运维家20 小时前
vDisk考试环境IO性能怎么优化?VOI架构实战指南
架构
一个骇客1 天前
让你的数据成为“操作日志”和“模型饲料”:事件溯源、CQRS与DataFrame漫谈
架构
鹏北海-RemHusband1 天前
从零到一:基于 micro-app 的企业级微前端模板完整实现指南
前端·微服务·架构
2的n次方_1 天前
Runtime 内存管理深化:推理批处理下的内存复用与生命周期精细控制
c语言·网络·架构
前端市界1 天前
用 React 手搓一个 3D 翻页书籍组件,呼吸海浪式翻页,交互体验带感!
前端·架构·github
文艺理科生1 天前
Nginx 路径映射深度解析:从本地开发到生产交付的底层哲学
前端·后端·架构