使用 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 采用内嵌运行环境的二进制文件部署,不需要额外的运行环境配置,适合团队共享。

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

相关推荐
果冻人工智能3 小时前
去中心化 AI:赋权还是混乱?
人工智能·深度学习·机器学习·架构·去中心化·区块链·ai员工
二进制coder5 小时前
DeepSeek核心技术全景解析:架构革新与工程突破
ai·架构·agi
数据智能老司机6 小时前
深度学习架构师手册——理解神经网络变换器(Transformers)
深度学习·架构
m0_748246617 小时前
超详细:数据库的基本架构
数据库·架构
CodeJourney.11 小时前
EndNote与Word关联:科研写作的高效助力
数据库·人工智能·算法·架构
m0_7482326411 小时前
鸿蒙NEXT(五):鸿蒙版React Native架构浅析
react native·架构·harmonyos
liruiqiang0514 小时前
DDD - 整洁架构
分布式·微服务·架构
莳花微语16 小时前
使用MyCAT实现分布式MySQL双主架构
分布式·mysql·架构
ITPUB-微风1 天前
Service Mesh在爱奇艺的落地实践:架构、运维与扩展
运维·架构·service_mesh
大腕先生1 天前
微服务环境搭建&架构介绍(附超清图解&源代码)
微服务·云原生·架构