背景
AIDocumentLibraryChat 專案已擴充套件至生成測試程式碼(Java 程式碼已透過測試)。該專案可為公開的 Github 專案生成測試程式碼。只需提供要測試的類的網址,該類就會被載入、分析匯入,專案中的依賴類也會被載入。這樣,LLM 就有機會在為測試生成模擬時考慮匯入的源類。可以提供 testUrl,為 LLM 提供一個示例,以便生成測試。我們已使用 Ollama 對 granite-code、deepseek-coder-v2 和 codestral 模型進行了測試。
目的是測試 LLM 對開發人員建立測試的幫助有多大。
實施
配置
要選擇 LLM 模型,需要更新 application-ollama.properties 檔案
spring.ai.ollama.base-url=${OLLAMA-BASE-URL:http://localhost:11434}
spring.ai.ollama.embedding.enabled=false
spring.ai.embedding.transformer.enabled=true
document-token-limit=150
embedding-token-limit=500
spring.liquibase.change-log=classpath:/dbchangelog/db.changelog-master-ollama.xml...
# generate code
#spring.ai.ollama.chat.model=granite-code:20b
#spring.ai.ollama.chat.options.num-ctx=8192spring.ai.ollama.chat.options.num-thread=8
spring.ai.ollama.chat.options.keep_alive=1s#spring.ai.ollama.chat.model=deepseek-coder-v2:16b
#spring.ai.ollama.chat.options.num-ctx=65536spring.ai.ollama.chat.model=codestral:22b
spring.ai.ollama.chat.options.num-ctx=32768
spring.ai.olama.chat.model "選擇要使用的 LLM 程式碼模型。
spring.ollama.chat.options.num-ctx "設定上下文視窗中的令牌數量。上下文視窗包含請求所需的令牌和響應所需的令牌。
如果 Ollama 沒有選擇正確的核心數量,可以使用 “spring.olama.chat.options.num-thread”。spring.olama.chat.options.keep_alive "設定了保留上下文視窗的秒數。
Controller
Controller是獲取訊號源和生成測試的介面:
@RestController
@RequestMapping("rest/code-generation")
public class CodeGenerationController {
private final CodeGenerationService codeGenerationService;public CodeGenerationController(CodeGenerationService
codeGenerationService) {
this.codeGenerationService = codeGenerationService;
}@GetMapping("/test")
public String getGenerateTests(@RequestParam("url") String url,
@RequestParam(name = "testUrl", required = false) String testUrl) {
return this.codeGenerationService.generateTest(URLDecoder.decode(url,
StandardCharsets.UTF_8),
Optional.ofNullable(testUrl).map(myValue -> URLDecoder.decode(myValue,
StandardCharsets.UTF_8)));
}@GetMapping("/sources")
public GithubSources getSources(@RequestParam("url") String url,
@RequestParam(name="testUrl", required = false) String testUrl) {
var sources = this.codeGenerationService.createTestSources(
URLDecoder.decode(url, StandardCharsets.UTF_8), true);
var test = Optional.ofNullable(testUrl).map(myTestUrl ->
this.codeGenerationService.createTestSources(
URLDecoder.decode(myTestUrl, StandardCharsets.UTF_8), false))
.orElse(new GithubSource("none", "none", List.of(), List.of()));
return new GithubSources(sources, test);
}
}
程式碼生成控制器 “有一個 ”getSources(...) "方法。該方法獲取要生成測試的類的 URL 和可選的 testUrl,以及可選的示例測試。它對請求引數進行解碼,並呼叫 “createTestSources(...) ”方法。該方法會返回 “GithubSources”,其中包含要測試的類的原始碼、其在專案中的依賴關係以及測試示例。
getGenerateTests(...) “方法獲取測試類的 ”url “和可選的 ”testUrl “以進行 url 解碼,並呼叫 ”CodeGenerationService “的 ”generateTests(...) "方法。
Services
CodeGenerationService "從 Github 收集類,併為被測類生成測試程式碼。
帶有提示的服務看起來像這樣:
@Service
public class CodeGenerationService {
private static final Logger LOGGER = LoggerFactory
.getLogger(CodeGenerationService.class);
private final GithubClient githubClient;
private final ChatClient chatClient;
private final String ollamaPrompt = """
You are an assistant to generate spring tests for the class under test.
Analyse the classes provided and generate tests for all methods. Base
your tests on the example.
Generate and implement the test methods. Generate and implement complete
tests methods.
Generate the complete source of the test class.
Generate tests for this class:
{classToTest}Use these classes as context for the tests:
{contextClasses}{testExample}
""";
private final String ollamaPrompt1 = """
You are an assistant to generate a spring test class for the source
class.
1. Analyse the source class
2. Analyse the context classes for the classes used by the source class
3. Analyse the class in test example to base the code of the generated
test class on it.
4. Generate a test class for the source class and use the context classes
as sources for creating the test class.
5. Use the code of the test class as test example.
6. Generate tests for each of the public methods of the source class.
Generate the complete source code of the test class implementing the
tests.{testExample}
Use these context classes as extension for the source class:
{contextClasses}
Generate the complete source code of the test class implementing the
tests.
Generate tests for this source class:
{classToTest}
""";
@Value("${spring.ai.ollama.chat.options.num-ctx:0}")
private Long contextWindowSize;public CodeGenerationService(GithubClient githubClient, ChatClient
chatClient) {
this.githubClient = githubClient;
this.chatClient = chatClient;
}
這是帶有 “GithubClient ”和 “ChatClient ”的 “CodeGenerationService”。GithubClient 用於從公開可用的資源庫載入原始碼,而 ChatClient 是 Spring AI 介面,用於訪問 AI/LLM。
ollamaPrompt "是 IBM Granite LLM 的提示符,上下文視窗有 8k 個 token。classToTest}(測試類)用被測類的原始碼代替。{contextClasses}“可以替換為被測類的從屬類,”{testExample}"是可選項,可以替換為測試類,作為程式碼生成的示例。
olamaPrompt2 "是 Deepseek Coder V2 和 Codestral LLM 的提示符。這些 LLM 可以 “理解 ”或使用思維鏈提示,其上下文視窗超過 32k 個片語。{...}“佔位符的工作原理與 ”olamaPrompt "相同。由於上下文視窗較長,因此可以為程式碼生成新增上下文類。
Spring 注入了 “contextWindowSize ”屬性,用於控制 LLM 的上下文視窗是否足夠大,以便在提示符中新增“{contextClasses}”。
我們也可抽取提示詞,完善後,單獨測試下
https://lxblog.com/qianwen/share?shareId=6ebef3fc-1c25-4abf-aa11-7122e540e964
示例提示詞
You are an assistant to generate a spring test class for the source class.
1. Analyse the source class
2. Analyse the context classes for the classes used by the source class
3. Analyse the class in test example to base the code of the generated test class on it.
4. Generate a test class for the source class and use the context classes as sources for creating the test class.
5. Use the code of the test class as test example.
6. Generate tests for each of the public methods of the source class.Your additional guidelines:
1.Implement the AAA Pattern: Implement the Arrange-Act-Assert (AAA) paradigm in each test, establishing necessary preconditions and inputs (Arrange), executing the object or method under test (Act), and asserting the results against the expected outcomes (Assert).
2.Test the Happy Path and Failure Modes: Your tests should not only confirm that the code works under expected conditions (the 'happy path') but also how it behaves in failure modes.
3.Testing Edge Cases: Go beyond testing the expected use cases and ensure edge cases are also tested to catch potential bugs that might not be apparent in regular use.
4.Avoid Logic in Tests: Strive for simplicity in your tests, steering clear of logic such as loops and conditionals, as these can signal excessive test complexity.
5.Leverage TypeScript's Type System: Leverage static typing to catch potential bugs before they occur, potentially reducing the number of tests needed.
6.Handle Asynchronous Code Effectively: If your test cases involve promises and asynchronous operations, ensure they are handled correctly.
7.Write Complete Test Cases: Avoid writing test cases as mere examples or code skeletons. You have to write a complete set of tests. They should effectively validate the functionality under test.Generate the complete source code of the test class implementing the tests.
{
@ExtendWith(MockitoExtension.class)
public class MockitoHelloTest {@Mock
RecordDao mockDao;@Mock
NotificationService mockNotification;@Mock
SequenceGenerator mockGenerator;@InjectMocks
RecordService service;@Test
public void testSaveRecord() {Record record = new Record();
record.setName("Test Record");
when(mockGenerator.getNext()).thenReturn(100L);
when(mockDao.saveRecord(record)).thenReturn(record);
Record savedRecord = service.saveRecord(record);
verify(mockGenerator, times(1)).getNext();
verify(mockDao, times(1)).saveRecord(any(Record.class));
assertEquals("Test Record", savedRecord.getName());
assertEquals(100L, savedRecord.getId());
}
}
}Use these context classes as extension for the source class:
{package com.app.login.domain;
@Entity
@Table(name = "app_user")
@Getter
@Setter
public class User implements Serializable {private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;@NotNull
@Pattern(regexp = Constants.LOGIN_REGEX)
@Size(min = 1, max = 50)
@Column(length = 50, unique = true, nullable = false)
private String login;@JsonIgnore
@NotNull
@Size(min = 60, max = 60)
@Column(name = "password_hash", length = 60)
private String password;@Size(max = 50)
@Column(name = "first_name", length = 50)
private String firstName;@Size(max = 50)
@Column(name = "last_name", length = 50)
private String lastName;@Size(min = 5, max = 100)
@Column(length = 100, unique = true)
private String email;@Column(name = "created_date")
private Instant createdDate = Instant.now();@NotNull
@Column(nullable = false)
private boolean activated = false;@Size(min = 2, max = 5)
@Column(name = "lang_key", length = 5)
private String langKey;@Size(max = 256)
@Column(name = "image_url", length = 256)
private String imageUrl;@Size(max = 20)
@Column(name = "activation_key", length = 20)
@JsonIgnore
private String activationKey;@Size(max = 20)
@Column(name = "reset_key", length = 20)
@JsonIgnore
private String resetKey;@Column(name = "reset_date")
private Instant resetDate = null;@Size(min = 7, max = 15)
@Column(name = "ip_address", length = 15)
String ipAddress;@JsonIgnore
@ManyToMany
@JoinTable(name = "app_user_authority", joinColumns = { @JoinColumn(name = "user_id", referencedColumnName = "id") }, inverseJoinColumns = { @JoinColumn(name = "authority_name", referencedColumnName = "name") })
@BatchSize(size = 20)
private Set<Authority> authorities = new HashSet<>();
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}User user = (User) o;
return login.equals(user.login);
}@Override
public int hashCode() {
return login.hashCode();
}}
@Entity
@Table(name = "app_authority")
public class Authority implements Serializable {private static final long serialVersionUID = 1L;
@NotNull
@Size(min = 0, max = 50)
@Id
@Column(length = 50)
private String name;public String getName() {
return name;
}public void setName(String name) {
this.name = name;
}@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}Authority authority = (Authority) o;
return !(name != null ? !name.equals(authority.name) : authority.name != null);
}@Override
public int hashCode() {
return name != null ? name.hashCode() : 0;
}
}
package com.app.login.repository;
public interface UserRepository extends JpaRepository<User, Long> {Optional<User> findOneByActivationKey(String activationKey);
Optional<User> findOneByResetKey(String resetKey);
Optional<User> findOneByEmail(String email);
Optional<User> findOneByLogin(String login);
@EntityGraph(attributePaths = "authorities")
User findOneWithAuthoritiesById(Long id);@EntityGraph(attributePaths = "authorities")
Optional<User> findOneWithAuthoritiesByLogin(String login);Page<User> findAllByLoginNot(Pageable pageable, String login);
List<User> findAllByIpAddressAndCreatedDateBetween(String ipAddress, Instant startDate, Instant currentDate);
}package com.app.login.security;
public class UserNotActivatedException extends AuthenticationException {private static final long serialVersionUID = 1L;
public UserNotActivatedException(String message) {
super(message);
}public UserNotActivatedException(String message, Throwable t) {
super(message, t);
}
}}
Generate the complete source code of the test class implementing the tests.
Generate tests for this source class:package com.app.login.security;
import com.app.login.domain.User;
import com.app.login.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Component("userDetailsService")
@Slf4j
public class DomainUserDetailsServiceImpl implements UserDetailsService {private final UserRepository userRepository;
public DomainUserDetailsServiceImpl(UserRepository userRepository) {
this.userRepository = userRepository;
}@Override
@Transactional(rollbackFor = Exception.class)
public UserDetails loadUserByUsername(final String login) {
if (login==null){
throw new UsernameNotFoundException("User login is null");
}
log.debug("Authenticating {}", login);
String lowercaseLogin = login.toLowerCase(Locale.ENGLISH);
Optional<User> userFromDatabase = userRepository.findOneWithAuthoritiesByLogin(lowercaseLogin);
return userFromDatabase.map(user -> {
if (!user.getActivated()) {
throw new UserNotActivatedException("User " + lowercaseLogin + " was not activated");
}
List<GrantedAuthority> grantedAuthorities = user.getAuthorities()
.stream()
.map(authority -> new SimpleGrantedAuthority(authority.getName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(lowercaseLogin, user.getPassword(), grantedAuthorities);
})
.orElseThrow(() -> new UsernameNotFoundException("User " + lowercaseLogin + " was not found in the " + "database"));
}
}}
方法 “createTestSources(...) ”收集並返回 AI/LLM 提示的原始碼:
public GithubSource createTestSources(String url, final boolean
referencedSources) {
final var myUrl = url.replace("https://github.com",
GithubClient.GITHUB_BASE_URL).replace("/blob", "");
var result = this.githubClient.readSourceFile(myUrl);
final var isComment = new AtomicBoolean(false);
final var sourceLines = result.lines().stream().map(myLine ->
myLine.replaceAll("[\t]", "").trim())
.filter(myLine -> !myLine.isBlank()).filter(myLine ->
filterComments(isComment, myLine)).toList();
final var basePackage = List.of(result.sourcePackage()
.split("\\.")).stream().limit(2)
.collect(Collectors.joining("."));
final var dependencies = this.createDependencies(referencedSources, myUrl,
sourceLines, basePackage);
return new GithubSource(result.sourceName(), result.sourcePackage(),
sourceLines, dependencies);
}private List<GithubSource> createDependencies(final boolean
referencedSources, final String myUrl, final List<String> sourceLines,
final String basePackage) {
return sourceLines.stream().filter(x -> referencedSources)
.filter(myLine -> myLine.contains("import"))
.filter(myLine -> myLine.contains(basePackage))
.map(myLine -> String.format("%s%s%s",
myUrl.split(basePackage.replace(".", "/"))[0].trim(),
myLine.split("import")[1].split(";")[0].replaceAll("\\.",
"/").trim(), myUrl.substring(myUrl.lastIndexOf('.'))))
.map(myLine -> this.createTestSources(myLine, false)).toList();
}private boolean filterComments(AtomicBoolean isComment, String myLine) {
var result1 = true;
if (myLine.contains("/*") || isComment.get()) {
isComment.set(true);
result1 = false;
}
if (myLine.contains("*/")) {
isComment.set(false);
result1 = false;
}
result1 = result1 && !myLine.trim().startsWith("//");
return result1;
}
方法 “createTestSources(...) ”使用 Github 原始碼 “url”,並根據 “referencedSources ”的值提供專案中依賴類的原始碼 “GithubSource ”記錄。
為此,我們建立了 “myUrl ”來獲取類的原始原始碼。然後使用 “githubClient ”以字串形式讀取原始檔。然後使用 “filterComments(...) ”方法將原始碼字串轉換為不帶格式和註釋的原始碼行。
要讀取專案中的依賴類,需要使用基礎包。例如,在 “ch.xxx.aidoclibchat.usecase.service ”包中,基礎包是 “ch.xxx"。方法 “createDependencies(...) ”用於為基礎軟體包中的依賴類建立 “GithubSource ”記錄。使用 “basePackage ”引數篩選出類,然後遞迴呼叫 “createTestSources(...) ”方法,並將引數 “referencedSources ”設為 false 以停止遞迴。這就是依賴類 “GithubSource ”記錄的建立過程。
generateTest(...) "方法用於使用 AI/LLM 建立被測類的測試源:
public String generateTest(String url, Optional<String> testUrlOpt) {
var start = Instant.now();
var githubSource = this.createTestSources(url, true);
var githubTestSource = testUrlOpt.map(testUrl ->
this.createTestSources(testUrl, false))
.orElse(new GithubSource(null, null, List.of(), List.of()));
String contextClasses = githubSource.dependencies().stream()
.filter(x -> this.contextWindowSize >= 16 * 1024)
.map(myGithubSource -> myGithubSource.sourceName() + ":" +
System.getProperty("line.separator")
+ myGithubSource.lines().stream()
.collect(Collectors.joining(System.getProperty("line.separator")))
.collect(Collectors.joining(System.getProperty("line.separator")));
String testExample = Optional.ofNullable(githubTestSource.sourceName())
.map(x -> "Use this as test example class:" +
System.getProperty("line.separator") +
githubTestSource.lines().stream()
.collect(Collectors.joining(System.getProperty("line.separator"))))
.orElse("");
String classToTest = githubSource.lines().stream()
.collect(Collectors.joining(System.getProperty("line.separator")));
LOGGER.debug(new PromptTemplate(this.contextWindowSize >= 16 * 1024 ?
this.ollamaPrompt1 : this.ollamaPrompt, Map.of("classToTest",
classToTest, "contextClasses", contextClasses, "testExample",
testExample)).createMessage().getContent());
LOGGER.info("Generation started with context window: {}",
this.contextWindowSize);
var response = chatClient.call(new PromptTemplate(
this.contextWindowSize >= 16 * 1024 ? this.ollamaPrompt1 :
this.ollamaPrompt, Map.of("classToTest", classToTest, "contextClasses",
contextClasses, "testExample", testExample)).create());
if((Instant.now().getEpochSecond() - start.getEpochSecond()) >= 300) {
LOGGER.info(response.getResult().getOutput().getContent());
}
LOGGER.info("Prompt tokens: " +
response.getMetadata().getUsage().getPromptTokens());
LOGGER.info("Generation tokens: " +
response.getMetadata().getUsage().getGenerationTokens());
LOGGER.info("Total tokens: " +
response.getMetadata().getUsage().getTotalTokens());
LOGGER.info("Time in seconds: {}", (Instant.now().toEpochMilli() -
start.toEpochMilli()) / 1000.0);
return response.getResult().getOutput().getContent();
}
為此,我們使用 “createTestSources(...) ”方法建立包含原始碼行的記錄。然後建立字串 “contextClasses ”來替換提示符中的“{contextClasses}”佔位符。如果上下文視窗小於 16k 位元組,則字串為空,以便為被測類和測試示例類提供足夠的位元組。然後建立可選的 “testExample ”字串,以替換提示符中的“{testExample}”佔位符。如果沒有提供 “testUrl”,則字串為空。然後建立 “classToTest ”字串來替換提示符中的“{classToTest}”佔位符。
呼叫 “chatClient ”將提示傳送到人工智慧/LLM。提示將根據 “contextWindowSize ”屬性中上下文視窗的大小進行選擇。PromptTemplate "會用準備好的字串替換佔位符。
響應 "用於記錄提示標記、生成標記和總標記的數量,以便檢查上下文視窗邊界是否得到遵守。然後記錄生成測試源的時間,並返回測試源。如果生成測試源的時間超過 5 分鐘,則會記錄測試源,以防止瀏覽器超時。
透過測試,兩種模型都能生成 Spring 控制器測試和 Spring 服務測試。測試網址如下
http://localhost:8080/rest/code-generation/test?url=https://github.com/Angular2Guy/MovieManager/blob/master/backend/src/main/java/ch/xxx/moviemanager/adapter/controller/ActorController.java&testUrl=https://github.com/Angular2Guy/MovieManager/blob/master/backend/src/test/java/ch/xxx/moviemanager/adapter/controller/MovieControllerTest.java
http://localhost:8080/rest/code-generation/test?url=https://github.com/Angular2Guy/MovieManager/blob/master/backend/src/main/java/ch/xxx/moviemanager/usecase/service/ActorService.java&testUrl=https://github.com/Angular2Guy/MovieManager/blob/master/backend/src/test/java/ch/xxx/moviemanager/usecase/service/MovieServiceTest.java
Ollama 上的 “granite-code:20b ”LLM 的上下文視窗只有 8k 個 token。這對於提供 “contextClasses ”並有足夠的標記進行響應來說太小了。這意味著 LLM 只能使用被測類和測試示例。
Ollama 上的 “deepseek-coder-v2:16b ”和 “codestral:22b ”LLM 的上下文視窗超過 32k 個 token。這使得 “contextClasses”(上下文類)可以新增到提示中,從而使模型可以與思維鏈提示一起工作。
測試結果
Granite-Code LLM 能夠為 Spring 服務測試生成錯誤但有用的基礎。沒有一個測試成功,但缺失的部分可以用缺失的上下文類來解釋。Spring Controller 測試則不太理想。它遺漏了太多程式碼,無法作為有用的基礎。在中等配置的膝上型電腦上生成測試花費了 10 多分鐘。
Deepseek-Coder-V2 LLM 能夠建立一個 Spring 服務測試,其中大部分測試都能正常工作。這是一個很好的工作基礎,缺失的部分很容易修復。Spring Controller 測試的錯誤較多,但也是一個有用的起點。在中等配置的膝上型電腦 CPU 上生成測試不到十分鐘。
Codestral LLM 能夠建立一個 Spring 服務測試,只有一個測試失敗。這個更復雜的測試需要一些修正。Spring Controller 測試只有 1 個失敗的測試用例,但這是因為缺少一個配置呼叫,導致測試成功而沒有進行測試。兩個生成的測試都是一個很好的起點。在中等配置的膝上型電腦 CPU 上生成測試花費了半個多小時。
結論
Deepseek-Coder-V2 和 Codestral LLM 可以幫助編寫 Spring 應用程式的測試。Codestal 是更好的模型,但需要更多的處理能力和記憶體。這兩個模型都需要 GPU 加速,才能用於生產。即使有可用的上下文類,LLM 也無法正確建立非瑣碎程式碼。LLM 所能提供的幫助非常有限,因為 LLM 並不理解程式碼。程式碼對 LLM 來說只是字元,如果不瞭解語言語法,結果就不會令人印象深刻。開發人員必須能夠修復測試中的所有錯誤,並新增斷言等缺失部分。這意味著它只是節省了一些鍵入測試的時間。
Github Copilot 的使用體驗與 Granite-Code LLM 類似。在 2024 年 9 月,上下文視窗太小,無法很好地生成程式碼,程式碼自動補全建議也經常被忽略。
LLM 是否有幫助 -> 是的
LLM 是否節省大量時間 -> 不是
今天先到這兒,希望對雲原生,技術領導力, 企業管理,系統架構設計與評估,團隊管理, 專案管理, 產品管理,資訊保安,團隊建設 有參考作用 , 您可能感興趣的文章:
構建創業公司突擊小團隊
國際化環境下系統架構演化
微服務架構設計
影片直播平臺的系統架構演化
微服務與Docker介紹
Docker與CI持續整合/CD
網際網路電商購物車架構演變案例
網際網路業務場景下訊息佇列架構
網際網路高效研發團隊管理演進之一
訊息系統架構設計演進
網際網路電商搜尋架構演化之一
企業資訊化與軟體工程的迷思
企業專案化管理介紹
軟體專案成功之要素
人際溝通風格介紹一
精益IT組織與分享式領導
學習型組織與企業
企業創新文化與等級觀念
組織目標與個人目標
初創公司人才招聘與管理
人才公司環境與企業文化
企業文化、團隊文化與知識共享
高效能的團隊建設
專案管理溝通計劃
構建高效的研發與自動化運維
某大型電商雲平臺實踐
網際網路資料庫架構設計思路
IT基礎架構規劃方案一(網路系統規劃)
餐飲行業解決方案之客戶分析流程
餐飲行業解決方案之採購戰略制定與實施流程
餐飲行業解決方案之業務設計流程
供應鏈需求調研CheckList
企業應用之效能實時度量系統演變
如有想了解更多軟體設計與架構, 系統IT,企業資訊化, 團隊管理 資訊,請關注我的微信訂閱號:
作者:Petter Liu
出處:http://www.cnblogs.com/wintersun/
本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。
該文章也同時釋出在我的獨立部落格中-Petter Liu Blog。